mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-02 15:06:21 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63b7d60e90 | ||
|
|
2c3f94f0ba | ||
|
|
745a1c0ee6 | ||
|
|
7188e22bdc | ||
|
|
8fc93e6ee4 | ||
|
|
6e87cd66bf | ||
|
|
8f97a0986a | ||
|
|
fbcc2d9855 | ||
|
|
b4447e6be8 | ||
|
|
3cfec5ab34 | ||
|
|
b467432ba4 | ||
|
|
8d4ef01621 | ||
|
|
335e4ca56f | ||
|
|
e327f93711 | ||
|
|
a978a1e010 | ||
|
|
e561431f7c | ||
|
|
519cd8c771 | ||
|
|
34e40cc4bc | ||
|
|
e0c7712f20 | ||
|
|
ddda404d99 | ||
|
|
0b771030ed | ||
|
|
4d0eb97ddf | ||
|
|
d117a33982 | ||
|
|
46c33630b7 | ||
|
|
74eda7f950 | ||
|
|
01093db365 | ||
|
|
95d788f8eb | ||
|
|
49f9a60087 | ||
|
|
48927df2ff | ||
|
|
2de99a2885 | ||
|
|
c8f81c8b83 | ||
|
|
f92a725dfd |
@@ -417,7 +417,6 @@ jobs:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name == 'beta'
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist
|
||||
@@ -480,7 +479,7 @@ jobs:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
OPENCODE_CLI_DIST: ${{ (github.ref_name == 'beta' && format('{0}/packages/cli/dist', github.workspace)) || '' }}
|
||||
OPENCODE_CLI_DIST: ${{ github.workspace }}/packages/cli/dist
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
@@ -592,8 +592,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.9",
|
||||
"@opentui/solid": ">=0.5.9",
|
||||
"@opentui/core": ">=0.5.10",
|
||||
"@opentui/solid": ">=0.5.10",
|
||||
"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.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@opentui/core": "0.5.10",
|
||||
"@opentui/keymap": "0.5.10",
|
||||
"@opentui/solid": "0.5.10",
|
||||
"@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.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": ["@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tTFLcM7Oj1gTyhm/bUdAt3C6grZdCxPk6+/g2azcZBUlI3/62LwbeRS6HbQKFFmm+1fUmX8cq6kWrtul885mVg=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-ncJXcgudhBf2GdJyF3xVQN/Ec+1F7GOL+pRrURmgBYSj2v1w6EyoDQFAACtPTK2c3R38W6fvZwL4JSLlm4EFXQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
|
||||
"@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-x64": ["@opentui/core-linux-x64@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-5qtYaOgwVycZD1GaGshTRsi0rXPAmVExO03N1JQaHu+NYxK/vXSOc7Bu4QW0sPXx3Sp0SpzpP+FHjXABfoK66g=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Oj4H9hApuvuTKPWxh4SoZAgGJorR7vbvnrZA/cAkSMAk2VGSoHRRcqeXQbcH8IcdjVZ0KFpv8Zkl/D5Ye+2mew=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-A9VhgvTxQoUdZ+8LmUumEng1sQNbj9QQQT3NYG9mSxI54qTANi7vOWNSphMiY6RMVsr22pgm6nUvSSvJXv7Jog=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-u3KHa7kEeWrmKVDRJYpxSGO+g5E9cMGlrmTsPN3GVPHUmQMiREUawLXUvsU8+IHaQnqG3Q5nuE1yf4fPBzS+Qw=="],
|
||||
|
||||
"@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/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/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=="],
|
||||
"@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=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
@@ -87,6 +87,11 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
|
||||
cd packages/desktop
|
||||
|
||||
export OPENCODE_CLI_DIST="$TMPDIR/desktop-cli"
|
||||
cli_package=$(bun -e 'import { getCurrentCli } from "./scripts/utils.ts"; console.log(getCurrentCli().package.replace("@opencode-ai/", ""))')
|
||||
mkdir -p "$OPENCODE_CLI_DIST/$cli_package/bin"
|
||||
cp ${lib.getExe opencode} "$OPENCODE_CLI_DIST/$cli_package/bin/opencode2"
|
||||
|
||||
bun run build
|
||||
npx electron-builder --dir \
|
||||
--config electron-builder.config.ts \
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-nV2bI91uUqHJugt1mERgYNCn/zqWJ21YXc5YozwQ+Ss=",
|
||||
"aarch64-linux": "sha256-s/0PghIWeRHsMw9Re84sQ8qW5IZwyhXrhmYlUy2xkt4=",
|
||||
"aarch64-darwin": "sha256-FAm7Bk3NPikHYmmJFwB+0V3saRZqlyxDHuM8gasN3zA=",
|
||||
"x86_64-darwin": "sha256-kdYwYQhubvO9CtVsuHsGnIJ9/byWQ5FMFCLRoScQ614="
|
||||
"x86_64-linux": "sha256-PhCLeam/Mx2KL0bu0kg1+TJ46CpEOmnkQQiK3myGqnk=",
|
||||
"aarch64-linux": "sha256-UJ2pj9WQ3hYOJBw0rFwcDx4XrvZf3CIF+hK0A9FjNbc=",
|
||||
"aarch64-darwin": "sha256-WiVvvLEpeWe/LMIgZUBJSkY2jfoxgDmHyddE9kp2Dyg=",
|
||||
"x86_64-darwin": "sha256-67n45FB0BdM30hcipPZqgjPZkfKc1dK56z9kjSLIeFM="
|
||||
}
|
||||
}
|
||||
|
||||
+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.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@opentui/core": "0.5.10",
|
||||
"@opentui/keymap": "0.5.10",
|
||||
"@opentui/solid": "0.5.10",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.4.3",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
||||
@@ -11,6 +11,23 @@ story("raises the docked composer only in dark mode", async ({ mount, page }) =>
|
||||
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
|
||||
})
|
||||
|
||||
story("centers add menu shortcuts in a consistent column", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--empty-draft")
|
||||
await component.locator('[data-action="composer-attach"]').click()
|
||||
|
||||
const shortcuts = page.locator('[role="menu"] [data-slot="menu-v2-item-shortcut"]')
|
||||
await expect(shortcuts).toHaveCount(4)
|
||||
const boxes = await shortcuts.evaluateAll((items) =>
|
||||
items.map((item) => {
|
||||
const box = item.getBoundingClientRect()
|
||||
return { width: box.width, center: box.left + box.width / 2 }
|
||||
}),
|
||||
)
|
||||
|
||||
expect(new Set(boxes.map((box) => box.width)).size).toBe(1)
|
||||
expect(new Set(boxes.map((box) => box.center)).size).toBe(1)
|
||||
})
|
||||
|
||||
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
|
||||
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
|
||||
const component = await mount(`opencode-composer-flow--${draft}`)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("keeps the comment options button pressed while its menu is open", async ({ mount, page }) => {
|
||||
const component = await mount("ui-line-comment--display")
|
||||
const trigger = component.locator('[data-slot="line-comment-v2-overflow"]')
|
||||
const rest = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
|
||||
|
||||
await trigger.click()
|
||||
|
||||
await expect(page.getByRole("menu")).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute("data-expanded", "")
|
||||
await expect(trigger).not.toHaveCSS("background-color", rest)
|
||||
})
|
||||
@@ -8,7 +8,8 @@ const projectID = "proj_composer_editing"
|
||||
const sessionID = "ses_composer_editing"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
|
||||
test("keeps a narrow session composer contained when invoking a built-in", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 800, height: 600 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -40,6 +41,8 @@ test("preserves the draft when a populated command menu triggers a built-in", as
|
||||
.poll(() => input.evaluate((element) => getComputedStyle(element, "::before").content))
|
||||
.toBe(`"${String.fromCodePoint(0x200b)}"`)
|
||||
await expectAppVisible(composer)
|
||||
await expect(page.locator('[data-slot="session-chat-panel"]')).toHaveCSS("min-width", "0px")
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
||||
|
||||
await input.fill("keep me")
|
||||
await composer.getByRole("button", { name: "Add images and files" }).click()
|
||||
|
||||
@@ -9,12 +9,17 @@ const projectID = "proj_workspace_pending"
|
||||
const draftID = "draft_workspace_pending"
|
||||
const otherID = "ses_workspace_pending_other"
|
||||
const text = "Create the workspace, then explain the pending session."
|
||||
const followUp = "Then explain the setup scripts.\nInclude the install command."
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const sessionPath = `/server/${base64Encode(server)}/session/`
|
||||
const draftPath = `/new-session?draftId=${draftID}`
|
||||
const headers = { "access-control-allow-origin": "*" }
|
||||
|
||||
test.use({ serviceWorkers: "block", viewport: { width: 1280, height: 900 } })
|
||||
test.use({
|
||||
serviceWorkers: "block",
|
||||
viewport: { width: 1280, height: 900 },
|
||||
permissions: ["clipboard-read", "clipboard-write"],
|
||||
})
|
||||
|
||||
for (const viewport of [
|
||||
{ name: "desktop", width: 1280, height: 900 },
|
||||
@@ -24,22 +29,44 @@ for (const viewport of [
|
||||
await page.setViewportSize(viewport)
|
||||
const mock = await openDraft(page)
|
||||
const pending = await submitPending(page, mock)
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await draftFollowUp(page)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
|
||||
await editor.press("Enter")
|
||||
await editor.press("ControlOrMeta+Enter")
|
||||
await page.locator('[data-component="composer"]').dispatchEvent("submit")
|
||||
await expect(editor).toHaveText(followUp)
|
||||
await expect(editor).toBeInViewport()
|
||||
|
||||
expect(mock.worktreeRequests).toEqual([expect.objectContaining({ from: directory })])
|
||||
await expect(pending.message).toBeInViewport()
|
||||
await expect(pending.shimmer).toBeInViewport()
|
||||
await expect(pending.title).toBeInViewport()
|
||||
const spinner = page.locator(
|
||||
viewport.name === "mobile"
|
||||
? '[data-slot="mobile-tabs-trigger"] [data-component="session-progress-indicator-v2"]'
|
||||
: `[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"] [data-component="session-progress-indicator-v2"]`,
|
||||
)
|
||||
await expect(spinner).toBeVisible()
|
||||
await testInfo.attach("creating-worktree", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath(`pending-${viewport.name}.png`) }),
|
||||
contentType: "image/png",
|
||||
})
|
||||
|
||||
if (viewport.name === "mobile") {
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
const tab = drawer.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
|
||||
await expect(tab.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
|
||||
await tab.click()
|
||||
await expect(drawer).toBeHidden()
|
||||
await page.locator("html").evaluate((element) => {
|
||||
element.dir = "rtl"
|
||||
element.setAttribute("dir", "rtl")
|
||||
})
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", "rtl")
|
||||
await expect(pending.message).toBeInViewport()
|
||||
await expect(pending.shimmer).toBeInViewport()
|
||||
await expect(pending.title).toBeInViewport()
|
||||
await expect(page.locator('[data-component="session-preparing"]')).toHaveCSS("direction", "rtl")
|
||||
expect(
|
||||
await page
|
||||
@@ -53,11 +80,14 @@ for (const viewport of [
|
||||
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(pending.shimmer).toBeHidden()
|
||||
await expect(spinner).toBeVisible()
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`).click()
|
||||
await expect(page).toHaveURL(pending.url)
|
||||
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
|
||||
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(pending.title).toHaveText("New session")
|
||||
await expect(editor).toHaveText(followUp)
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${otherID}"]`).click()
|
||||
@@ -88,12 +118,133 @@ for (const viewport of [
|
||||
|
||||
await expect(page).toHaveURL(pending.url)
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(spinner).toHaveCount(1)
|
||||
await expect(pending.message).toHaveCount(1)
|
||||
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
|
||||
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
|
||||
await expect(editor).toHaveText(followUp)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
expect(mock.prompts).toHaveLength(1)
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect
|
||||
.poll(() => mock.prompts)
|
||||
.toEqual([
|
||||
{ sessionID: pending.sessionID, body: expect.objectContaining({ id: pending.messageID, text }) },
|
||||
{ sessionID: pending.sessionID, body: expect.objectContaining({ text: followUp }) },
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`keeps the title and message stable through worktree creation in ${direction}`, async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
await page.locator("html").evaluate((element, direction) => element.setAttribute("dir", direction), direction)
|
||||
const pending = await submitPending(page, mock)
|
||||
await draftFollowUp(page)
|
||||
await page.locator('[data-component="composer-editor"]').press("ControlOrMeta+Home")
|
||||
const title = page.locator("[data-session-title]").getByRole("heading", { level: 1 })
|
||||
const before = await title.boundingBox()
|
||||
const messageBefore = await pending.message.boundingBox()
|
||||
// Observe painted frames during the handoff, without using frame counts to wait for readiness.
|
||||
const observation = await page.evaluateHandle(() => {
|
||||
const frames: { title: string | null; message: boolean; spinner: boolean; draft: string | null }[] = []
|
||||
let frame = 0
|
||||
const sample = () => {
|
||||
const title = document.querySelector<HTMLElement>("[data-session-title] h1")
|
||||
const message = document.querySelector<HTMLElement>('[data-component="user-message"]')
|
||||
const spinner = document.querySelector(
|
||||
'[data-titlebar-tab-slot][data-active="true"] [data-component="session-progress-indicator-v2"]',
|
||||
)
|
||||
const editor = document.querySelector('[data-component="composer-editor"]')
|
||||
frames.push({
|
||||
title: title?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }) ? title.textContent : null,
|
||||
message: !!message?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }),
|
||||
spinner: !!spinner?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }),
|
||||
draft: editor?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }) ? editor.textContent : null,
|
||||
})
|
||||
frame = requestAnimationFrame(sample)
|
||||
}
|
||||
sample()
|
||||
return {
|
||||
stop: () => {
|
||||
cancelAnimationFrame(frame)
|
||||
return frames
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
|
||||
await expect(title).toHaveText("Created workspace session")
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(followUp)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
|
||||
const frames = await observation.evaluate((observation) => observation.stop())
|
||||
await observation.dispose()
|
||||
expect(frames.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
frames.filter(
|
||||
(frame) =>
|
||||
!frame.message ||
|
||||
!frame.spinner ||
|
||||
frame.draft !== followUp ||
|
||||
!["New session", "Created workspace session"].includes(frame.title ?? ""),
|
||||
),
|
||||
).toEqual([])
|
||||
const after = await title.boundingBox()
|
||||
const messageAfter = await pending.message.boundingBox()
|
||||
expect(after?.y).toBe(before?.y)
|
||||
expect(after?.height).toBe(before?.height)
|
||||
expect(messageAfter).toEqual(messageBefore)
|
||||
await page.locator('[data-component="composer-editor"]').pressSequentially("Also: ")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`Also: ${followUp}`)
|
||||
expect(mock.calls).toEqual(["worktree", "session", "prompt"])
|
||||
})
|
||||
}
|
||||
|
||||
for (const failure of ["worktree", "session"]) {
|
||||
test(`preserves both inputs when ${failure} creation fails`, async ({ page }) => {
|
||||
const mock = await openDraft(page, { failSessionCreate: failure === "session" })
|
||||
const pending = await submitPending(page, mock)
|
||||
await draftFollowUp(page)
|
||||
|
||||
mock.worktree.resolve(
|
||||
failure === "worktree"
|
||||
? { status: 500, json: { message: "Worktree creation failed" } }
|
||||
: { status: 200, json: { directory: workspace } },
|
||||
)
|
||||
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`${text}\n\n${followUp}`)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test("preserves both inputs when the initial prompt cannot be sent", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const pending = await submitPending(page, mock)
|
||||
await draftFollowUp(page)
|
||||
await page.route(`**/api/session/${pending.sessionID}/prompt`, (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
json: { message: "Prompt admission failed" },
|
||||
headers,
|
||||
}),
|
||||
)
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`${text}\n\n${followUp}`)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect.poll(() => mock.calls).toEqual(["worktree", "session", "prompt", "prompt"])
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("restores the original draft when worktree creation fails", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const pending = await submitPending(page, mock)
|
||||
@@ -144,6 +295,7 @@ test("retains the draft and reuses the created workspace after session creation
|
||||
test("restores the draft after closing and revisiting a pending session that fails", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const pending = await submitPending(page, mock)
|
||||
await draftFollowUp(page)
|
||||
const tab = page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
|
||||
|
||||
await page.locator("[data-titlebar-tab-slot]").filter({ has: tab }).locator('[data-slot="tab-close"] button').click()
|
||||
@@ -164,13 +316,14 @@ test("restores the draft after closing and revisiting a pending session that fai
|
||||
await expect(pending.shimmer).toBeVisible()
|
||||
await expect(pending.shimmer).toContainText("Creating worktree")
|
||||
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(followUp)
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
|
||||
mock.worktree.resolve({ status: 500, json: { message: "Worktree creation failed after revisiting the session" } })
|
||||
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`${text}\n\n${followUp}`)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
|
||||
await expect(page.locator(`[data-titlebar-tab-link][href="${draftPath}"]`)).toHaveCount(1)
|
||||
@@ -182,6 +335,17 @@ test("restores the draft after closing and revisiting a pending session that fai
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
async function draftFollowUp(page: Page) {
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await editor.pressSequentially("!")
|
||||
await expect(editor).toHaveText("!")
|
||||
await expect(editor).toHaveAttribute("dir", "auto")
|
||||
await editor.fill("")
|
||||
await page.evaluate((text) => navigator.clipboard.writeText(text), followUp)
|
||||
await editor.press("ControlOrMeta+V")
|
||||
await expect(editor).toHaveText(followUp)
|
||||
}
|
||||
|
||||
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
|
||||
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
|
||||
const calls: string[] = []
|
||||
@@ -254,6 +418,24 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
|
||||
headers,
|
||||
})
|
||||
})
|
||||
await page.route("**/api/agent?**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
|
||||
data: [
|
||||
{
|
||||
id: "build",
|
||||
name: "Build",
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
permissions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
headers,
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, otherID, server }) => {
|
||||
localStorage.setItem(
|
||||
@@ -292,7 +474,11 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
|
||||
const preparing = page.locator('[data-component="session-preparing"]')
|
||||
const message = page.locator('[data-component="user-message"]')
|
||||
const shimmer = preparing.getByRole("status").locator('[data-component="text-shimmer"]')
|
||||
const title = preparing.getByRole("heading", { level: 1 })
|
||||
await expect(preparing).toBeVisible()
|
||||
await expect(title).toHaveText("New session")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
|
||||
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
|
||||
await expect(message).toHaveCount(1)
|
||||
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(text)
|
||||
@@ -304,5 +490,5 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
|
||||
await expect.poll(() => mock.calls).toEqual(["worktree"])
|
||||
expect(mock.creates).toEqual([])
|
||||
expect(mock.prompts).toEqual([])
|
||||
return { url, sessionID, messageID, message, shimmer }
|
||||
return { url, sessionID, messageID, message, shimmer, title }
|
||||
}
|
||||
|
||||
@@ -93,11 +93,18 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await contextButton.click()
|
||||
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible()
|
||||
const openFileTab = panel.getByRole("tab", { name: "Open file" })
|
||||
const openFileTabClose = openFileTab.locator("..").getByRole("button", { name: "Close tab" })
|
||||
await expect(openFileTab).toHaveAttribute("data-selected", "")
|
||||
await expect(openFileTab.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-file-tree")
|
||||
await expect(openFileTab.getByText("Open file", { exact: true }).locator("..")).not.toHaveClass(/italic/)
|
||||
await expect(openFileTabClose).toHaveAttribute("data-variant", "ghost-muted")
|
||||
await expect(openFileTabClose).toHaveCSS("opacity", "1")
|
||||
await expect(sidebarToggle).toBeDisabled()
|
||||
await expect(sidebar).toBeVisible()
|
||||
await contextButton.click()
|
||||
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
|
||||
await expect(openFileTabClose).toHaveCSS("opacity", "0")
|
||||
await expect(sidebar).toBeHidden()
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`session header groups controls and exposes server status in ${direction}`, async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.addInitScript(() => {
|
||||
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ ...settings, general: { ...settings.general, showStatus: true } }),
|
||||
)
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const header = page.locator("[data-session-title]")
|
||||
const more = header.getByRole("button", { name: "More options", exact: true })
|
||||
const review = header.getByRole("button", { name: "Toggle review", exact: true })
|
||||
const details = header.getByRole("button", { name: "Session details", exact: true })
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
await expect(review).toBeVisible()
|
||||
await expect(details).toBeVisible()
|
||||
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
|
||||
await expect(status).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const boxes = await Promise.all(
|
||||
[header.getByRole("heading"), more, review, details].map((button) => button.boundingBox()),
|
||||
)
|
||||
const [title, menu, sidebar, summary] = boxes
|
||||
if (!title || !menu || !sidebar || !summary) return false
|
||||
return direction === "ltr"
|
||||
? Math.abs(title.x + title.width - menu.x) <= 1 &&
|
||||
menu.x + menu.width <= summary.x &&
|
||||
summary.x + summary.width <= sidebar.x
|
||||
: Math.abs(menu.x + menu.width - title.x) <= 1 &&
|
||||
sidebar.x + sidebar.width <= summary.x &&
|
||||
summary.x + summary.width <= menu.x
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
await review.click()
|
||||
await expect(review).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await review.click()
|
||||
await expect(review).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
await more.click()
|
||||
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
|
||||
await page.keyboard.press("Escape")
|
||||
await status.click()
|
||||
const mcp = page.getByRole("tab", { name: "MCP", exact: true })
|
||||
const plugins = page.getByRole("tab", { name: "Plugins", exact: true })
|
||||
await expect(mcp).toHaveAttribute("aria-selected", "true")
|
||||
await plugins.click()
|
||||
await expect(plugins).toHaveAttribute("aria-selected", "true")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(mcp).toBeHidden()
|
||||
})
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { fixture } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test("keeps history visible and recovers the composer by choosing another directory", async ({ page }) => {
|
||||
const directory = "/projects/deleted"
|
||||
const destination = "/projects/restored"
|
||||
const sessionID = "ses_missing_location"
|
||||
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Missing location" }
|
||||
const events: OpenCodeEvent[] = []
|
||||
const moves: unknown[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: destination,
|
||||
project: { ...fixture.project, worktree: destination },
|
||||
provider: fixture.provider,
|
||||
sessions: [session],
|
||||
events: () => events.splice(0),
|
||||
fileList: () => [],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_saved", type: "user", text: "Keep this session history", time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await page.route("**/api/**", (route) => {
|
||||
if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback()
|
||||
return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("status")).toContainText("Session location unavailable")
|
||||
await expect(page.getByRole("status")).toContainText(directory)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0)
|
||||
const choose = page.getByRole("button", { name: "Choose directory", exact: true })
|
||||
await expect(choose).toBeEnabled()
|
||||
await choose.click()
|
||||
const dialog = page.getByRole("dialog", { name: "Choose directory", exact: true })
|
||||
await expect(dialog.getByRole("combobox")).toBeFocused()
|
||||
await dialog.getByRole("combobox").press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(choose).toBeFocused()
|
||||
expect(moves).toEqual([])
|
||||
|
||||
// The server can still reject a destination after it was selected in the picker.
|
||||
await page.route(`**/api/session/${sessionID}/move`, (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
moves.push(route.request().postDataJSON())
|
||||
return route.fulfill({
|
||||
status: 400,
|
||||
json: { _tag: "InvalidRequestError", message: "Destination is unavailable" },
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
})
|
||||
})
|
||||
await choose.press("Enter")
|
||||
await expect(dialog.getByRole("combobox")).toBeFocused()
|
||||
await dialog.getByRole("combobox").fill(destination)
|
||||
await dialog.getByRole("combobox").press("Enter")
|
||||
await expect(dialog.locator(".directory-picker-selection")).toHaveText(destination)
|
||||
await dialog.getByRole("button", { name: "Select folder", exact: true }).click()
|
||||
await expect(page.getByText("Failed to move session", { exact: true })).toBeVisible()
|
||||
await expect(choose).toBeEnabled()
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0)
|
||||
await expect(choose).toBeFocused()
|
||||
expect(moves).toEqual([{ directory: destination }])
|
||||
|
||||
await page.route(`**/api/session/${sessionID}/move`, (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
moves.push(route.request().postDataJSON())
|
||||
session.directory = destination
|
||||
events.push({
|
||||
id: "evt_location_recovered",
|
||||
type: "session.moved",
|
||||
created: 2,
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
|
||||
})
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await choose.click()
|
||||
await dialog.getByRole("combobox").fill(destination)
|
||||
await dialog.getByRole("combobox").press("Enter")
|
||||
await expect(dialog.locator(".directory-picker-selection")).toHaveText(destination)
|
||||
await dialog.getByRole("button", { name: "Select folder", exact: true }).click()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await expect(page.locator('[data-action="composer-model"]')).toBeVisible()
|
||||
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}$`))
|
||||
expect(moves).toEqual([{ directory: destination }, { directory: destination }])
|
||||
})
|
||||
|
||||
for (const create of [false, true]) {
|
||||
test(`recovers into ${create ? "a new" : "an existing"} worktree without resolving the deleted location`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const canonical = "/projects/repository"
|
||||
const directory = "/projects/deleted-worktree"
|
||||
const destination = create ? "/projects/new-worktree" : "/projects/existing-worktree"
|
||||
const sessionID = "ses_worktree_recovery"
|
||||
const events: OpenCodeEvent[] = []
|
||||
const requests: { operation: string; body: unknown }[] = []
|
||||
let listing = Promise.withResolvers<void>()
|
||||
let listingRequested = Promise.withResolvers<void>()
|
||||
const moving = Promise.withResolvers<void>()
|
||||
const moveRequested = Promise.withResolvers<void>()
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: canonical,
|
||||
project: { ...fixture.project, worktree: canonical },
|
||||
provider: fixture.provider,
|
||||
sessions: [{ id: sessionID, projectID: fixture.project.id, directory }],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_worktree", type: "user", text: "Recover my worktree", time: { created: 1 } }],
|
||||
}),
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const headers = { "access-control-allow-origin": "*" }
|
||||
if (url.searchParams.get("location[directory]") === directory)
|
||||
return route.fulfill({ status: 500, body: "", headers })
|
||||
if (url.pathname === `/api/worktree/${fixture.project.id}`) {
|
||||
if (route.request().method() === "GET") {
|
||||
listingRequested.resolve()
|
||||
await listing.promise
|
||||
return route.fulfill({
|
||||
json: [
|
||||
{ directory: canonical, strategy: null },
|
||||
{ directory: "/projects/existing-worktree", strategy: "git" },
|
||||
...Array.from({ length: 20 }, (_, index) => ({
|
||||
directory: `/projects/worktree-${index}`,
|
||||
strategy: "git",
|
||||
})),
|
||||
],
|
||||
headers,
|
||||
})
|
||||
}
|
||||
if (route.request().method() === "POST") {
|
||||
requests.push({ operation: "create", body: route.request().postDataJSON() })
|
||||
return route.fulfill({
|
||||
json: { directory: destination, name: "new-worktree", branch: "new-worktree" },
|
||||
headers,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (url.pathname === `/api/session/${sessionID}/move` && route.request().method() === "POST") {
|
||||
requests.push({ operation: "move", body: route.request().postDataJSON() })
|
||||
moveRequested.resolve()
|
||||
await moving.promise
|
||||
events.push({
|
||||
id: "evt_worktree_recovered",
|
||||
type: "session.moved",
|
||||
created: 2,
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
|
||||
})
|
||||
return route.fulfill({ status: 204, headers })
|
||||
}
|
||||
return route.fallback()
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
|
||||
await listingRequested.promise
|
||||
await expect(page.getByRole("menuitem", { name: "Loading", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "New workspace", exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toBeVisible()
|
||||
listing.resolve()
|
||||
const existing = page.getByRole("menuitem", { name: "existing-worktree", exact: true })
|
||||
await expect(existing).toBeVisible()
|
||||
await expect(existing).toHaveCSS("height", "28px")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("menu")).toHaveCount(0)
|
||||
listing = Promise.withResolvers<void>()
|
||||
listingRequested = Promise.withResolvers<void>()
|
||||
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
|
||||
await listingRequested.promise
|
||||
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
|
||||
listing.resolve()
|
||||
await page.getByRole("menuitem", { name: create ? "New workspace" : "existing-worktree", exact: true }).click()
|
||||
await moveRequested.promise
|
||||
await expect(page.getByRole("button", { name: "Moving session…", exact: true })).toBeDisabled()
|
||||
await expect(page.getByRole("button", { name: "Choose worktree", exact: true })).toBeDisabled()
|
||||
moving.resolve()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
|
||||
expect(requests).toEqual([
|
||||
...(create ? [{ operation: "create", body: { strategy: "git", from: canonical, directory: "/projects/" } }] : []),
|
||||
{ operation: "move", body: { directory: destination } },
|
||||
])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { fixture } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
for (const endpoint of ["/api/location", "/api/agent"]) {
|
||||
for (const recover of [false, true]) {
|
||||
test(`keeps the composer when ${endpoint} ${recover ? "recovers on retry" : "fails"}`, async ({ page }) => {
|
||||
const recovery = recoveryRequests(page)
|
||||
const directory = "/projects/working-tree"
|
||||
const sessionID = "ses_location_sync_failure"
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: [{ id: sessionID, projectID: fixture.project.id, directory, title: "Workspace sync" }],
|
||||
fileList: () => [],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_saved", type: "user", text: "Keep working in this worktree", time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
let requests = 0
|
||||
await page.route("**/api/**", (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname !== endpoint || url.searchParams.get("location[directory]") !== directory)
|
||||
return route.fallback()
|
||||
requests++
|
||||
if (recover && requests > 1) return route.fallback()
|
||||
return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
const failure = page.waitForResponse(
|
||||
(response) => new URL(response.url()).pathname === endpoint && response.status() === 500,
|
||||
)
|
||||
const settled = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return (
|
||||
url.pathname === endpoint &&
|
||||
url.searchParams.get("location[directory]") === directory &&
|
||||
(recover ? response.ok() : requests === 3 && response.status() === 500)
|
||||
)
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
await failure
|
||||
await expect(page.getByText("Keep working in this worktree", { exact: true })).toBeVisible()
|
||||
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(prompt).toBeEditable()
|
||||
await prompt.fill("Continue after reconnecting")
|
||||
await expect(prompt).toHaveText("Continue after reconnecting")
|
||||
await settled
|
||||
await expect(prompt).toBeEditable()
|
||||
await expect(prompt).toHaveText("Continue after reconnecting")
|
||||
expect(requests).toBe(recover ? 2 : 3)
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0)
|
||||
expect(recovery).toEqual([])
|
||||
await page.screenshot({ path: test.info().outputPath("location-sync.png") })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test("follows a live session move while the agent catalog is still loading", async ({ page }) => {
|
||||
const recovery = recoveryRequests(page)
|
||||
const directory = "/projects/old-tree"
|
||||
const destination = "/projects/current-tree"
|
||||
const sessionID = "ses_location_moved_while_loading"
|
||||
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Moved session" }
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const transport = await installSseTransport(page, { server: fixture.serverKey })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: [session],
|
||||
fileList: () => [],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_saved", type: "user", text: "Follow the session move", time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === directory) {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
}
|
||||
return route.fallback()
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
await requested.promise
|
||||
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(prompt).toBeEditable()
|
||||
await prompt.fill("Keep this draft")
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await transport.waitForConnection()
|
||||
const resolved = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return (
|
||||
url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
|
||||
)
|
||||
})
|
||||
session.directory = destination
|
||||
await transport.send({
|
||||
id: "evt_location_moved_while_loading",
|
||||
type: "session.moved",
|
||||
created: 2,
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
|
||||
})
|
||||
await resolved
|
||||
const delayed = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === directory && response.ok()
|
||||
})
|
||||
release.resolve()
|
||||
await delayed
|
||||
await expect(prompt).toBeEditable()
|
||||
await expect(prompt).toHaveText("Keep this draft")
|
||||
await expect(page.getByText("Follow the session move", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-type="location-switched"]').getByText(destination, { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0)
|
||||
expect(recovery).toEqual([])
|
||||
})
|
||||
|
||||
test("refreshes a session moved during disconnection without losing the draft", async ({ page }) => {
|
||||
const recovery = recoveryRequests(page)
|
||||
const directory = "/projects/before-reconnect"
|
||||
const destination = "/projects/after-reconnect"
|
||||
const sessionID = "ses_location_reconnect"
|
||||
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Reconnected session" }
|
||||
const transport = await installSseTransport(page, { server: fixture.serverKey })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: [session],
|
||||
fileList: () => [],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_saved", type: "user", text: "Resume in the current worktree", time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(prompt).toBeEditable()
|
||||
await prompt.fill("Draft before disconnect")
|
||||
const connection = await transport.waitForConnection()
|
||||
const resolved = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return (
|
||||
url.pathname === "/api/location" && url.searchParams.get("location[directory]") === destination && response.ok()
|
||||
)
|
||||
})
|
||||
session.directory = destination
|
||||
await transport.close()
|
||||
await transport.waitForConnection({ after: connection.id })
|
||||
await resolved
|
||||
await expect(prompt).toBeEditable()
|
||||
await expect(prompt).toHaveText("Draft before disconnect")
|
||||
await expect(page.getByText("Resume in the current worktree", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0)
|
||||
expect(recovery).toEqual([])
|
||||
})
|
||||
|
||||
test("ignores an old failed location read after reconnecting", async ({ page }) => {
|
||||
const recovery = recoveryRequests(page)
|
||||
const directory = "/projects/reconnected-tree"
|
||||
const sessionID = "ses_location_stale_response"
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const transport = await installSseTransport(page, { server: fixture.serverKey })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: [{ id: sessionID, projectID: fixture.project.id, directory }],
|
||||
fileList: () => [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
let requests = 0
|
||||
await page.route("**/api/location?**", async (route) => {
|
||||
if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback()
|
||||
requests++
|
||||
if (requests > 1) return route.fallback()
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
return route.fulfill({
|
||||
status: 500,
|
||||
body: "",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
})
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
await requested.promise
|
||||
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(prompt).toBeEditable()
|
||||
await prompt.fill("Keep typing here")
|
||||
const connection = await transport.waitForConnection()
|
||||
const metadata = page.waitForResponse((response) => new URL(response.url()).pathname === `/api/session/${sessionID}`)
|
||||
const resolved = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return (
|
||||
url.pathname === "/api/location" && url.searchParams.get("location[directory]") === directory && response.ok()
|
||||
)
|
||||
})
|
||||
await transport.close()
|
||||
await transport.waitForConnection({ after: connection.id })
|
||||
await metadata
|
||||
release.resolve()
|
||||
await resolved
|
||||
await expect(prompt).toBeEditable()
|
||||
await expect(prompt).toHaveText("Keep typing here")
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0)
|
||||
expect(requests).toBe(2)
|
||||
expect(recovery).toEqual([])
|
||||
})
|
||||
|
||||
function recoveryRequests(page: Page) {
|
||||
const requests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path)) requests.push(path)
|
||||
})
|
||||
return requests
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
directory,
|
||||
partUpdated,
|
||||
reasoningPart,
|
||||
sessionID,
|
||||
setupTimeline,
|
||||
status,
|
||||
stepStarted,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const width of [1400, 390]) {
|
||||
test(`shows Working between busy and reasoning states at ${width}px`, async ({ page }, testInfo) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage()],
|
||||
sessionStatus: { [sessionID]: { type: "busy" } },
|
||||
viewport: { width, height: 900 },
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
await expect(working).toHaveCount(1)
|
||||
await expect(working).toHaveRole("status")
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
|
||||
await expect(working).toBeInViewport()
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveCSS("line-height", "16px")
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await page.screenshot({ path: testInfo.outputPath(`working-${width}.png`) })
|
||||
|
||||
await timeline.send(stepStarted(assistantMessage([], { completed: false })))
|
||||
await expect(working).toBeVisible()
|
||||
const id = `prt_working_reasoning_${width}`
|
||||
await timeline.send(partUpdated(reasoningPart(id, "")))
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(working).toHaveCount(0)
|
||||
|
||||
await timeline.send(partUpdated(reasoningPart(id, "The inspection is complete.")))
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
|
||||
await expect(working).toBeInViewport()
|
||||
|
||||
await timeline.send(status("idle"))
|
||||
await expect(working).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
for (const name of ["shell", "patch", "subagent"] as const) {
|
||||
test(`hides Working during ${name} input and execution, then restores it on completion`, async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([], { completed: false })],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
await expect(working).toBeVisible()
|
||||
|
||||
const id = `prt_working_${name}`
|
||||
const input =
|
||||
name === "shell"
|
||||
? { command: "printf ready" }
|
||||
: name === "patch"
|
||||
? { patchText: "*** Begin Patch\n*** Add File: src/working.ts\n+export const ready = true\n*** End Patch" }
|
||||
: { agent: "general", description: "Inspect working indicator", prompt: "Inspect the timeline." }
|
||||
await timeline.send(partUpdated(toolPart(id, name, "streaming", input)))
|
||||
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
await expect(tool).toBeVisible()
|
||||
await expect(working).toHaveCount(0)
|
||||
|
||||
const metadata =
|
||||
name === "patch"
|
||||
? {
|
||||
files: [
|
||||
{
|
||||
file: "src/working.ts",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
patch: "@@ -0,0 +1 @@\n+export const ready = true",
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}
|
||||
await timeline.send(partUpdated(toolPart(id, name, "running", input, { metadata })))
|
||||
await expect(tool).toContainText(
|
||||
name === "shell" ? "printf ready" : name === "patch" ? "working.ts" : "Inspect working indicator",
|
||||
)
|
||||
await expect(working).toHaveCount(0)
|
||||
|
||||
await timeline.send(partUpdated(toolPart(id, name, "completed", input, { metadata })))
|
||||
if (name === "shell") {
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(
|
||||
group.getByRole("button", { name: "Used 1 Shell", exact: true, includeHidden: true }),
|
||||
).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(group).toBeVisible()
|
||||
}
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
|
||||
await expect(working).toBeVisible()
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
||||
})
|
||||
}
|
||||
|
||||
for (const name of ["read", "shell", "subagent"] as const) {
|
||||
test(`keeps Working for grouped ${name} regardless of disclosure`, async ({ page }, testInfo) => {
|
||||
await setupTimeline(page, {
|
||||
viewport: { width: name === "shell" ? 390 : 1400, height: 900 },
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart("prt_grouped_previous", "read", "completed", { filePath: "package.json" }),
|
||||
toolPart(
|
||||
"prt_grouped_active",
|
||||
name,
|
||||
"running",
|
||||
name === "shell"
|
||||
? { command: "sleep 10" }
|
||||
: name === "subagent"
|
||||
? { agent: "general", description: "Inspect the timeline", prompt: "Inspect it." }
|
||||
: { filePath: "src/working.ts" },
|
||||
),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", "prt_grouped_previous,prt_grouped_active")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(working).toBeInViewport()
|
||||
if (name !== "read") {
|
||||
const hint = page.locator('[data-component="session-background-hint"]')
|
||||
await expect(hint).toBeInViewport()
|
||||
await expect(page.locator('[data-component="session-background-hint-row"]')).toHaveCSS("height", "24px")
|
||||
await page.screenshot({ path: testInfo.outputPath(`working-grouped-${name}.png`) })
|
||||
}
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
if (name === "subagent") await expect(group.getByText("Inspect the timeline", { exact: true })).toBeVisible()
|
||||
if (name !== "subagent")
|
||||
await expect(group.locator('[data-component="text-shimmer"][data-active="true"]')).toBeVisible()
|
||||
await expect(working).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(working).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
for (const grouped of [false, true]) {
|
||||
test(`uses ${grouped ? "grouped" : "standalone"} background shell presentation for Working`, async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
settings: { shellToolPartsExpanded: !grouped },
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart("prt_background_previous", "shell", "completed", { command: "echo ready" }),
|
||||
toolPart(
|
||||
"prt_background_active",
|
||||
"shell",
|
||||
"completed",
|
||||
{ command: "sleep 10" },
|
||||
{
|
||||
metadata: { shellID: "sh_working_background", status: "running" },
|
||||
},
|
||||
),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
if (!grouped) {
|
||||
await expect(page.locator('[data-timeline-part-id="prt_background_active"]')).toBeVisible()
|
||||
await expect(working).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
const trigger = group.getByRole("button", { name: "Used 2 Shell", exact: true, includeHidden: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(working).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(working).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("replaces Working with Retry and restores it on recovery", async ({ page }) => {
|
||||
const assistant = assistantMessage([], { completed: false })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
await expect(working).toBeVisible()
|
||||
|
||||
await timeline.send(status("retry"))
|
||||
const retry = page.locator('[data-timeline-row="Retry"]')
|
||||
await expect(retry).toContainText("Rate limited")
|
||||
await expect(working).toHaveCount(0)
|
||||
|
||||
await timeline.send(stepStarted(assistant))
|
||||
await expect(retry).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
|
||||
await expect(working).toBeVisible()
|
||||
})
|
||||
|
||||
test("hides Working while assistant text streams", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([], { completed: false })],
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
await expect(working).toBeVisible()
|
||||
|
||||
await timeline.send({
|
||||
id: "evt_working_text_started",
|
||||
type: "session.text.started",
|
||||
created: 1700000002000,
|
||||
location: { directory },
|
||||
durable: { aggregateID: sessionID, seq: 0, version: 1 },
|
||||
data: { sessionID, assistantMessageID: assistantID, ordinal: 0 },
|
||||
})
|
||||
await timeline.send({
|
||||
id: "evt_working_text_delta",
|
||||
type: "session.text.delta",
|
||||
created: 1700000002001,
|
||||
location: { directory },
|
||||
data: { sessionID, assistantMessageID: assistantID, ordinal: 0, delta: "The response is streaming." },
|
||||
})
|
||||
await expect(page.locator(`[data-timeline-part-id="${assistantID}:text:0"]`)).toContainText(
|
||||
"The response is streaming.",
|
||||
)
|
||||
await expect(working).toHaveCount(0)
|
||||
})
|
||||
|
||||
for (const failed of [false, true]) {
|
||||
test(`shows Working before prompt admission or execution events${failed ? " after an error" : ""}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
...(failed
|
||||
? [{ ...assistantMessage([]), error: { type: "provider.error", message: "Previous request failed" } }]
|
||||
: []),
|
||||
],
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
await expect(working).toHaveCount(0)
|
||||
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(`**/api/session/${sessionID}/prompt`, async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
await release.promise
|
||||
return route.fallback()
|
||||
})
|
||||
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
|
||||
await expect(editor).toBeEditable()
|
||||
await editor.fill("Check the working indicator immediately.")
|
||||
const requested = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
|
||||
)
|
||||
try {
|
||||
await editor.press("Enter")
|
||||
const request = await requested
|
||||
expect(request.postDataJSON()).toMatchObject({ text: "Check the working indicator immediately." })
|
||||
await expect(working).toHaveRole("status")
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
|
||||
await expect(working).toBeInViewport()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -22,8 +22,7 @@ test("navigates to a subagent child session missing from the session list", asyn
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
await expect(page.getByRole("heading", { name: parentTitle })).toHaveCount(0)
|
||||
|
||||
const titlebarRight = page.locator("#opencode-titlebar-right")
|
||||
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
|
||||
await expect(page.getByRole("button", { name: "Toggle review", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("returns to the parent session with Escape", async ({ page }) => {
|
||||
|
||||
@@ -9,6 +9,32 @@ const sessionB = session("ses_tab_b", "Tab B session")
|
||||
const sessionC = session("ses_tab_c", "Tab C session")
|
||||
const unresolvedSessionID = "ses_tab_unresolved"
|
||||
|
||||
test("new session tab hugs its content", async ({ page }) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionID, directory }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server, sessionId: sessionID },
|
||||
{ type: "draft", server, directory, draftID: "draft_tab_width" },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ server, sessionID: sessionA.id, directory: sessionA.directory },
|
||||
)
|
||||
|
||||
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
await page.goto(href)
|
||||
|
||||
const sessionTab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"])`)
|
||||
const draftTab = page.locator('[data-titlebar-tab-slot]:has(a[href^="/new-session?draftId="])')
|
||||
await expect(draftTab).toContainText("New session")
|
||||
const width = (await draftTab.boundingBox())?.width ?? 0
|
||||
expect(width).toBeGreaterThan(100)
|
||||
expect(width).toBeLessThan((await sessionTab.boundingBox())?.width ?? 0)
|
||||
})
|
||||
|
||||
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
@@ -121,7 +147,10 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA, sessionB }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ appearance: { tabLayout: "vertical" }, general: { showStatus: true } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
@@ -144,7 +173,26 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
await expect(tabA).toContainText(sessionA.title)
|
||||
await expect(tabB).toContainText(sessionB.title)
|
||||
await expect(tabB.locator('[data-slot="tab-project"]')).toHaveText("tab-project")
|
||||
await expect(sidebar.getByRole("button", { name: "Home", exact: true })).toHaveText("Home")
|
||||
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible()
|
||||
const status = sidebar.getByRole("button", { name: "Status", exact: true })
|
||||
await expect(status).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await sidebar.boundingBox()
|
||||
const button = await status.boundingBox()
|
||||
return !!bounds && !!button && bounds.x + bounds.width - button.x - button.width <= 12
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const button = await sidebar.getByRole("button", { name: "New session" }).boundingBox()
|
||||
const tab = await tabA.boundingBox()
|
||||
return !!button && !!tab && button.y + button.height < tab.y
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
|
||||
|
||||
const handle = sidebar.locator('[data-component="resize-handle"]')
|
||||
|
||||
@@ -91,7 +91,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
event.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
<Show when={state.popover.type !== "closed"}>
|
||||
<Show when={!view.draftOnly && state.popover.type !== "closed"}>
|
||||
<ComposerEditorPopover
|
||||
emptyLabel={i18n.t("ui.promptInput.noMatchingItems")}
|
||||
items={props.controller.suggestions()}
|
||||
@@ -185,7 +185,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (props.controller.onKeyDown(event)) return
|
||||
if (!view.draftOnly && props.controller.onKeyDown(event)) return
|
||||
const mod = event.metaKey || event.ctrlKey
|
||||
if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) {
|
||||
if (view.submit.queue?.editFirst()) event.preventDefault()
|
||||
@@ -238,7 +238,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
style={buttons()}
|
||||
>
|
||||
<ComposerEditorAddMenu
|
||||
disabled={state.mode === "shell"}
|
||||
disabled={view.draftOnly || state.mode === "shell"}
|
||||
title={i18n.t("ui.promptInput.add")}
|
||||
keybind={props.attachKeybind ?? ["Mod", "U"]}
|
||||
attachLabel={i18n.t("ui.promptInput.attachments")}
|
||||
@@ -555,7 +555,10 @@ export function ComposerEditorAddMenu(props: {
|
||||
aria-label={props.title}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content style={{ "min-width": "180px" }}>
|
||||
<Menu.Content
|
||||
class="[&_[data-slot=menu-v2-item-shortcut]]:w-8 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
|
||||
style={{ "min-width": "180px" }}
|
||||
>
|
||||
<Menu.Item onSelect={props.onAttach} shortcut={props.attachShortcut}>
|
||||
{props.attachLabel}
|
||||
</Menu.Item>
|
||||
|
||||
@@ -29,6 +29,7 @@ export type ComposerSelectControl = {
|
||||
}
|
||||
|
||||
export type ComposerEditorView = {
|
||||
draftOnly?: boolean
|
||||
placeholder?: Accessor<string>
|
||||
add?: {
|
||||
onAttach: () => void
|
||||
@@ -332,6 +333,7 @@ export function createComposerEditor(input: {
|
||||
draft.removeAttachment(id)
|
||||
},
|
||||
canSubmit() {
|
||||
if (input.view.draftOnly) return false
|
||||
const persisted = draft.state
|
||||
if (state.mode === "shell") {
|
||||
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
|
||||
@@ -347,6 +349,7 @@ export function createComposerEditor(input: {
|
||||
restoreFocus,
|
||||
onInput(value: string, prompt?: ComposerPersistedState["prompt"], cursor?: number) {
|
||||
if (prompt) draft.setPrompt(prompt, cursor)
|
||||
if (input.view.draftOnly) return
|
||||
dispatch({ type: "input.changed", value, persist: !prompt })
|
||||
},
|
||||
onCursor(cursor: number) {
|
||||
@@ -362,6 +365,7 @@ export function createComposerEditor(input: {
|
||||
dispatch({ type: "mode.shell" })
|
||||
},
|
||||
submit(options?: { alternate?: boolean }) {
|
||||
if (input.view.draftOnly) return
|
||||
input.view.submit.onSubmit(options)
|
||||
dispatch({ type: "popover.close" })
|
||||
},
|
||||
|
||||
@@ -9,3 +9,15 @@ export function clonePrompt(prompt: Prompt): Prompt {
|
||||
export function promptLength(prompt: Prompt) {
|
||||
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
|
||||
}
|
||||
|
||||
export function appendPrompt(prompt: Prompt, following: Prompt): Prompt {
|
||||
const start = promptLength(prompt)
|
||||
const offset = start + 2
|
||||
return [
|
||||
...clonePrompt(prompt),
|
||||
{ type: "text", content: "\n\n", start, end: offset },
|
||||
...clonePrompt(following).map((part) =>
|
||||
part.type === "image" ? part : { ...part, start: part.start + offset, end: part.end + offset },
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ComposerState, ContextItem, Prompt } from "./state"
|
||||
import { appendPrompt, clonePrompt } from "./prompt-parts"
|
||||
|
||||
export type ComposerStateTarget = ReturnType<ComposerState["capture"]>
|
||||
|
||||
@@ -10,24 +11,36 @@ export function createComposerSubmission(input: {
|
||||
const initial = input.target
|
||||
let target = input.target
|
||||
let cleared: Prompt | undefined
|
||||
let following: Prompt | undefined
|
||||
let preserveDraft = false
|
||||
|
||||
return {
|
||||
prompt: input.prompt,
|
||||
context: input.context,
|
||||
target: () => target,
|
||||
clear() {
|
||||
if (initial !== target) initial.reset()
|
||||
target.reset()
|
||||
if (initial !== target) {
|
||||
initial.reset()
|
||||
// A preparing session may already have an unsent follow-up in its promoted composer.
|
||||
if (preserveDraft && target.current().some((part) => part.type === "image" || part.content.length > 0))
|
||||
following = clonePrompt(target.current())
|
||||
}
|
||||
if (!following) target.reset()
|
||||
cleared = target.current()
|
||||
},
|
||||
retarget(next: ComposerStateTarget) {
|
||||
retarget(next: ComposerStateTarget, options?: { preserveDraft?: boolean }) {
|
||||
input.context.forEach((item) => next.context.add(item))
|
||||
target = next
|
||||
preserveDraft = options?.preserveDraft ?? false
|
||||
},
|
||||
current: (value: ComposerStateTarget) => target === value,
|
||||
restore() {
|
||||
if (cleared !== undefined && target.current() !== cleared) return
|
||||
return { target, prompt: input.prompt, context: input.context }
|
||||
return {
|
||||
target,
|
||||
prompt: following ? appendPrompt(input.prompt, following) : input.prompt,
|
||||
context: input.context,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +231,7 @@ describe("Composer submission", () => {
|
||||
|
||||
test("previews the first prompt while starting and hands it off before completing preparation", async () => {
|
||||
const draft = createMemoryComposerState({ prompt: "prepare my worktree" }).capture()
|
||||
const promoted = createMemoryComposerState().capture()
|
||||
const preview = Promise.withResolvers<SessionMessageUser>()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const calls: string[] = []
|
||||
@@ -247,9 +248,10 @@ describe("Composer submission", () => {
|
||||
controls,
|
||||
working: () => false,
|
||||
submitted() {},
|
||||
async start(_selection, _submission, message) {
|
||||
async start(_selection, submission, message) {
|
||||
preview.resolve(message)
|
||||
await ready.promise
|
||||
submission.retarget(promoted, { preserveDraft: true })
|
||||
return {
|
||||
session: target,
|
||||
cleanupReady: Promise.resolve(),
|
||||
@@ -257,6 +259,8 @@ describe("Composer submission", () => {
|
||||
expect(handoff).toHaveLength(1)
|
||||
expect(handoff[0]?.id).toBe(message.id)
|
||||
expect(handoff[0]?.text).toBe("prepare my worktree")
|
||||
expect(promoted.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
promoted.set([{ type: "text", content: "follow up", start: 0, end: 9 }], 9)
|
||||
calls.push("complete")
|
||||
},
|
||||
}
|
||||
@@ -271,6 +275,7 @@ describe("Composer submission", () => {
|
||||
await submitted
|
||||
expect(calls).toContain("complete")
|
||||
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
expect(promoted.current()).toEqual([{ type: "text", content: "follow up", start: 0, end: 9 }])
|
||||
})
|
||||
|
||||
test("does not restore a prompt already acknowledged by the durable inbox", async () => {
|
||||
|
||||
@@ -248,7 +248,8 @@ function restoreSubmission(
|
||||
preview: item.preview,
|
||||
})),
|
||||
)
|
||||
if (value.mode === "normal") {
|
||||
// A recovered follow-up changes the payload, so it must use a new admission ID.
|
||||
if (value.mode === "normal" && restored.prompt === submission.prompt) {
|
||||
restored.target.retry.set({
|
||||
id: value.id,
|
||||
agent: value.selection.agent,
|
||||
|
||||
@@ -73,6 +73,7 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
|
||||
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
|
||||
clearAuthToken()
|
||||
const standalone = isStandalone()
|
||||
root.dataset.standalone = String(standalone)
|
||||
if (standalone) restorePwaRoute()
|
||||
const server: ServerConnection.Http = {
|
||||
type: "http",
|
||||
|
||||
@@ -32,6 +32,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* iOS can report navigator.standalone without matching display-mode. */
|
||||
#root[data-standalone="true"] {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
[data-slot="session-side-panel-presence"][data-opened="true"] {
|
||||
animation: terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
|
||||
@@ -117,12 +117,13 @@ export function createNewSessionComposerAdapter(props: {
|
||||
{ dir: base64Encode(sessionDirectory), id: created.id },
|
||||
{ server: server.key, scope: serverSDK.scope },
|
||||
),
|
||||
{ preserveDraft: !!pending },
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
cleanupReady,
|
||||
complete: pending?.complete,
|
||||
complete: pending ? () => pending.complete(submission.target()) : undefined,
|
||||
session: {
|
||||
id: created.id,
|
||||
directory: sessionDirectory,
|
||||
@@ -194,6 +195,7 @@ async function resolveSessionDirectory(input: {
|
||||
|
||||
return createWorktree({
|
||||
api: input.serverSDK.api,
|
||||
data: input.data,
|
||||
directory: input.projectDirectory,
|
||||
project: input.data.location.info({ directory: input.projectDirectory })?.project,
|
||||
branch: input.branch,
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
normalizeNewSessionWorktree,
|
||||
resolveNewSessionBranch,
|
||||
resolveNewSessionGit,
|
||||
resolveNewSessionWorktree,
|
||||
} from "./controller"
|
||||
import { resolveNewSessionBranch, resolveNewSessionGit, resolveNewSessionWorktree } from "./controller"
|
||||
|
||||
describe("new session workspace selection", () => {
|
||||
test("uses main when the workspace bar is unavailable", () => {
|
||||
@@ -12,8 +7,6 @@ describe("new session workspace selection", () => {
|
||||
resolveNewSessionWorktree({
|
||||
enabled: false,
|
||||
selected: "/project/feature",
|
||||
directory: "/project/feature",
|
||||
projectWorktree: "/project",
|
||||
}),
|
||||
).toBe("main")
|
||||
})
|
||||
@@ -22,31 +15,21 @@ describe("new session workspace selection", () => {
|
||||
expect(
|
||||
resolveNewSessionWorktree({
|
||||
enabled: true,
|
||||
directory: "/project/feature",
|
||||
projectWorktree: "/project",
|
||||
fallback: "create",
|
||||
}),
|
||||
).toBe("create")
|
||||
expect(
|
||||
resolveNewSessionWorktree({
|
||||
enabled: true,
|
||||
directory: "/project/feature",
|
||||
projectWorktree: "/project",
|
||||
fallback: "main",
|
||||
}),
|
||||
).toBe("/project")
|
||||
})
|
||||
|
||||
test("normalizes main to the project root outside the main worktree", () => {
|
||||
expect(normalizeNewSessionWorktree("main", "/project/feature", "/project")).toBe("/project")
|
||||
expect(normalizeNewSessionWorktree("main", "/project", "/project")).toBe("main")
|
||||
})
|
||||
|
||||
test("treats equivalent Windows roots as the main worktree", () => {
|
||||
expect(
|
||||
resolveNewSessionWorktree({ enabled: true, directory: "C:\\Repo\\", projectWorktree: "c:/repo" }),
|
||||
).toBe("main")
|
||||
expect(normalizeNewSessionWorktree("main", "C:\\Repo\\", "c:/repo")).toBe("main")
|
||||
})
|
||||
|
||||
test("keeps local selection when the cached project path is stale", () => {
|
||||
const input = { enabled: true, directory: "C:/Projects/repo", projectWorktree: "D:/Projects/repo" }
|
||||
expect(resolveNewSessionWorktree(input)).toBe("main")
|
||||
expect(resolveNewSessionWorktree({ ...input, selected: "/worktree" })).toBe("/worktree")
|
||||
})
|
||||
|
||||
test("resolves the branch from the active location", () => {
|
||||
|
||||
@@ -11,27 +11,15 @@ import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
|
||||
import {
|
||||
isWorkspaceDirectory,
|
||||
isWorkspaceSelection,
|
||||
sameDirectory,
|
||||
workspaceDefaultSelection,
|
||||
workspaceDirectories,
|
||||
workspaceSelectionDestination,
|
||||
} from "@/workspaces/paths"
|
||||
|
||||
export function resolveNewSessionWorktree(input: {
|
||||
enabled: boolean
|
||||
selected?: string
|
||||
directory: string
|
||||
projectWorktree?: string
|
||||
fallback?: string
|
||||
}) {
|
||||
export function resolveNewSessionWorktree(input: { enabled: boolean; selected?: string; fallback?: string }) {
|
||||
if (!input.enabled) return "main"
|
||||
if (input.selected) return input.selected
|
||||
return normalizeNewSessionWorktree(input.fallback ?? "main", input.directory, input.projectWorktree)
|
||||
}
|
||||
|
||||
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
|
||||
if (value === "main" && projectWorktree && !sameDirectory(directory, projectWorktree)) return projectWorktree
|
||||
return value
|
||||
return input.fallback ?? "main"
|
||||
}
|
||||
|
||||
export function resolveNewSessionBranch(input: {
|
||||
@@ -92,8 +80,6 @@ export function createNewSessionWorkspaceController(input: {
|
||||
resolveNewSessionWorktree({
|
||||
enabled: visible(),
|
||||
selected: selected(),
|
||||
directory: sdk().directory,
|
||||
projectWorktree: currentProject()?.worktree,
|
||||
fallback: fallback(),
|
||||
}),
|
||||
)
|
||||
@@ -145,7 +131,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
remember,
|
||||
set: (worktree: string) => {
|
||||
input.setSelectedBranch(undefined)
|
||||
input.setSelectedWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
|
||||
input.setSelectedWorktree(worktree)
|
||||
remember(worktree)
|
||||
},
|
||||
create: (branch: string) => {
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import { createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||
import createPresence from "solid-presence"
|
||||
|
||||
export function createAnimatedPresence<T>(
|
||||
value: Accessor<T | undefined>,
|
||||
element: Accessor<HTMLElement | null>,
|
||||
identity?: Accessor<unknown>,
|
||||
minimumDuration = 0,
|
||||
) {
|
||||
const animation = createMemo<{ identity?: unknown; show: boolean; animate: boolean; value: T | undefined }>(
|
||||
(previous) => {
|
||||
const currentIdentity = identity?.()
|
||||
const current = value()
|
||||
const show = current !== undefined
|
||||
const same = !identity || previous?.identity === currentIdentity
|
||||
return {
|
||||
identity: currentIdentity,
|
||||
show,
|
||||
animate: previous !== undefined && same && (previous.animate || previous.show !== show),
|
||||
value: current ?? (same ? previous?.value : undefined),
|
||||
}
|
||||
},
|
||||
)
|
||||
const [tick, setTick] = createSignal(0)
|
||||
const animation = createMemo<{
|
||||
identity?: unknown
|
||||
show: boolean
|
||||
animate: boolean
|
||||
value: T | undefined
|
||||
started: number
|
||||
}>((previous) => {
|
||||
tick()
|
||||
const currentIdentity = identity?.()
|
||||
const current = value()
|
||||
const same = !identity || previous?.identity === currentIdentity
|
||||
const started = same && previous?.show ? previous.started : performance.now()
|
||||
const remaining =
|
||||
current === undefined && same && previous?.show ? minimumDuration - (performance.now() - started) : 0
|
||||
const show = current !== undefined || remaining > 0
|
||||
if (remaining > 0) {
|
||||
const timer = setTimeout(() => setTick((value) => value + 1), remaining)
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
}
|
||||
return {
|
||||
identity: currentIdentity,
|
||||
show,
|
||||
started,
|
||||
animate: previous !== undefined && same && (previous.animate || previous.show !== show),
|
||||
value: current ?? (same ? previous?.value : undefined),
|
||||
}
|
||||
})
|
||||
const presence = createPresence({ show: () => animation().show, element })
|
||||
return {
|
||||
...presence,
|
||||
|
||||
@@ -694,6 +694,7 @@ export const dict = {
|
||||
"session.queue.remove": "Remove",
|
||||
"session.queue.reorder": "Reorder queued prompt",
|
||||
"session.queue.attachments": "+ attachments",
|
||||
"session.timeline.working": "Working",
|
||||
"session.timeline.notice.finished": "{{actor}} finished",
|
||||
"session.timeline.notice.failed": "{{actor}} failed",
|
||||
"session.timeline.notice.cancelled": "{{actor}} cancelled",
|
||||
@@ -744,6 +745,7 @@ export const dict = {
|
||||
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
||||
"session.new.worktree.create": "Create new worktree",
|
||||
"session.new.worktree.creating": "Creating worktree",
|
||||
"session.new.worktree.draftPlaceholder": "Draft a follow-up while the worktree is being prepared...",
|
||||
"session.new.workspace.runIn": "Run session in",
|
||||
"session.new.workspace.triggerLocal": "Local",
|
||||
"session.new.workspace.local": "Local repository",
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DockShell, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { createMutation } from "@tanstack/solid-query"
|
||||
import { createEffect, createResource, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useDirectoryPicker } from "@/workspaces/selection/picker"
|
||||
import { createWorktree } from "@/workspaces/create"
|
||||
|
||||
export function SessionLocationMissing(props: { sessionID: string; projectID: string; directory: string }) {
|
||||
const language = useLanguage()
|
||||
const sdk = useServerSDK()
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const [state, setState] = createStore({ restoreFocus: false, worktreesOpen: false })
|
||||
const project = () => data.project.get(props.projectID)
|
||||
const [worktrees] = createResource(
|
||||
() => (state.worktreesOpen ? props.projectID : undefined),
|
||||
async (projectID) => {
|
||||
try {
|
||||
await sdk.api.worktree.refresh({ projectID })
|
||||
return await sdk.api.worktree.list({ projectID })
|
||||
} catch {
|
||||
showToast({ variant: "error", title: language.t("session.location.worktreesFailed") })
|
||||
return []
|
||||
}
|
||||
},
|
||||
// Seed latest so even the first fetch does not enter Suspense.
|
||||
{ initialValue: [] },
|
||||
)
|
||||
let button: HTMLButtonElement | undefined
|
||||
|
||||
const move = createMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; directory?: string }) => {
|
||||
// A deleted worktree cannot resolve its own location. Create from the
|
||||
// project's saved canonical checkout instead.
|
||||
const current = project()
|
||||
const destination =
|
||||
input.directory ??
|
||||
(current &&
|
||||
(await createWorktree({
|
||||
api: sdk.api,
|
||||
directory: current.canonical,
|
||||
project: { id: current.id, canonical: current.canonical, directory: current.canonical },
|
||||
})))
|
||||
if (!destination) return
|
||||
await sdk.api.session.move({ sessionID: input.sessionID, directory: destination })
|
||||
},
|
||||
onError: (error) => {
|
||||
setState("restoreFocus", true)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.move.failed"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
createEffect(() => {
|
||||
if (!state.restoreFocus || move.isPending || dialog.active) return
|
||||
setState("restoreFocus", false)
|
||||
button?.focus()
|
||||
})
|
||||
|
||||
function choose() {
|
||||
if (move.isPending) return
|
||||
const sessionID = props.sessionID
|
||||
pickDirectory({
|
||||
server: sdk.server,
|
||||
title: language.t("session.location.choose"),
|
||||
onSelect: (result) => {
|
||||
const directory = Array.isArray(result) ? result[0] : result
|
||||
if (directory) move.mutate({ sessionID, directory })
|
||||
if (!directory) setState("restoreFocus", true)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="session-location-missing">
|
||||
<DockShell class="flex flex-col gap-2 p-3">
|
||||
<div role="status" class="flex items-start gap-2 text-13-regular leading-[var(--line-height-base)]">
|
||||
<Icon name="warning" class="shrink-0 text-icon-warning-base" />
|
||||
<div class="min-w-0 flex flex-col gap-1">
|
||||
<div class="font-medium text-text-strong">{language.t("session.location.unavailable")}</div>
|
||||
<div class="break-all font-mono text-12-regular text-text-weak">{props.directory}</div>
|
||||
<div class="text-text-base">{language.t("session.location.description")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</DockShell>
|
||||
<DockTray class="flex flex-wrap justify-end gap-2 p-2">
|
||||
<Show when={project()?.vcs === "git"}>
|
||||
<Menu placement="top-end" onOpenChange={(open) => setState("worktreesOpen", open)}>
|
||||
<Menu.Trigger as={Button} variant="neutral" disabled={move.isPending}>
|
||||
{language.t("session.location.worktree")}
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="max-h-80 max-w-[calc(100vw-32px)] overflow-y-auto">
|
||||
<Menu.Item onSelect={() => move.mutate({ sessionID: props.sessionID })} disabled={move.isPending}>
|
||||
<Icon name="workspace-new" />
|
||||
{language.t("workspace.new")}
|
||||
</Menu.Item>
|
||||
<Show when={worktrees.loading}>
|
||||
<Menu.Item disabled>{language.t("common.loading")}</Menu.Item>
|
||||
</Show>
|
||||
<For each={worktrees.latest.filter((item) => item.strategy && item.directory !== props.directory)}>
|
||||
{(worktree) => (
|
||||
<Menu.Item
|
||||
title={worktree.directory}
|
||||
onSelect={() => move.mutate({ sessionID: props.sessionID, directory: worktree.directory })}
|
||||
disabled={move.isPending}
|
||||
>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="truncate">{getFilename(worktree.directory)}</span>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
<Button ref={button} variant="contrast" onClick={choose} disabled={move.isPending}>
|
||||
{language.t(move.isPending ? "session.location.moving" : "session.location.choose")}
|
||||
</Button>
|
||||
</DockTray>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, on, onMount, Show } from "solid-js"
|
||||
import { createEffect, on, onMount } from "solid-js"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import { createComposerModel, type ComposerModel } from "@/composer/model"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
@@ -32,7 +32,6 @@ import { SessionQueuePanel } from "./queue-panel"
|
||||
import { resolveSessionComposerSelection } from "./selection"
|
||||
import { createSessionRequestModel } from "../requests/model"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SessionLocationMissing } from "./location-missing"
|
||||
|
||||
export function createActiveSessionRegion(input: {
|
||||
session: SessionModel
|
||||
@@ -220,14 +219,6 @@ export function ActiveSessionComposerRegion(props: {
|
||||
onResponseSubmit: () => void
|
||||
}) {
|
||||
const settings = useSettings()
|
||||
const location = useWorkspaceLocation()
|
||||
const missing = createMemo(() => {
|
||||
const error = location().error
|
||||
const current = props.session.data.info()?.location
|
||||
if (error && current?.directory === error.location.directory && current.workspaceID === error.location.workspaceID)
|
||||
return current.directory
|
||||
return undefined
|
||||
})
|
||||
const region = createSessionComposerRegionController({
|
||||
state: props.model.region.state,
|
||||
parentID: props.session.data.parentID,
|
||||
@@ -256,26 +247,12 @@ export function ActiveSessionComposerRegion(props: {
|
||||
<SessionComposerRegion
|
||||
controller={region}
|
||||
composer={
|
||||
<Show
|
||||
when={missing()}
|
||||
keyed
|
||||
fallback={
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer model={composer} borderUnderlay />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(directory) => (
|
||||
<SessionLocationMissing
|
||||
sessionID={requireSessionID(props.session)}
|
||||
projectID={props.session.data.info()!.projectID}
|
||||
directory={directory}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer model={composer} borderUnderlay />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -359,6 +359,7 @@ export function SessionSidePanel(props: {
|
||||
>
|
||||
<Tabs.Trigger
|
||||
value={SESSION_OPEN_FILE_TAB}
|
||||
class="group"
|
||||
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
|
||||
closeButton={
|
||||
<Tooltip
|
||||
@@ -373,16 +374,29 @@ export function SessionSidePanel(props: {
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<Tabs.CloseButton
|
||||
onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100"
|
||||
classList={{ "opacity-100": activeTab() === SESSION_OPEN_FILE_TAB }}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
tabs().close(SESSION_OPEN_FILE_TAB)
|
||||
}}
|
||||
icon={<Icon name="xmark-small" />}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
hideCloseButton
|
||||
>
|
||||
<div class="flex items-center gap-1.5 italic">
|
||||
<Icon name="open-file" size="small" />
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="file-tree" size="small" />
|
||||
<span>{language.t("command.file.open")}</span>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Show, type JSX } from "solid-js"
|
||||
import { Show } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
|
||||
export type SessionHeaderActionsState = {
|
||||
status?: { label: string; content: () => JSX.Element }
|
||||
reviewLabel: string
|
||||
reviewKeybind: string[]
|
||||
reviewVisible: boolean
|
||||
@@ -16,13 +15,6 @@ export type SessionHeaderActionsState = {
|
||||
export function SessionHeaderActions(props: { state: SessionHeaderActionsState }) {
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={props.state.status}>
|
||||
{(status) => (
|
||||
<Tooltip appearance="standard" placement="bottom" value={status().label}>
|
||||
{status().content()}
|
||||
</Tooltip>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.state.reviewVisible}>
|
||||
<Tooltip
|
||||
class="shrink-0"
|
||||
@@ -40,7 +32,7 @@ export function SessionHeaderActions(props: { state: SessionHeaderActionsState }
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="!w-9 shrink-0"
|
||||
class="shrink-0"
|
||||
state={props.state.reviewOpened ? "pressed" : undefined}
|
||||
onClick={props.state.onReviewToggle}
|
||||
aria-label={props.state.reviewLabel}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -7,6 +7,7 @@ import { useSessionLayout } from "@/session/session-layout"
|
||||
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
|
||||
import { StatusPopover } from "@/shell/status/status-popover"
|
||||
import { TitlebarRight } from "@/shell/titlebar/right-slot"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { SessionHeaderActions, type SessionHeaderActionsState } from "./session-header-actions"
|
||||
|
||||
export function SessionHeader() {
|
||||
@@ -15,14 +16,9 @@ export function SessionHeader() {
|
||||
const settings = useSettings()
|
||||
const { view } = useSessionLayout()
|
||||
|
||||
const status = settings.visibility.status
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
const actions = createMemo<SessionHeaderActionsState>(() => ({
|
||||
status:
|
||||
isDesktop() && status()
|
||||
? { label: language.t("status.popover.trigger"), content: () => <StatusPopover /> }
|
||||
: undefined,
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: reviewTooltipKeybind(command),
|
||||
reviewVisible: isDesktop(),
|
||||
@@ -31,8 +27,15 @@ export function SessionHeader() {
|
||||
}))
|
||||
|
||||
return (
|
||||
<TitlebarRight>
|
||||
<>
|
||||
<TitlebarRight>
|
||||
<Show when={isDesktop() && settings.visibility.status()}>
|
||||
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopover />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</TitlebarRight>
|
||||
<SessionHeaderActions state={actions()} />
|
||||
</TitlebarRight>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createEffect, on, onCleanup } from "solid-js"
|
||||
import { useLocation } from "@solidjs/router"
|
||||
import { ComposerEditor } from "@/composer/editor/editor"
|
||||
import { setCursorPosition } from "@/composer/editor/dom"
|
||||
import { createComposerEditor } from "@/composer/editor/interaction"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { PendingSession } from "@/shell/tabs/tabs"
|
||||
|
||||
export function PreparingComposer(props: { pending: PendingSession }) {
|
||||
const language = useLanguage()
|
||||
const location = useLocation()
|
||||
let element: HTMLElement | undefined
|
||||
const editor = createComposerEditor({
|
||||
store: () => props.pending.composer.store,
|
||||
commands: () => [],
|
||||
context: () => [],
|
||||
searchContextFiles: () => [],
|
||||
onEditor: (value) => {
|
||||
element = value
|
||||
},
|
||||
view: {
|
||||
draftOnly: true,
|
||||
placeholder: () => language.t("session.new.worktree.draftPlaceholder"),
|
||||
submit: { stopping: () => false, onSubmit() {}, onStop() {} },
|
||||
},
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => props.pending,
|
||||
(pending) => {
|
||||
const pathname = location.pathname
|
||||
editor.restoreFocus(pending.composer.cursor())
|
||||
onCleanup(() => {
|
||||
if (document.activeElement !== element) return
|
||||
const cursor = pending.composer.cursor()
|
||||
requestAnimationFrame(() => {
|
||||
if (location.pathname !== pathname) return
|
||||
const next = document.querySelector<HTMLDivElement>('[data-component="composer-editor"]')
|
||||
if (!next || next === element) return
|
||||
next.focus()
|
||||
setCursorPosition(next, cursor ?? 0)
|
||||
})
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<div data-component="session-composer-dock" class="w-full shrink-0 bg-v2-background-bg-base pb-3">
|
||||
<div class="mx-auto w-full max-w-[1000px] px-3">
|
||||
<ComposerEditor controller={editor} modelControlsVisible={false} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { IncompatibleServerPanel } from "./incompatible-server-panel"
|
||||
import { SessionErrorFallback } from "./route-error"
|
||||
import { createSessionResolution } from "./session-resolution"
|
||||
import { SessionScreen } from "./screen"
|
||||
import { PreparingComposer } from "./preparing-composer"
|
||||
|
||||
export function TargetSessionRouteContent() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
@@ -64,7 +65,8 @@ function PreparingSession(props: { sessionID: string; pending: PendingSession })
|
||||
}}
|
||||
>
|
||||
<div data-component="session-preparing" class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="mx-auto w-full min-w-0 max-w-[1000px] px-4 pt-5 pb-5 md:px-5">
|
||||
<SessionIdentityHeader sessionID={props.sessionID} />
|
||||
<div class="mx-auto w-full min-w-0 max-w-[1000px] px-4 pb-5 md:px-5">
|
||||
<SessionUserMessage
|
||||
sessionID={props.sessionID}
|
||||
message={props.pending.message}
|
||||
@@ -84,6 +86,7 @@ function PreparingSession(props: { sessionID: string; pending: PendingSession })
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PreparingComposer pending={props.pending} />
|
||||
</DataProvider>
|
||||
</SessionStatePanel>
|
||||
)
|
||||
@@ -116,7 +119,7 @@ function ResolvedTargetSessionRoute() {
|
||||
const current = createSessionResolution(
|
||||
() => params.id,
|
||||
() => data.session,
|
||||
{ children: true },
|
||||
{ children: true, connected: () => server.ctx.sdk.connection.status() === "connected" },
|
||||
)
|
||||
const directory = createMemo(() => current()?.location.directory)
|
||||
|
||||
@@ -133,7 +136,7 @@ function ResolvedTargetSessionRoute() {
|
||||
>
|
||||
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
|
||||
{(value) => (
|
||||
<LocationProvider directory={value}>
|
||||
<LocationProvider directory={value} workspaceID={() => current()?.location.workspaceID}>
|
||||
<SessionUIProvider directory={value()} server={server.key}>
|
||||
<TargetSessionPage />
|
||||
</SessionUIProvider>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { SessionHeader } from "@/session/header/session-header"
|
||||
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
@@ -273,12 +272,12 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SessionHeader />
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
<div
|
||||
classList={{
|
||||
"@container relative z-10 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||
"@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]":
|
||||
true,
|
||||
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!screen.size.active() && sidePresence.animate(),
|
||||
"transition-none": screen.size.active() || !sidePresence.animate(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant } from "@/shell/state/layout"
|
||||
import { tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
@@ -27,6 +28,8 @@ export function SessionTitleHeader(props: ParentProps) {
|
||||
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
const pending = createMemo(() => tabs.pendingSession(server.key, props.sessionID))
|
||||
const settings = useSettings()
|
||||
const navigate = useNavigate()
|
||||
const tab = createMemo(() =>
|
||||
@@ -54,10 +57,18 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
const parentTitle = createMemo(() => {
|
||||
const id = parentID()
|
||||
const current = tab()
|
||||
return sessionTitle(parent()?.title ?? (current?.type === "session" && current.sessionId === id ? info()?.title : undefined))
|
||||
return sessionTitle(
|
||||
parent()?.title ?? (current?.type === "session" && current.sessionId === id ? info()?.title : undefined),
|
||||
)
|
||||
})
|
||||
const directory = createMemo(() => props.session?.location.directory ?? info()?.directory)
|
||||
const title = createMemo(() => sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)))
|
||||
const directory = createMemo(
|
||||
() => props.session?.location.directory ?? pending()?.draft.directory ?? info()?.directory,
|
||||
)
|
||||
const title = createMemo(() =>
|
||||
pending()
|
||||
? language.t("command.session.new")
|
||||
: sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)),
|
||||
)
|
||||
const project = createMemo(() => {
|
||||
const projects = server.ctx.projects.list()
|
||||
if (props.session) return projectForSession(props.session, projects)
|
||||
@@ -70,7 +81,7 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
})
|
||||
const showProjectIcon = () =>
|
||||
import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon() && !!directory()
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), directory() ?? ""))
|
||||
const workspaceSession = createMemo(() => !!pending() || isWorkspaceDirectory(project(), directory() ?? ""))
|
||||
const navigateParent = () => {
|
||||
const id = parentID()
|
||||
const current = tab()
|
||||
|
||||
@@ -34,7 +34,7 @@ type Resolution<T> = { id: string; store: SessionStore<T> } & (
|
||||
export function createSessionResolution<T>(
|
||||
sessionID: () => string | undefined,
|
||||
sessions: () => SessionStore<T>,
|
||||
options?: { children?: boolean },
|
||||
options?: { children?: boolean; connected?: () => boolean },
|
||||
) {
|
||||
const cached = createMemo(() => {
|
||||
const id = sessionID()
|
||||
@@ -46,15 +46,15 @@ export function createSessionResolution<T>(
|
||||
// Start independent reads before constructing the selected view, including
|
||||
// when its metadata is cached but its transcript has never been loaded.
|
||||
createRenderEffect(
|
||||
on([sessionID, sessions] as const, ([id, store]) => {
|
||||
if (!id) return
|
||||
on([sessionID, sessions, () => options?.connected?.() ?? true] as const, ([id, store, connected]) => {
|
||||
if (!id || !connected) return
|
||||
let stale = false
|
||||
onCleanup(() => {
|
||||
stale = true
|
||||
})
|
||||
// The timeline owns message errors; metadata resolution stays independent.
|
||||
void store.message.sync(id).catch(() => undefined)
|
||||
if (cached() && !options?.children) {
|
||||
if (cached() && !options?.children && !options?.connected) {
|
||||
setStatus({ id, store, state: "settled" })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { parseCommentNote, readPromptPresentation } from "@/composer/comment-not
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionHeader } from "@/session/header/session-header"
|
||||
|
||||
type BackgroundTask = {
|
||||
id: string
|
||||
@@ -539,33 +540,96 @@ function MessageTimelineView(
|
||||
.findLast((ref) => blocking.has(ref.partID))?.partID
|
||||
})
|
||||
const [backgroundHintRef, setBackgroundHintRef] = createSignal<HTMLDivElement>()
|
||||
const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null)
|
||||
const backgroundHintPresence = createAnimatedPresence(
|
||||
backgroundHintPartID,
|
||||
() => backgroundHintRef() ?? null,
|
||||
sessionID,
|
||||
1000,
|
||||
)
|
||||
const showWorking = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id || sessionStatus().type !== "busy") return false
|
||||
if (data.session.permission.list(id)?.length || data.session.form.list(id)?.length) return false
|
||||
const active = projection.activeMessageID()
|
||||
if (!active) return false
|
||||
const assistant = projection.assistantMessagesByParent().get(active)?.at(-1)
|
||||
if (assistant?.retry) return false
|
||||
// Pending steers still project under the previous response until delivery.
|
||||
// Its error must not hide feedback for a newly submitted prompt.
|
||||
if (
|
||||
assistant?.error &&
|
||||
!data.session.pending.list(id).some((item) => item.type === "user" && item.delivery === "steer")
|
||||
)
|
||||
return false
|
||||
const content = assistant?.content.at(-1)
|
||||
if (
|
||||
assistant?.time.completed === undefined &&
|
||||
assistant?.time.streamed === undefined &&
|
||||
content?.type === "text" &&
|
||||
content.text.trim()
|
||||
)
|
||||
return false
|
||||
const background = new Set(props.background.tasks().map((task) => task.id))
|
||||
return !projection.rows().some((row) => {
|
||||
if (row.userMessageID !== active) return false
|
||||
if (row._tag === "Thinking") return true
|
||||
if (row._tag === "Notice") {
|
||||
const message = messageByID().get(row.messageID)
|
||||
return message?.type === "compaction" && message.status === "running"
|
||||
}
|
||||
// Used groups keep the fallback regardless of disclosure state.
|
||||
if (row._tag !== "AssistantPart" || row.group.type === "context") return false
|
||||
return (row.group.type === "part" ? [row.group.ref] : row.group.refs).some((ref) => {
|
||||
const content = Timeline.resolveContent(messageByID().get(ref.messageID), ref.partID)
|
||||
if (content?.type !== "tool") return false
|
||||
if (content.state.status === "streaming" || content.state.status === "running") return true
|
||||
const taskID = content.state.metadata?.[content.name === "subagent" ? "sessionID" : "shellID"]
|
||||
return background.has(content.id) || (typeof taskID === "string" && background.has(taskID))
|
||||
})
|
||||
})
|
||||
})
|
||||
return (
|
||||
<VirtualizedTimeline
|
||||
workspaceSession={workspaceSession}
|
||||
bottomSpacer={
|
||||
<Show when={backgroundHintPresence.present()}>
|
||||
<div
|
||||
data-component="session-background-hint-row"
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-[1000px] md:mx-auto": props.centered,
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<Show when={showWorking()}>
|
||||
<div
|
||||
ref={setBackgroundHintRef}
|
||||
class="duration-150 motion-reduce:animate-none"
|
||||
data-component="session-working"
|
||||
role="status"
|
||||
classList={{
|
||||
[`flex h-9 items-start pt-3 ${turnPadding()}`]: true,
|
||||
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
|
||||
"animate-out fade-out fill-mode-forwards":
|
||||
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-[1000px] md:mx-auto": props.centered,
|
||||
}}
|
||||
>
|
||||
<BackgroundMoveHint />
|
||||
<div class={`flex h-9 items-start pt-3 text-[13px] font-[530] leading-text-compact ${turnPadding()}`}>
|
||||
<TextShimmer text={language.t("session.timeline.working")} active />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={backgroundHintPresence.present()}>
|
||||
<div
|
||||
data-component="session-background-hint-row"
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-[1000px] md:mx-auto": props.centered,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={setBackgroundHintRef}
|
||||
class="duration-150 motion-reduce:animate-none"
|
||||
classList={{
|
||||
[`flex items-start ${showWorking() ? "h-6" : "h-9 pt-3"} ${turnPadding()}`]: true,
|
||||
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
|
||||
"animate-out fade-out fill-mode-forwards":
|
||||
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
|
||||
}}
|
||||
>
|
||||
<BackgroundMoveHint />
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
deferred={(row) => {
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "part") return false
|
||||
@@ -669,6 +733,58 @@ function MessageTimelineView(
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={sessionID()} keyed>
|
||||
{(id) => (
|
||||
<Menu
|
||||
gutter={6}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||
>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="outline-dots" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
aria-expanded={title.menuOpen}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
style={{ "min-width": "160px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!title.pendingRename) return
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
}}
|
||||
>
|
||||
<Show when={!parentID()}>
|
||||
<Menu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
{language.t("common.rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={() => void props.action.export(id)}>
|
||||
{language.t("common.export")}...
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Show when={!parentID()}>
|
||||
{/* TODO: Need a session archive API. */}
|
||||
<Menu.Separator />
|
||||
<Menu.Item onSelect={() => props.action.showDelete(id)}>
|
||||
{language.t("common.delete")}...
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sessionID()} keyed>
|
||||
@@ -712,56 +828,7 @@ function MessageTimelineView(
|
||||
</Popover>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!parentID()}>
|
||||
<Menu
|
||||
gutter={6}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => {
|
||||
setTitle("menuOpen", open)
|
||||
if (open) return
|
||||
}}
|
||||
>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="outline-dots" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
aria-expanded={title.menuOpen}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
style={{ width: "120px", "min-width": "120px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (title.pendingRename) {
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
return
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Menu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
{language.t("common.rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={() => void props.action.export(id)}>
|
||||
{language.t("common.export")}...
|
||||
</Menu.Item>
|
||||
{/* TODO: Need a session archive API. */}
|
||||
<Menu.Separator />
|
||||
<Menu.Item onSelect={() => props.action.showDelete(id)}>
|
||||
{language.t("common.delete")}...
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
<SessionHeader />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -60,6 +60,7 @@ export function SessionWorkspaceMenu(props: {
|
||||
selection === "create"
|
||||
? await createWorktree({
|
||||
api: sdk.api,
|
||||
data,
|
||||
directory: props.directory,
|
||||
project: data.location.info({ directory: props.directory })?.project,
|
||||
})
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function Layout(props: ParentProps) {
|
||||
<aside
|
||||
ref={(element) => setState("tabsMount", element)}
|
||||
data-slot="vertical-tabs-sidebar"
|
||||
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
|
||||
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
|
||||
style={{
|
||||
width: `${state.tabsWidth}px`,
|
||||
"padding-bottom": "max(8px, env(safe-area-inset-bottom, 0px))",
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { hasNonBlockingServiceIssue, hasServiceNeedingAttention, serverStatusDotClass } from "./indicator"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
|
||||
const Body = lazy(() => import("./body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||
|
||||
@@ -16,6 +18,8 @@ export function StatusPopover() {
|
||||
const global = useGlobal()
|
||||
const data = useData()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const settings = useSettings()
|
||||
const desktop = createMediaQuery("(min-width: 768px)")
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
|
||||
@@ -37,6 +41,8 @@ export function StatusPopover() {
|
||||
serverHealth: serverHealth(),
|
||||
attention: attention(),
|
||||
issue: issue(),
|
||||
placement: desktop() && settings.appearance.tabLayout() === "vertical" ? "top-start" : "bottom-end",
|
||||
shift: desktop() && settings.appearance.tabLayout() === "vertical" ? 0 : -168,
|
||||
label: language.t("status.popover.trigger"),
|
||||
onOpenChange: setShown,
|
||||
body: () => (
|
||||
@@ -55,6 +61,8 @@ type StatusPopoverState = {
|
||||
serverHealth: boolean | undefined
|
||||
attention: boolean
|
||||
issue: boolean
|
||||
placement: "top-start" | "bottom-end"
|
||||
shift: number
|
||||
label: string
|
||||
onOpenChange: (value: boolean) => void
|
||||
body: () => JSX.Element
|
||||
@@ -77,8 +85,8 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
|
||||
class:
|
||||
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
|
||||
gutter: 4,
|
||||
placement: "bottom-end" as const,
|
||||
shift: -168,
|
||||
placement: props.state.placement,
|
||||
shift: props.state.shift,
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,7 +12,13 @@ import { SessionTabsRemovedDetail } from "@/shell/titlebar/session-events"
|
||||
import { sessionHref } from "@/shell/routes/session"
|
||||
import { createTabMemory } from "./memory"
|
||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
|
||||
import { createDraftComposerState, type PromptModel } from "@/composer/state"
|
||||
import {
|
||||
createDraftComposerState,
|
||||
createMemoryComposerState,
|
||||
type ComposerState,
|
||||
type PromptModel,
|
||||
} from "@/composer/state"
|
||||
import { appendPrompt, promptLength } from "@/composer/prompt-parts"
|
||||
import { TabStorage } from "./schema"
|
||||
import { useCurrentRoute } from "@/shell/state/layout"
|
||||
|
||||
@@ -24,6 +30,7 @@ export type PendingSession = {
|
||||
draft: DraftTab
|
||||
message: SessionMessageUser
|
||||
selection: ComposerSelection
|
||||
composer: ComposerState
|
||||
}
|
||||
|
||||
export type TabInfo = typeof TabStorage.Info.Type
|
||||
@@ -285,8 +292,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const draft = { ...actions.draft(draftID) }
|
||||
const next = { type: "session" as const, ...session }
|
||||
const key = tabKey(next)
|
||||
const composer = createMemoryComposerState()
|
||||
const ready = startTransition(() => {
|
||||
setPending(key, { draft, ...preview })
|
||||
setPending(key, { draft, ...preview, composer })
|
||||
const index = store.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
|
||||
if (index === -1) return
|
||||
const active = location.pathname === "/new-session" && location.query.draftId === draftID
|
||||
@@ -301,9 +309,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
|
||||
return {
|
||||
ready,
|
||||
async complete() {
|
||||
async complete(target: ReturnType<ComposerState["capture"]>) {
|
||||
await ready
|
||||
if (!pending[key]) return
|
||||
await memory.get<ComposerState>(key, "prompt")?.ready.promise
|
||||
if (promptLength(composer.current())) target.set(composer.current(), composer.cursor())
|
||||
await startTransition(() => setPending(key, undefined))
|
||||
memory.remove(tabKey(draft))
|
||||
removeDraftPersisted(draftID)
|
||||
@@ -311,6 +321,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
async rollback(worktree?: string) {
|
||||
await ready
|
||||
if (!pending[key]) return
|
||||
const original = memory.get<ComposerState>(tabKey(draft), "prompt")
|
||||
if (original && promptLength(composer.current())) {
|
||||
// Nothing was submitted: recover both inputs in the original draft.
|
||||
const restored = appendPrompt(original.current(), composer.current())
|
||||
original.set(restored, promptLength(restored))
|
||||
}
|
||||
await startTransition(() => {
|
||||
const index = store.findIndex((tab) => tabKey(tab) === key)
|
||||
if (index !== -1) {
|
||||
|
||||
@@ -86,6 +86,10 @@
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
[data-slot="vertical-tabs-sidebar"] [data-titlebar-tab-list][data-orientation="vertical"] {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-slot] {
|
||||
--tab-separator: var(--v2-background-bg-layer-03);
|
||||
position: relative;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
|
||||
import { displayName, projectForSession } from "@/shell/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { sessionLabel } from "@/session/title"
|
||||
import { canOpenTabRename, forwardTabRef } from "./tab-gesture"
|
||||
@@ -25,6 +26,7 @@ export function TabNavItem(props: {
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
session: SessionInfo | undefined
|
||||
preparing: boolean
|
||||
fallbackTitle?: string
|
||||
onRename: (title: string) => Promise<void>
|
||||
onClose: () => void
|
||||
@@ -239,7 +241,14 @@ export function TabNavItem(props: {
|
||||
when={props.session}
|
||||
keyed
|
||||
fallback={
|
||||
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
||||
<Show
|
||||
when={props.preparing}
|
||||
fallback={
|
||||
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
||||
}
|
||||
>
|
||||
<SessionProgressIndicatorV2 />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(session) => (
|
||||
@@ -384,8 +393,11 @@ export function DraftTabItem(props: {
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-state={props.active || props.pressed ? "pressed" : undefined}
|
||||
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap"
|
||||
classList={{ invisible: props.hidden }}
|
||||
class="group relative flex h-7 min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 whitespace-nowrap"
|
||||
classList={{
|
||||
invisible: props.hidden,
|
||||
"w-full [container-type:inline-size]": props.orientation === "vertical",
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
||||
event.preventDefault()
|
||||
@@ -418,14 +430,16 @@ export function DraftTabItem(props: {
|
||||
if (props.suppressNavigation) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
class="flex h-full min-w-0 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
classList={{ "flex-1": props.orientation === "vertical", "flex-none pe-10": props.orientation !== "vertical" }}
|
||||
>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<Icon name="edit" />
|
||||
</span>
|
||||
<span
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
|
||||
class="min-w-0 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
|
||||
classList={{ "flex-1": props.orientation === "vertical", "flex-none": props.orientation !== "vertical" }}
|
||||
>
|
||||
{props.title}
|
||||
</span>
|
||||
|
||||
@@ -27,6 +27,7 @@ function SessionTabSlot(props: {
|
||||
active: boolean
|
||||
orientation: "horizontal" | "vertical"
|
||||
session: SessionInfo | undefined
|
||||
preparing: boolean
|
||||
fallbackTitle?: string
|
||||
onRename: (title: string) => Promise<void>
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
@@ -62,6 +63,7 @@ function SessionTabSlot(props: {
|
||||
href={tabHref(props.tab)}
|
||||
server={props.tab.server}
|
||||
session={props.session}
|
||||
preparing={props.preparing}
|
||||
fallbackTitle={props.fallbackTitle}
|
||||
onRename={props.onRename}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
@@ -167,6 +169,7 @@ function SessionTabEntry(props: {
|
||||
active={props.active}
|
||||
orientation={props.orientation}
|
||||
session={session()}
|
||||
preparing={!!pending()}
|
||||
fallbackTitle={
|
||||
pending()
|
||||
? language.t("command.session.new")
|
||||
@@ -209,7 +212,7 @@ function DraftTabSlot(props: {
|
||||
data-orientation={props.orientation}
|
||||
class="relative flex"
|
||||
classList={{
|
||||
"w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
|
||||
"w-max min-w-7 max-w-56 shrink-0": props.orientation === "horizontal",
|
||||
"w-full shrink-0": props.orientation === "vertical",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
|
||||
import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
|
||||
import { sessionLabel } from "@/session/title"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { useSettingsDialog } from "@/settings/command"
|
||||
|
||||
@@ -36,6 +37,7 @@ const windowsTitlebarHeight = 44 // Includes the content inset; matches the nati
|
||||
const minTitlebarZoom = 0.25
|
||||
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
||||
const macTrafficLightsBaseWidth = 84
|
||||
const macTrafficLightsTopClearance = 28
|
||||
|
||||
export type TitlebarUpdate = {
|
||||
version: string | undefined
|
||||
@@ -62,6 +64,7 @@ export function Titlebar(props: {
|
||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||
const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.())
|
||||
const macVerticalTabs = createMemo(() => mac() && !!props.verticalTabs)
|
||||
const zoom = () => platform.webviewZoom?.() ?? 1
|
||||
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
|
||||
const minHeight = () => {
|
||||
@@ -104,6 +107,7 @@ export function Titlebar(props: {
|
||||
const rightState = createMemo<TitlebarRightState>(() => ({
|
||||
update: updateState(),
|
||||
}))
|
||||
const hideVerticalTitlebar = createMemo(() => !!props.verticalTabs && !windows())
|
||||
|
||||
const back = () => {
|
||||
const next = backPath(history)
|
||||
@@ -139,6 +143,7 @@ export function Titlebar(props: {
|
||||
return (
|
||||
<header
|
||||
data-slot="titlebar-v2"
|
||||
hidden={hideVerticalTitlebar()}
|
||||
classList={{
|
||||
"shrink-0 relative flex flex-row h-9 bg-v2-background-bg-deep overflow-visible": true,
|
||||
"order-last": bottom(),
|
||||
@@ -171,11 +176,15 @@ export function Titlebar(props: {
|
||||
const tabs = useTabs()
|
||||
const tabsStore = tabs.store
|
||||
const tabsStoreActions = tabs
|
||||
const preparing = createMemo(() => {
|
||||
const route = layout.route()
|
||||
return route.type === "session" && !!tabs.pendingSession(route.server, route.sessionId)
|
||||
})
|
||||
const [loadedSession] = createResource(
|
||||
() => {
|
||||
const route = layout.route()
|
||||
if (route.type !== "session") return undefined
|
||||
if (tabs.pendingSession(route.server, route.sessionId)) return undefined
|
||||
if (preparing()) return undefined
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
|
||||
return conn ? { route, ctx: global.ensureServerCtx(conn) } : undefined
|
||||
},
|
||||
@@ -184,7 +193,7 @@ export function Titlebar(props: {
|
||||
const session = createMemo(() => {
|
||||
const route = layout.route()
|
||||
if (route.type !== "session") return
|
||||
if (tabs.pendingSession(route.server, route.sessionId)) return
|
||||
if (preparing()) return
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
|
||||
const cached = conn ? global.ensureServerCtx(conn).data.session.get(route.sessionId) : undefined
|
||||
if (cached) return cached
|
||||
@@ -306,6 +315,48 @@ export function Titlebar(props: {
|
||||
}
|
||||
}
|
||||
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
|
||||
const homeButton = (vertical = false) => (
|
||||
<Show
|
||||
when={vertical}
|
||||
fallback={
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
{language.t("home.title")}
|
||||
<Keybind keys={command.keybindParts("home.toggle")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
class="shrink-0"
|
||||
>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="!w-9 shrink-0"
|
||||
icon={<Icon name="grid-plus" />}
|
||||
state={layout.route().type === "home" ? "pressed" : undefined}
|
||||
onClick={toggleHome}
|
||||
aria-label={language.t("home.title")}
|
||||
aria-pressed={layout.route().type === "home"}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-action="vertical-tabs-home"
|
||||
data-state={layout.route().type === "home" ? "pressed" : undefined}
|
||||
class="mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base"
|
||||
onClick={toggleHome}
|
||||
aria-label={language.t("home.title")}
|
||||
aria-pressed={layout.route().type === "home"}
|
||||
>
|
||||
<Icon name="grid-plus" />
|
||||
{language.t("home.title")}
|
||||
</button>
|
||||
</Show>
|
||||
)
|
||||
|
||||
command.register("titlebar-home", () => [
|
||||
{
|
||||
@@ -381,36 +432,13 @@ export function Titlebar(props: {
|
||||
"md:pl-4": !macTrafficLights(),
|
||||
}}
|
||||
>
|
||||
<Show when={!mobile()}>
|
||||
<Show when={!mobile() && !props.verticalTabs}>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
</Show>
|
||||
<Show when={windows() || linux()}>
|
||||
<WindowsAppMenu command={command} platform={platform} />
|
||||
</Show>
|
||||
<Show when={!mobile()}>
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
{language.t("home.title")}
|
||||
<Keybind keys={command.keybindParts("home.toggle")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
class="shrink-0"
|
||||
>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="!w-9 shrink-0"
|
||||
icon={<Icon name="grid-plus" />}
|
||||
state={layout.route().type === "home" ? "pressed" : undefined}
|
||||
onClick={toggleHome}
|
||||
aria-label={language.t("home.title")}
|
||||
aria-pressed={layout.route().type === "home"}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={!mobile() && !props.verticalTabs}>{homeButton()}</Show>
|
||||
|
||||
<Show
|
||||
when={!mobile()}
|
||||
@@ -441,10 +469,17 @@ export function Titlebar(props: {
|
||||
tab().type === "draft" ? (
|
||||
<Icon name="edit" />
|
||||
) : (
|
||||
<span
|
||||
class="block size-4 rounded-[3px] border border-v2-border-border-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Show
|
||||
when={preparing()}
|
||||
fallback={
|
||||
<span
|
||||
class="block size-4 rounded-[3px] border border-v2-border-border-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SessionProgressIndicatorV2 />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -583,31 +618,53 @@ export function Titlebar(props: {
|
||||
{(vertical) => (
|
||||
<Show when={vertical().mount} keyed>
|
||||
{(mount) => (
|
||||
<Portal mount={mount}>
|
||||
<TitlebarTabStrip
|
||||
orientation="vertical"
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab()}
|
||||
onNavigate={(tab, el) => {
|
||||
tabs.select(tab)
|
||||
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
|
||||
}}
|
||||
onClose={(tab) => {
|
||||
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
|
||||
if (index !== -1) tabsStoreActions.closeTab(index)
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
<Portal
|
||||
mount={mount}
|
||||
ref={(element) => (element.className = "flex size-full min-h-0 flex-col")}
|
||||
>
|
||||
<Show when={macVerticalTabs()}>
|
||||
<div
|
||||
class="relative w-full shrink-0"
|
||||
style={{ height: `${macTrafficLightsTopClearance / zoom()}px` }}
|
||||
data-tauri-drag-region
|
||||
></div>
|
||||
</Show>
|
||||
{homeButton(true)}
|
||||
<button
|
||||
type="button"
|
||||
data-action="vertical-tabs-new-session"
|
||||
class="mt-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
|
||||
class="flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
{language.t("command.session.new")}
|
||||
</button>
|
||||
<div class="my-1 h-px w-full shrink-0 bg-v2-border-border-muted" aria-hidden="true" />
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-1">
|
||||
<TitlebarTabStrip
|
||||
orientation="vertical"
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab()}
|
||||
onNavigate={(tab, el) => {
|
||||
tabs.select(tab)
|
||||
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
|
||||
}}
|
||||
onClose={(tab) => {
|
||||
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
|
||||
if (index !== -1) tabsStoreActions.closeTab(index)
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="vertical-tabs-footer" class="relative mt-auto h-9 w-full shrink-0">
|
||||
<div class="absolute bottom-0 left-0 flex h-9 items-center">
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0 flex h-9 items-center">
|
||||
<TitlebarRightMount />
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
@@ -617,7 +674,7 @@ export function Titlebar(props: {
|
||||
<Show when={!mobile()}>
|
||||
<div class="flex-1" />
|
||||
</Show>
|
||||
<TitlebarRight state={rightState()} />
|
||||
<TitlebarRight state={rightState()} mount={!props.verticalTabs} />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
@@ -640,13 +697,15 @@ type TitlebarRightState = {
|
||||
update: TitlebarUpdatePillState
|
||||
}
|
||||
|
||||
function TitlebarRight(props: { state: TitlebarRightState }) {
|
||||
function TitlebarRight(props: { state: TitlebarRightState; mount?: boolean }) {
|
||||
return (
|
||||
<div class="relative z-20 flex shrink-0 items-center justify-end gap-0 overflow-visible">
|
||||
<Show when={props.state.update.visible}>
|
||||
<TitlebarUpdateIconButton state={props.state.update} />
|
||||
</Show>
|
||||
<TitlebarRightMount />
|
||||
<Show when={props.mount !== false}>
|
||||
<TitlebarRightMount />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { createData } from "@opencode-ai/client/solid"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createWorktree } from "./create"
|
||||
|
||||
describe("worktree creation", () => {
|
||||
@@ -44,28 +46,44 @@ describe("worktree creation", () => {
|
||||
),
|
||||
})
|
||||
|
||||
expect(
|
||||
await createWorktree({
|
||||
api,
|
||||
await createRoot(async (dispose) => {
|
||||
const data = createData({
|
||||
api: () => api,
|
||||
directory: input.directory,
|
||||
project: input.cached ? project : undefined,
|
||||
branch: "clone-only",
|
||||
}),
|
||||
).toBe("/created")
|
||||
expect(await requests.find((request) => request.method === "POST")?.json()).toEqual({
|
||||
strategy: "git",
|
||||
from: input.canonical,
|
||||
branch: "clone-only",
|
||||
directory: input.parent,
|
||||
event: { on: () => () => {}, listen: () => () => {} },
|
||||
})
|
||||
try {
|
||||
expect(
|
||||
await createWorktree({
|
||||
api,
|
||||
data,
|
||||
directory: input.directory,
|
||||
project: input.cached ? project : undefined,
|
||||
branch: "clone-only",
|
||||
}),
|
||||
).toBe("/created")
|
||||
expect(await requests.find((request) => request.method === "POST")?.json()).toEqual({
|
||||
strategy: "git",
|
||||
from: input.canonical,
|
||||
branch: "clone-only",
|
||||
directory: input.parent,
|
||||
})
|
||||
expect(requests.find((request) => request.method === "POST")?.url).toBe(
|
||||
"http://localhost:3000/api/worktree/proj_clone",
|
||||
)
|
||||
expect(
|
||||
requests
|
||||
.filter((request) => request.method === "GET")
|
||||
.map((request) => new URL(request.url).searchParams.get("location[directory]")),
|
||||
).toEqual(input.cached ? ["/created"] : [input.directory, "/created"])
|
||||
expect(data.location.info({ directory: "/created" })).toEqual({ directory: "/created", project })
|
||||
const count = requests.length
|
||||
await data.location.syncInfo({ directory: "/created" })
|
||||
expect(requests).toHaveLength(count)
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
expect(requests.find((request) => request.method === "POST")?.url).toBe(
|
||||
"http://localhost:3000/api/worktree/proj_clone",
|
||||
)
|
||||
expect(
|
||||
requests
|
||||
.filter((request) => request.method === "GET")
|
||||
.map((request) => new URL(request.url).searchParams.get("location[directory]")),
|
||||
).toEqual(input.cached ? ["/created"] : [input.directory, "/created"])
|
||||
})
|
||||
|
||||
test("does not fall back to a shared project when location lookup fails", async () => {
|
||||
@@ -81,10 +99,21 @@ describe("worktree creation", () => {
|
||||
),
|
||||
})
|
||||
|
||||
await expect(createWorktree({ api, directory: "/copies/repo" })).rejects.toMatchObject({
|
||||
reason: "UnexpectedStatus",
|
||||
cause: { status: 503 },
|
||||
await createRoot(async (dispose) => {
|
||||
const data = createData({
|
||||
api: () => api,
|
||||
directory: "/copies/repo",
|
||||
event: { on: () => () => {}, listen: () => () => {} },
|
||||
})
|
||||
try {
|
||||
await expect(createWorktree({ api, data, directory: "/copies/repo" })).rejects.toMatchObject({
|
||||
reason: "UnexpectedStatus",
|
||||
cause: { status: 503 },
|
||||
})
|
||||
expect(requests.map((request) => request.method)).toEqual(["GET"])
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
expect(requests.map((request) => request.method)).toEqual(["GET"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { LocationGetOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { getDirectory } from "@opencode-ai/util/path"
|
||||
|
||||
export async function createWorktree(input: {
|
||||
api: Pick<OpenCodeClient, "location" | "worktree">
|
||||
data: Pick<Data, "location">
|
||||
directory: string
|
||||
project?: LocationGetOutput["project"]
|
||||
branch?: string
|
||||
@@ -15,6 +17,7 @@ export async function createWorktree(input: {
|
||||
branch: input.branch,
|
||||
directory: getDirectory(project.canonical),
|
||||
})
|
||||
await input.api.location.get({ location: { directory: created.directory } })
|
||||
// Populate the client cache before the destination session mounts.
|
||||
await input.data.location.syncInfo({ directory: created.directory })
|
||||
return created.directory
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { LocationGetOutput, LocationRef } from "@opencode-ai/client/promise"
|
||||
import { type Accessor, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { type LocationContext, useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
export type { LocationContext } from "@/runtime/server/client"
|
||||
@@ -8,7 +9,6 @@ export type { LocationContext } from "@/runtime/server/client"
|
||||
export type WorkspaceLocation = LocationContext & {
|
||||
readonly ref: LocationRef
|
||||
readonly current: LocationGetOutput | undefined
|
||||
readonly error: { readonly location: LocationRef; readonly cause: unknown } | undefined
|
||||
}
|
||||
|
||||
const context = createSimpleContext({
|
||||
@@ -16,29 +16,29 @@ const context = createSimpleContext({
|
||||
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
|
||||
const serverSDK = useServerSDK()
|
||||
const data = useData()
|
||||
const ref = createMemo(() => ({
|
||||
directory: typeof props.directory === "function" ? props.directory() : props.directory,
|
||||
workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID,
|
||||
}))
|
||||
const ref = createMemo(
|
||||
() => ({
|
||||
directory: typeof props.directory === "function" ? props.directory() : props.directory,
|
||||
workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID,
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
equals: (previous, next) => previous.directory === next.directory && previous.workspaceID === next.workspaceID,
|
||||
},
|
||||
)
|
||||
const current = createMemo(() => data.location.info(ref()))
|
||||
const [error, setError] = createSignal<{ readonly location: LocationRef; readonly cause: unknown }>()
|
||||
let generation = 0
|
||||
|
||||
createEffect(() => {
|
||||
const location = ref()
|
||||
if (serverSDK.connection.status() !== "connected") return
|
||||
const attempt = ++generation
|
||||
setError(undefined)
|
||||
void data.location.sync(location).catch((cause) => {
|
||||
const latest = ref()
|
||||
if (
|
||||
generation !== attempt ||
|
||||
latest.directory !== location.directory ||
|
||||
latest.workspaceID !== location.workspaceID
|
||||
)
|
||||
return
|
||||
setError({ location, cause })
|
||||
let stale = false
|
||||
onCleanup(() => {
|
||||
stale = true
|
||||
})
|
||||
if (serverSDK.connection.status() !== "connected") return
|
||||
// A failed sync does not prove the directory is missing. Keep recovery local to reads.
|
||||
void retry(() => (stale ? Promise.resolve() : data.location.sync(location)), {
|
||||
retryIf: () => !stale,
|
||||
}).catch(() => undefined)
|
||||
})
|
||||
|
||||
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
|
||||
@@ -46,7 +46,6 @@ const context = createSimpleContext({
|
||||
...location(),
|
||||
ref: ref(),
|
||||
current: current(),
|
||||
error: error(),
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -70,4 +70,22 @@ describe("prompt submission state", () => {
|
||||
expect(submission.restore()).toBeUndefined()
|
||||
expect(target.current()[0]).toMatchObject({ type: "text", content: "new draft" })
|
||||
})
|
||||
|
||||
test("preserves a prepared follow-up and recovers both inputs when the first send fails", () => {
|
||||
const draft = createMemoryComposerState({ prompt: "first prompt" })
|
||||
const session = createMemoryComposerState({ prompt: "follow-up" })
|
||||
const submission = createComposerSubmission({ target: draft, prompt: draft.current(), context: [] })
|
||||
submission.retarget(session, { preserveDraft: true })
|
||||
submission.clear()
|
||||
|
||||
expect(draft.current()[0]).toMatchObject({ content: "" })
|
||||
expect(session.current()[0]).toMatchObject({ content: "follow-up" })
|
||||
expect(submission.restore()?.prompt).toEqual([
|
||||
{ type: "text", content: "first prompt", start: 0, end: 12 },
|
||||
{ type: "text", content: "\n\n", start: 12, end: 14 },
|
||||
{ type: "text", content: "follow-up", start: 14, end: 23 },
|
||||
])
|
||||
session.set([{ type: "text", content: "edited", start: 0, end: 6 }])
|
||||
expect(submission.restore()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,6 +19,18 @@ test("normal browser windows are not standalone", () => {
|
||||
expect(isStandalone()).toBe(false)
|
||||
})
|
||||
|
||||
test("detects iOS home-screen apps when the standalone media query does not match", () => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(navigator, "standalone")
|
||||
Object.defineProperty(navigator, "standalone", { configurable: true, value: true })
|
||||
try {
|
||||
expect(window.matchMedia("(display-mode: standalone)").matches).toBe(false)
|
||||
expect(isStandalone()).toBe(true)
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(navigator, "standalone", descriptor)
|
||||
if (!descriptor) Reflect.deleteProperty(navigator, "standalone")
|
||||
}
|
||||
})
|
||||
|
||||
test("restores the last PWA route including query and hash without adding history", () => {
|
||||
window.history.replaceState({ retained: true }, "", "http://localhost/")
|
||||
const length = window.history.length
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 }
|
||||
@@ -34,9 +35,10 @@ function createFixture(initial: Record<string, Session> = {}) {
|
||||
},
|
||||
},
|
||||
},
|
||||
settle(id: string) {
|
||||
setCache({ ...cache(), [id]: sessionOf(id) })
|
||||
settle(id: string, directory = `/dir/${id}`) {
|
||||
setCache({ ...cache(), [id]: { id, directory } })
|
||||
deferred.get(id)?.resolve(undefined)
|
||||
deferred.delete(id)
|
||||
},
|
||||
fail(id: string, error: unknown) {
|
||||
deferred.get(id)?.reject(error)
|
||||
@@ -59,6 +61,38 @@ 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()
|
||||
|
||||
@@ -412,7 +412,11 @@ async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog
|
||||
client.skill.list({ location }),
|
||||
])
|
||||
const models = modelResult.data.filter((model) => model.enabled)
|
||||
const defaultModel = defaultResult.data ?? models[0]
|
||||
const preferred = defaultResult.data
|
||||
// Parallel reads can straddle initialization; select only from this model list.
|
||||
const defaultModel = preferred
|
||||
? models.find((model) => model.providerID === preferred.providerID && model.id === preferred.id)
|
||||
: models[0]
|
||||
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
|
||||
const defaultAgent = agents.find((agent) => agent.mode === "primary") ?? agents[0]
|
||||
if (defaultModel && defaultAgent) {
|
||||
|
||||
@@ -25,9 +25,12 @@ export default Runtime.handler(
|
||||
selected,
|
||||
(item) =>
|
||||
(item.runtime === "Server"
|
||||
? Effect.promise(() => result.client.plugin.update({ location: result.location, target: item.target }))
|
||||
? Effect.promise(() => result.client.plugin.update({ location: result.location, targets: [item.target] }))
|
||||
: npm.update(item.target, { subpaths: ["tui"] }).pipe(Effect.asVoid)
|
||||
).pipe(Effect.exit, Effect.map((result) => ({ item, result }))),
|
||||
).pipe(
|
||||
Effect.exit,
|
||||
Effect.map((result) => ({ item, result })),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
for (const item of updated) {
|
||||
|
||||
@@ -26,7 +26,7 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
|
||||
|
||||
function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) {
|
||||
const key = url.pathname.replace(/^\//, "")
|
||||
if (key.startsWith("_assets/") && assets[key] === undefined)
|
||||
if ((key.startsWith("_assets/") || key.startsWith("icons/")) && assets[key] === undefined)
|
||||
return Effect.succeed(HttpServerResponse.empty({ status: 404, headers: { "cache-control": "no-store" } }))
|
||||
const name = assets[key] !== undefined ? key : "index.html"
|
||||
const file = assets[name]
|
||||
@@ -45,7 +45,7 @@ function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets:
|
||||
}
|
||||
return Effect.succeed(
|
||||
request.method === "HEAD"
|
||||
? HttpServerResponse.empty({ headers })
|
||||
? HttpServerResponse.empty({ status: 200, headers })
|
||||
: HttpServerResponse.raw(file, { headers, contentType: headers["content-type"] }),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
|
||||
import { flattenSelectOptions, requireSelectOption } from "./subprocess"
|
||||
|
||||
describe("acp service directory behavior", () => {
|
||||
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
|
||||
@@ -72,6 +73,41 @@ describe("acp service directory behavior", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test.each(["empty", "missing the default"])(
|
||||
"retries when the model list is %s but the default is ready",
|
||||
async (initial) => {
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request, context) {
|
||||
if (
|
||||
request.path === "/api/model" &&
|
||||
context.requests.filter((request) => request.path === "/api/model").length === 1
|
||||
) {
|
||||
return Response.json({
|
||||
location: {
|
||||
directory: "/workspace",
|
||||
project: { id: "global", directory: "/workspace", canonical: "/workspace" },
|
||||
},
|
||||
data: initial === "empty" ? [] : [secondModel],
|
||||
})
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_ready") })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
const model = requireSelectOption(session.configOptions, "model")
|
||||
const choices = flattenSelectOptions(model).map((option) => option.value)
|
||||
|
||||
expect(choices).toContain("test/second-model")
|
||||
expect(choices).toContain("test/test-model")
|
||||
expect(model.currentValue).toBe("test/test-model")
|
||||
expect(fixture.requests.filter((request) => request.path === "/api/model")).toHaveLength(2)
|
||||
},
|
||||
)
|
||||
|
||||
test("does not cache a failed catalog load", async () => {
|
||||
let modelCalls = 0
|
||||
await using fixture = makeACPFixture({
|
||||
|
||||
@@ -97,7 +97,7 @@ export type PluginCheckOperation<E = never> = (input?: PluginCheckInput) => Effe
|
||||
|
||||
export type PluginUpdateInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly target: string
|
||||
readonly targets: ReadonlyArray<string>
|
||||
}
|
||||
export type PluginUpdateOutput = void
|
||||
export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Effect.Effect<PluginUpdateOutput, E>
|
||||
|
||||
@@ -335,7 +335,7 @@ const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input?: Plugin
|
||||
|
||||
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
|
||||
preserveEffect<PluginUpdateOutput>()(
|
||||
raw["plugin.update"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
|
||||
raw["plugin.update"]({ query: { location: input["location"] }, payload: { targets: input["targets"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ export type PermissionEffect = "allow" | "deny" | "ask"
|
||||
|
||||
export type PluginSource =
|
||||
| { type: "builtin" }
|
||||
| { type: "package"; target: string; version?: string; outdated?: true }
|
||||
| { type: "package"; target: string; version?: string; outdated?: true; updating?: true }
|
||||
| { type: "local"; path: string }
|
||||
| { type: "sdk" }
|
||||
|
||||
@@ -2336,10 +2336,6 @@ export type V2Event =
|
||||
|
||||
export type SessionLogItem = SessionEventDurable | EventLogSynced
|
||||
|
||||
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
|
||||
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
|
||||
|
||||
export type InvalidRequestError = {
|
||||
readonly _tag: "InvalidRequestError"
|
||||
readonly message: string
|
||||
@@ -2349,6 +2345,10 @@ export type InvalidRequestError = {
|
||||
export const isInvalidRequestError = (value: unknown): value is InvalidRequestError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidRequestError"
|
||||
|
||||
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
|
||||
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
|
||||
|
||||
export type AgentNotFoundError = {
|
||||
readonly _tag: "AgentNotFoundError"
|
||||
readonly agentID: string
|
||||
@@ -2369,14 +2369,6 @@ export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly
|
||||
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
|
||||
|
||||
export type ConflictError = {
|
||||
readonly _tag: "ConflictError"
|
||||
readonly message: string
|
||||
readonly resource?: string | undefined
|
||||
}
|
||||
export const isConflictError = (value: unknown): value is ConflictError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ConflictError"
|
||||
|
||||
export type SessionNotFoundError = {
|
||||
readonly _tag: "SessionNotFoundError"
|
||||
readonly sessionID: string
|
||||
@@ -2385,6 +2377,14 @@ export type SessionNotFoundError = {
|
||||
export const isSessionNotFoundError = (value: unknown): value is SessionNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionNotFoundError"
|
||||
|
||||
export type ConflictError = {
|
||||
readonly _tag: "ConflictError"
|
||||
readonly message: string
|
||||
readonly resource?: string | undefined
|
||||
}
|
||||
export const isConflictError = (value: unknown): value is ConflictError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ConflictError"
|
||||
|
||||
export type UnknownError = {
|
||||
readonly _tag: "UnknownError"
|
||||
readonly message: string
|
||||
@@ -2474,14 +2474,6 @@ export type FormNotFoundError = { readonly _tag: "FormNotFoundError"; readonly i
|
||||
export const isFormNotFoundError = (value: unknown): value is FormNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError"
|
||||
|
||||
export type FormAlreadySettledError = {
|
||||
readonly _tag: "FormAlreadySettledError"
|
||||
readonly id: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isFormAlreadySettledError = (value: unknown): value is FormAlreadySettledError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormAlreadySettledError"
|
||||
|
||||
export type FormInvalidAnswerError = {
|
||||
readonly _tag: "FormInvalidAnswerError"
|
||||
readonly id: string
|
||||
@@ -2490,6 +2482,14 @@ export type FormInvalidAnswerError = {
|
||||
export const isFormInvalidAnswerError = (value: unknown): value is FormInvalidAnswerError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormInvalidAnswerError"
|
||||
|
||||
export type FormAlreadySettledError = {
|
||||
readonly _tag: "FormAlreadySettledError"
|
||||
readonly id: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isFormAlreadySettledError = (value: unknown): value is FormAlreadySettledError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormAlreadySettledError"
|
||||
|
||||
export type PermissionNotFoundError = {
|
||||
readonly _tag: "PermissionNotFoundError"
|
||||
readonly requestID: string
|
||||
@@ -2601,7 +2601,7 @@ export type PluginUpdateInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly target: { readonly target: string }["target"]
|
||||
readonly targets: { readonly targets: ReadonlyArray<string> }["targets"]
|
||||
}
|
||||
|
||||
export type PluginUpdateOutput = void
|
||||
|
||||
@@ -37,6 +37,7 @@ export const DEFAULT_SEARCH_TIMEOUT_MS = 30_000
|
||||
export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
|
||||
pattern: Schema.String,
|
||||
path: Schema.optionalKey(RelativePath),
|
||||
hidden: Schema.optionalKey(Schema.Boolean),
|
||||
limit: Schema.optionalKey(PositiveInt),
|
||||
}) {}
|
||||
|
||||
@@ -44,6 +45,8 @@ export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
|
||||
pattern: Schema.String,
|
||||
path: Schema.optionalKey(RelativePath),
|
||||
include: Schema.optionalKey(Schema.String),
|
||||
literal: Schema.optionalKey(Schema.Boolean),
|
||||
caseSensitive: Schema.optionalKey(Schema.Boolean),
|
||||
limit: Schema.optionalKey(PositiveInt),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Context, Effect, Layer, LayerMap } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import path from "path"
|
||||
import { Location } from "./location.js"
|
||||
import type { Instance } from "./instance.js"
|
||||
|
||||
@@ -15,4 +17,12 @@ export class Service extends Context.Service<
|
||||
|
||||
export const node = LayerNode.unbound(Service, Node.tags.values.global)
|
||||
|
||||
/** Normalize equivalent placements before they become resource-cache keys. */
|
||||
export function canonical(ref: Location.Ref) {
|
||||
return Location.Ref.make({
|
||||
directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory),
|
||||
workspaceID: ref.workspaceID,
|
||||
})
|
||||
}
|
||||
|
||||
export * as LocationServiceMap from "./location-service-map.js"
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Duration, Effect, Layer, LayerMap } from "effect"
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Instance } from "./instance.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map.js"
|
||||
|
||||
@@ -15,13 +13,6 @@ export type LocationError = Instance.Error
|
||||
export function buildLocationServiceMap(
|
||||
replacements: LayerNode.Replacements = [],
|
||||
): Layer.Layer<LocationServiceMap.Service> {
|
||||
// Structural Equal distinguishes optional-key shape and Windows separator style.
|
||||
// The RcMap caches the raw key before the build callback, so normalize both here.
|
||||
const canonical = (ref: Location.Ref) =>
|
||||
Location.Ref.make({
|
||||
directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory),
|
||||
workspaceID: ref.workspaceID,
|
||||
})
|
||||
return Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -35,10 +26,10 @@ export function buildLocationServiceMap(
|
||||
})
|
||||
const map = {
|
||||
...inner,
|
||||
get: (ref: Location.Ref) => inner.get(canonical(ref)),
|
||||
contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)),
|
||||
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(canonical(ref)),
|
||||
invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)),
|
||||
get: (ref: Location.Ref) => inner.get(LocationServiceMap.canonical(ref)),
|
||||
contextEffect: (ref: Location.Ref) => inner.contextEffect(LocationServiceMap.canonical(ref)),
|
||||
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(LocationServiceMap.canonical(ref)),
|
||||
invalidate: (ref: Location.Ref) => inner.invalidate(LocationServiceMap.canonical(ref)),
|
||||
}
|
||||
// Cached instances borrow their owner instead of retaining its Layer scope.
|
||||
const bindings: LayerNode.Replacements = [
|
||||
|
||||
@@ -23,16 +23,6 @@ Guidelines:
|
||||
|
||||
Complete the user's search request efficiently and report your findings clearly.`
|
||||
|
||||
const PROMPT_COMPACTION = `You are an anchored context summarization assistant for coding sessions.
|
||||
|
||||
Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work.
|
||||
|
||||
If the prompt includes a <previous-summary> block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts.
|
||||
|
||||
Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs.
|
||||
|
||||
Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation.`
|
||||
|
||||
const PROMPT_TITLE = `You are a title generator. You output ONLY a thread title. Nothing else.
|
||||
|
||||
<task>
|
||||
@@ -144,8 +134,6 @@ export const Plugin = define({
|
||||
item.name = Agent.Name.make("Compaction")
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_COMPACTION
|
||||
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("title"), (item) => {
|
||||
|
||||
@@ -37,7 +37,7 @@ export const ModelsDevPlugin = define({
|
||||
})
|
||||
for (const model of provider.models) {
|
||||
if (model.status === "deprecated") continue
|
||||
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, structuredClone(model)))
|
||||
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, copy(model)))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -65,8 +65,27 @@ function environmentNames(provider: ModelsDev.Snapshot) {
|
||||
}
|
||||
|
||||
function snapshots(data: readonly ModelsDev.Snapshot[]) {
|
||||
return structuredClone(data).filter(
|
||||
return copy(data).filter(
|
||||
// These deprecated aliases are replaced by the canonical Azure and Google Vertex providers.
|
||||
(provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic",
|
||||
)
|
||||
}
|
||||
|
||||
// The catalog owns and mutates its model records, so every rebuild needs fresh copies of the
|
||||
// thousands of snapshot models. Snapshot data is plain JSON, and a direct copy is an order of
|
||||
// magnitude faster than structuredClone's general graph walk on the startup path.
|
||||
function copy<T>(value: T): T {
|
||||
if (Array.isArray(value)) return value.map(copy) as T
|
||||
if (value !== null && typeof value === "object") {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const key of Object.keys(value)) {
|
||||
const copied = copy((value as Record<string, unknown>)[key])
|
||||
// Assigning this key would set the prototype rather than an own property, unlike structuredClone.
|
||||
if (key === "__proto__")
|
||||
Object.defineProperty(result, key, { value: copied, enumerable: true, writable: true, configurable: true })
|
||||
else result[key] = copied
|
||||
}
|
||||
return result as T
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export const Plugin = define({
|
||||
// Compaction and committed reverts can strip reminders while the session's agent stays
|
||||
// put. Reconcile per request, appending near the tail so the cached prefix stays warm.
|
||||
yield* ctx.session.hook("context", (event) => {
|
||||
if (event.purpose === "title") return Effect.void
|
||||
const reminder = lastReminder(event.messages, enterReminder)
|
||||
const missing = event.agent === plan && reminder !== enterReminder
|
||||
const stale = event.agent !== plan && reminder === enterReminder
|
||||
|
||||
@@ -80,13 +80,26 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
enabled.add(plugin.id)
|
||||
}
|
||||
|
||||
const ordered = [
|
||||
...pre.filter((plugin) => enabled.has(plugin.id)),
|
||||
...[...packages.values()].filter((plugin) => enabled.has(plugin.id)),
|
||||
...post.filter((plugin) => enabled.has(plugin.id)),
|
||||
]
|
||||
// Registry activation dies on a duplicate ID, which would drop the whole generation including builtins.
|
||||
// Keep the first occurrence in boot order and report later ones like any other plugin setup failure.
|
||||
const duplicate = (plugin: Plugin.Generation, index: number) =>
|
||||
ordered.findIndex((other) => other.id === plugin.id) !== index
|
||||
return {
|
||||
plugins: [
|
||||
...pre.filter((plugin) => enabled.has(plugin.id)),
|
||||
...[...packages.values()].filter((plugin) => enabled.has(plugin.id)),
|
||||
...post.filter((plugin) => enabled.has(plugin.id)),
|
||||
plugins: ordered.filter((plugin, index) => !duplicate(plugin, index)),
|
||||
failures: [
|
||||
...failures.values(),
|
||||
...ordered.filter(duplicate).map((plugin) => ({
|
||||
id: Plugin.ID.make(plugin.id),
|
||||
source: plugin.source ?? { type: "builtin" as const },
|
||||
state: { status: "failed" as const, error: `Duplicate plugin ID: ${plugin.id}` },
|
||||
features: { server: true as const, ...plugin.features },
|
||||
})),
|
||||
],
|
||||
failures: [...failures.values()],
|
||||
pending: [...pending],
|
||||
}
|
||||
})
|
||||
@@ -106,6 +119,7 @@ export const layer = Layer.effectDiscard(
|
||||
yield* Effect.addFinalizer(registry.close)
|
||||
let packages = new Set<string>()
|
||||
let outdated = new Set<string>()
|
||||
const updating = new Set<string>()
|
||||
let generation = 0
|
||||
let observed = 0
|
||||
|
||||
@@ -128,7 +142,13 @@ export const layer = Layer.effectDiscard(
|
||||
// Activate everything available locally before waiting on missing package installs.
|
||||
const immediate = yield* resolve(pre, post, operations, false)
|
||||
const source = (source: Plugin.Source) =>
|
||||
source.type === "package" && outdated.has(source.target) ? { ...source, outdated: true as const } : source
|
||||
source.type === "package"
|
||||
? {
|
||||
...source,
|
||||
...(outdated.has(source.target) ? { outdated: true as const } : {}),
|
||||
...(updating.has(source.target) ? { updating: true as const } : {}),
|
||||
}
|
||||
: source
|
||||
const apply = (resolved: typeof immediate) =>
|
||||
registry.activate(
|
||||
resolved.plugins.map((plugin) => (plugin.source ? { ...plugin, source: source(plugin.source) } : plugin)),
|
||||
@@ -163,7 +183,10 @@ export const layer = Layer.effectDiscard(
|
||||
updates.changes().pipe(
|
||||
Stream.filter((update) => packages.has(update.target)),
|
||||
Stream.tap((update) =>
|
||||
Effect.sync(() => (update.outdated ? outdated.add(update.target) : outdated.delete(update.target))),
|
||||
Effect.sync(() => {
|
||||
update.outdated ? outdated.add(update.target) : outdated.delete(update.target)
|
||||
update.updating ? updating.add(update.target) : updating.delete(update.target)
|
||||
}),
|
||||
),
|
||||
Stream.map(() => undefined),
|
||||
),
|
||||
|
||||
@@ -35,6 +35,7 @@ function make(id: string, select: (modelID: string) => string | undefined) {
|
||||
effect: Effect.fn(`SystemPromptPlugin.${id}`)(function* (ctx) {
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.purpose === "title") return
|
||||
if ((yield* ctx.agent.get({ agentID: event.agent })).data.system) return
|
||||
const system = event.system[0]
|
||||
if (!system) return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as PluginUpdate from "./update.js"
|
||||
|
||||
import { Clock, Context, Effect, Layer, Option, PubSub, Stream } from "effect"
|
||||
import { Clock, Context, Effect, Exit, Layer, Option, PubSub, Stream } from "effect"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { EffectFlock } from "@opencode-ai/util/effect-flock"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -8,10 +8,16 @@ import { KeyedMutex } from "../effect/keyed-mutex.js"
|
||||
|
||||
const interval = 24 * 60 * 60 * 1_000
|
||||
|
||||
interface Change {
|
||||
readonly target: string
|
||||
readonly outdated: boolean
|
||||
readonly updating: boolean
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly check: (target: string, options?: { readonly refresh?: boolean }) => Effect.Effect<boolean>
|
||||
readonly update: (target: string) => Effect.Effect<void, Npm.InstallFailedError | EffectFlock.LockError>
|
||||
readonly changes: () => Stream.Stream<{ readonly target: string; readonly outdated: boolean }>
|
||||
readonly changes: () => Stream.Stream<Change>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginUpdate") {}
|
||||
@@ -22,7 +28,7 @@ const layer = Layer.effect(
|
||||
const npm = yield* Npm.Service
|
||||
const locks = KeyedMutex.makeUnsafe<string>()
|
||||
const status = new Map<string, { readonly outdated: boolean; readonly checkedAt: number }>()
|
||||
const changes = yield* PubSub.unbounded<{ readonly target: string; readonly outdated: boolean }>()
|
||||
const changes = yield* PubSub.unbounded<Change>()
|
||||
|
||||
return Service.of({
|
||||
check: (target, options) =>
|
||||
@@ -37,15 +43,28 @@ const layer = Layer.effect(
|
||||
)
|
||||
const value = Option.getOrElse(outdated, () => current?.outdated ?? false)
|
||||
status.set(target, { outdated: value, checkedAt })
|
||||
if ((current?.outdated ?? false) !== value) yield* PubSub.publish(changes, { target, outdated: value })
|
||||
if ((current?.outdated ?? false) !== value)
|
||||
yield* PubSub.publish(changes, { target, outdated: value, updating: false })
|
||||
return value
|
||||
}),
|
||||
),
|
||||
// Serialize per target so a concurrent update cannot clear the in-progress flag of another still running.
|
||||
update: (target) =>
|
||||
npm.update(target).pipe(
|
||||
Effect.tap(() => Effect.sync(() => status.delete(target))),
|
||||
Effect.tap(() => PubSub.publish(changes, { target, outdated: false })),
|
||||
Effect.asVoid,
|
||||
locks.withLock(target)(
|
||||
Effect.gen(function* () {
|
||||
const outdated = status.get(target)?.outdated ?? false
|
||||
yield* PubSub.publish(changes, { target, outdated, updating: true })
|
||||
yield* npm.update(target).pipe(
|
||||
Effect.tap(() => Effect.sync(() => status.delete(target))),
|
||||
Effect.onExit((exit) =>
|
||||
PubSub.publish(changes, {
|
||||
target,
|
||||
outdated: Exit.isSuccess(exit) ? false : outdated,
|
||||
updating: false,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
changes: () => Stream.fromPubSub(changes),
|
||||
})
|
||||
|
||||
@@ -56,6 +56,7 @@ export const Plugin = define({
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.purpose === "title") return
|
||||
const active = sessions.get(event.sessionID)
|
||||
const settings = yield* loadSettings()
|
||||
if (!settings) {
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface GrepInput {
|
||||
readonly pattern: string
|
||||
readonly file?: string
|
||||
readonly include?: string
|
||||
readonly literal?: boolean
|
||||
readonly caseSensitive?: boolean
|
||||
readonly limit: number
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
@@ -220,6 +222,8 @@ const layer = Layer.effect(
|
||||
"--json",
|
||||
"--hidden",
|
||||
"--no-messages",
|
||||
...(input.literal ? ["--fixed-strings"] : []),
|
||||
...(input.caseSensitive === false ? ["--ignore-case"] : []),
|
||||
...(input.include ? [`--glob=${input.include}`] : []),
|
||||
"--glob=!**/.git/**",
|
||||
"--",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionCompaction from "./compaction.js"
|
||||
|
||||
import { LLMClient, LLMEvent, Message, type ContentPart } from "@opencode-ai/ai"
|
||||
import { LLMClient, LLMEvent, LLMRequest, Message, type ContentPart } from "@opencode-ai/ai"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
@@ -18,6 +18,8 @@ import { Token } from "../util/token.js"
|
||||
import { SessionUsage } from "./usage.js"
|
||||
import { State } from "../state.js"
|
||||
import { toLLMMessages } from "./runner/to-llm-message.js"
|
||||
import type { AgentNotFoundError } from "./error.js"
|
||||
import type { Instructions } from "../instructions/index.js"
|
||||
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
const DEFAULT_KEEP_TOKENS = 15_000
|
||||
@@ -25,13 +27,16 @@ const OUTPUT_TOKEN_MAX = 32_000
|
||||
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
||||
const IMAGE_TOKEN_ESTIMATE = 1_500
|
||||
const PDF_TOKEN_ESTIMATE = 2_000
|
||||
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
||||
const SUMMARY_TEMPLATE = `You MUST use this format for your response (you may omit sections that aren't applicable). Do not include the <template> tags in your response.
|
||||
<template>
|
||||
## Objective
|
||||
- [one or two brief sentences describing what the user is trying to accomplish]
|
||||
|
||||
## Important Details
|
||||
- [constraints/preferences, decisions and why, important facts/assumptions, exact context needed to continue, or "(none)"]
|
||||
## Requirements
|
||||
- [constraints, preferences, requirements, and scope boundaries, or "(none)"]
|
||||
|
||||
## Decisions
|
||||
- [decisions already made and why, or "(none)"]
|
||||
|
||||
## Work State
|
||||
### Completed
|
||||
@@ -44,19 +49,26 @@ const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <te
|
||||
- [blockers, failing commands, or unknowns; otherwise "(none)"]
|
||||
|
||||
## Next Move
|
||||
1. [immediate concrete action, or "(none)"]
|
||||
2. [next action if known, or "(none)"]
|
||||
1. [ordered list of next actions, or "(none)"]
|
||||
|
||||
## Relevant Files
|
||||
- [file or directory path: why it matters, or "(none)"]
|
||||
</template>
|
||||
List files and directories that are important to the conversation. Include paths outside the current working directory when relevant. If none are relevant, write "(none)".
|
||||
- \`[exact path]\`: [why it matters]
|
||||
|
||||
Rules:
|
||||
- Keep every section, even when empty.
|
||||
## Additional Context
|
||||
- [important facts, assumptions, unresolved questions, exact references, or other context needed to continue that does not fit above; when uncertain, preserve it here, or "(none)"]
|
||||
</template>`
|
||||
|
||||
const SUMMARY_RULES = `Rules:
|
||||
- Use terse bullets, not prose paragraphs.
|
||||
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
|
||||
- Carry forward only user questions or requests that remain unanswered or require further action. Do not repeat ones that newer history has answered or resolved. Preserve exact wording when carrying one forward.
|
||||
- Preserve consequential workflow state, including whether changes are uncommitted, committed, pushed, under review, or merged.
|
||||
- Do not include ambient environment metadata such as the session ID, current working directory, repository root, current branch, or worktree path. The next agent receives current environment information separately. Include these details only when they directly affect the task.
|
||||
- Do not mention the summary process or that context was compacted.`
|
||||
|
||||
const SUMMARY_HEADINGS = SUMMARY_TEMPLATE.split("\n").filter((line) => line.startsWith("##"))
|
||||
|
||||
export type Settings = {
|
||||
auto: boolean
|
||||
buffer: number
|
||||
@@ -68,13 +80,13 @@ export type Draft = {
|
||||
}
|
||||
|
||||
export type AutoInput = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly resolved: SessionRunnerModel.Resolved
|
||||
readonly context: SessionContext.Loaded
|
||||
readonly prepare: SessionModelRequest.Interface["prepare"]
|
||||
}
|
||||
|
||||
type RequiredInput = Pick<AutoInput, "messages" | "resolved"> & {
|
||||
type RequiredInput = {
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly resolved: SessionRunnerModel.Resolved
|
||||
readonly context: SessionContext.Loaded
|
||||
}
|
||||
|
||||
@@ -83,20 +95,21 @@ export type ManualInput = {
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly inputID: SessionMessage.ID
|
||||
readonly started?: boolean
|
||||
/** Invoked after content planning, not when the caller captures the operation. */
|
||||
readonly resolveModel: SessionContext.Interface["resolveModel"]
|
||||
/** Empty compaction controls do not preflight model or instruction availability. */
|
||||
readonly resolveContext: (
|
||||
session: SessionSchema.Info,
|
||||
) => Effect.Effect<
|
||||
SessionContext.Loaded & { readonly instructionUpdate: string },
|
||||
SessionRunnerModel.Error | AgentNotFoundError | Instructions.InitializationBlocked
|
||||
>
|
||||
readonly prepare: SessionModelRequest.Interface["prepare"]
|
||||
}
|
||||
|
||||
type Plan = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly resolved: SessionRunnerModel.Resolved
|
||||
type ExecuteInput = AutoInput & {
|
||||
readonly reason: SessionMessage.Compaction["reason"]
|
||||
readonly prompt: string
|
||||
readonly recent: string
|
||||
readonly inputID?: SessionMessage.ID
|
||||
readonly started?: boolean
|
||||
readonly prepare: SessionModelRequest.Interface["prepare"]
|
||||
readonly instructionUpdate?: string
|
||||
}
|
||||
|
||||
export type Outcome =
|
||||
@@ -194,7 +207,9 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[
|
||||
)
|
||||
.join("\n")
|
||||
|
||||
const serialize = (message: SessionMessage.Info) => {
|
||||
const serializeRecentMessage = (message: SessionMessage.Info) => {
|
||||
// Checkpoints and instruction updates are handled outside the serialized tail.
|
||||
if (message.type === "compaction" || message.type === "system") return ""
|
||||
if (message.type === "user") {
|
||||
const files =
|
||||
message.files?.map(
|
||||
@@ -226,7 +241,6 @@ const serialize = (message: SessionMessage.Info) => {
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
if (message.type === "system") return `[System update]: ${message.text}`
|
||||
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
|
||||
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
|
||||
if (message.type === "shell")
|
||||
@@ -236,70 +250,74 @@ const serialize = (message: SessionMessage.Info) => {
|
||||
return ""
|
||||
}
|
||||
|
||||
const select = (
|
||||
messages: readonly SessionMessage.Info[],
|
||||
tokens: number,
|
||||
): { readonly head: string; readonly recent: string } | undefined => {
|
||||
const conversation = messages
|
||||
.filter((message) => message.type !== "compaction" && message.type !== "system")
|
||||
.flatMap((message) => {
|
||||
const text = serialize(message)
|
||||
return text ? [{ message, text }] : []
|
||||
})
|
||||
if (conversation.length === 0) return undefined
|
||||
let total = 0
|
||||
let split = conversation.length
|
||||
for (let index = conversation.length - 1; index >= 0; index--) {
|
||||
const next = total + Token.estimate(conversation[index].text)
|
||||
if (split < conversation.length && next > tokens) break
|
||||
total = next
|
||||
split = index
|
||||
}
|
||||
while (split > 0 && conversation[split].message.type !== "user") split--
|
||||
if (split === 0) {
|
||||
const latestUser = conversation.findLastIndex((item) => item.message.type === "user")
|
||||
if (latestUser > 0) split = latestUser
|
||||
}
|
||||
const splitHistory = (messages: readonly SessionMessage.Info[], keepTokens: number) => {
|
||||
const tailStart = findTailStart(messages, keepTokens)
|
||||
if (tailStart === undefined) return
|
||||
return {
|
||||
head: conversation
|
||||
.slice(0, split)
|
||||
.map((item) => item.text)
|
||||
.join("\n\n"),
|
||||
recent: conversation
|
||||
.slice(split)
|
||||
.map((item) => item.text)
|
||||
.join("\n\n"),
|
||||
messages: messages.slice(0, tailStart),
|
||||
recent: messages.slice(tailStart).map(serializeRecentMessage).filter(Boolean).join("\n\n"),
|
||||
}
|
||||
}
|
||||
|
||||
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) =>
|
||||
[
|
||||
input.previousSummary
|
||||
? `Update the anchored summary below using the conversation history below.\nPreserve still-true details, remove stale details, and merge in the new facts.\n<previous-summary>\n${input.previousSummary}\n</previous-summary>`
|
||||
: "Create a new anchored summary from the conversation history.",
|
||||
SUMMARY_TEMPLATE,
|
||||
"The following is the conversation history:",
|
||||
...input.context,
|
||||
].join("\n\n")
|
||||
const findTailStart = (messages: readonly SessionMessage.Info[], keepTokens: number) => {
|
||||
const conversation = messages.flatMap((message, index) => {
|
||||
const text = serializeRecentMessage(message)
|
||||
return text ? [{ message, text, index }] : []
|
||||
})
|
||||
if (conversation.length === 0) return undefined
|
||||
|
||||
// Keep at least the newest entry, even if it exceeds the allowance.
|
||||
let total = 0
|
||||
let start = conversation.length
|
||||
for (let index = conversation.length - 1; index >= 0; index--) {
|
||||
const next = total + Token.estimate(conversation[index].text)
|
||||
if (start < conversation.length && next > keepTokens) break
|
||||
total = next
|
||||
start = index
|
||||
}
|
||||
|
||||
// Start at a user boundary so an assistant's tool calls and results stay together.
|
||||
while (start > 0 && conversation[start].message.type !== "user") start--
|
||||
if (start > 0) return conversation[start].index
|
||||
|
||||
// If everything fits, retain only the latest exchange to leave an older prefix to summarize.
|
||||
const latestUser = conversation.findLastIndex((item) => item.message.type === "user")
|
||||
if (latestUser > 0) return conversation[latestUser].index
|
||||
|
||||
const planContent = (messages: readonly SessionMessage.Info[], tokens: number) => {
|
||||
const selected = select(messages, tokens)
|
||||
if (!selected) return
|
||||
const previousSummary = messages.findLast(
|
||||
(message): message is SessionMessage.CompactionCompleted =>
|
||||
message.type === "compaction" && message.status === "completed",
|
||||
)
|
||||
const previousRecent = previousSummary?.recent ?? ""
|
||||
const summarizeRecent = !previousRecent && !selected.head
|
||||
return {
|
||||
prompt: buildPrompt({
|
||||
previousSummary: previousSummary?.summary,
|
||||
context: summarizeRecent ? [selected.recent] : [previousRecent, selected.head].filter(Boolean),
|
||||
}),
|
||||
recent: summarizeRecent ? "" : selected.recent,
|
||||
}
|
||||
// Without an older retained tail to summarize, summarize everything and retain nothing.
|
||||
return previousSummary?.recent ? conversation[0].index : messages.length
|
||||
}
|
||||
|
||||
export const buildPrompt = (update: boolean) => {
|
||||
const shared = [
|
||||
"Summarize only the history shown. More recent context may be retained and presented after this summary.",
|
||||
SUMMARY_TEMPLATE,
|
||||
SUMMARY_RULES,
|
||||
"Do not continue the task or call tools.",
|
||||
"Return only the structured summary in the requested format. Do not include a preamble, explanation, or other commentary.",
|
||||
]
|
||||
if (update) {
|
||||
return [
|
||||
"Update the existing checkpoint in the conversation above into one consolidated summary.",
|
||||
"Newer history always takes precedence over the existing checkpoint. Preserve previous information unless newer history clearly contradicts, supersedes, resolves, or makes it stale. When uncertain and there is no conflict, retain it under Additional Context.",
|
||||
"Incorporate newer requirements, decisions, progress, and context. Reconcile Work State and Next Move: move completed work out of Active, remove resolved blockers and answered questions, and preserve unresolved or pending work.",
|
||||
"Return only the updated Markdown sections. Do not reproduce the `<conversation-checkpoint>`, `<summary>`, or `<recent-context>` wrapper tags from the previous checkpoint.",
|
||||
...shared,
|
||||
].join("\n\n")
|
||||
}
|
||||
return [
|
||||
"You MUST summarize the conversation above into a structured summary that will be given to another agent to resume the work.",
|
||||
...shared,
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
const hasSummarySection = (summary: string) =>
|
||||
summary.split("\n").some((line) => SUMMARY_HEADINGS.includes(line.trim()))
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -326,13 +344,22 @@ export const layer = Layer.effect(
|
||||
yield* bus.publish(SessionEvent.Compaction.Failed, input)
|
||||
return { status: "failed" as const, error: input.error }
|
||||
})
|
||||
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
|
||||
if (!plan.started)
|
||||
const execute = Effect.fn("SessionCompaction.execute")(function* (input: ExecuteInput) {
|
||||
const context = input.context
|
||||
const history = splitHistory(context.messages, state.get().tokens)
|
||||
if (!history)
|
||||
return yield* failed({
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
inputID: input.inputID,
|
||||
})
|
||||
if (!input.started)
|
||||
yield* bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
recent: plan.recent,
|
||||
inputID: plan.inputID,
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
recent: history.recent,
|
||||
inputID: input.inputID,
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
@@ -341,92 +368,125 @@ export const layer = Layer.effect(
|
||||
const recordUsage = Effect.suspend(() =>
|
||||
usage
|
||||
? bus.publish(SessionEvent.UsageRecorded, {
|
||||
sessionID: plan.session.id,
|
||||
sessionID: context.session.id,
|
||||
source: "compaction",
|
||||
...usage,
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const prepared = yield* plan.prepare({
|
||||
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
|
||||
transcript: { system: [], messages: [Message.user(plan.prompt)] },
|
||||
contextHooks: false,
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: context.agent.info,
|
||||
model: context.model,
|
||||
tools: context.tools,
|
||||
initial: context.initial,
|
||||
messages: history.messages,
|
||||
})
|
||||
yield* llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return bus.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: plan.session.id,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, plan.resolved.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("AI.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
recordUsage.pipe(
|
||||
Effect.andThen(
|
||||
plan.reason === "auto"
|
||||
? failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: plan.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
const prepared = yield* input.prepare({
|
||||
purpose: "compaction",
|
||||
scope: {
|
||||
session: context.session,
|
||||
agentID: Agent.ID.make("compaction"),
|
||||
contextAgentID: context.agent.id,
|
||||
model: context.model,
|
||||
tools: context.tools,
|
||||
},
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
messages: [
|
||||
...transcript.messages,
|
||||
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
|
||||
Message.user(
|
||||
buildPrompt(
|
||||
history.messages.some((message) => message.type === "compaction" && message.status === "completed"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
},
|
||||
})
|
||||
// Ignored tool calls never enter the follow-up history or need fabricated results.
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
chunks.length = 0
|
||||
yield* llm
|
||||
.stream(
|
||||
attempt === 0
|
||||
? prepared.request
|
||||
: LLMRequest.update(prepared.request, {
|
||||
messages: [
|
||||
...prepared.request.messages,
|
||||
Message.user(
|
||||
"The previous response did not fill in the required summary template. Do not call tools. Return the summary as text using the exact section headings from the template.",
|
||||
),
|
||||
],
|
||||
}),
|
||||
prepared.options,
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return bus.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: context.session.id,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, context.model.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("AI.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
recordUsage.pipe(
|
||||
Effect.andThen(
|
||||
input.reason === "auto"
|
||||
? failed({
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: input.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (failure || hasSummarySection(chunks.join(""))) break
|
||||
}
|
||||
yield* recordUsage
|
||||
const summary = chunks.join("")
|
||||
if (failure || !summary.trim()) {
|
||||
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
|
||||
if (failure || !hasSummarySection(summary)) {
|
||||
const error = failure ?? {
|
||||
type: "compaction.failed" as const,
|
||||
message: summary.trim()
|
||||
? "Compaction summary did not match the required template"
|
||||
: "Compaction produced no summary",
|
||||
}
|
||||
return yield* failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
error,
|
||||
inputID: plan.inputID,
|
||||
inputID: input.inputID,
|
||||
})
|
||||
}
|
||||
yield* bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
text: summary,
|
||||
recent: plan.recent,
|
||||
recent: history.recent,
|
||||
})
|
||||
return { status: "completed" as const }
|
||||
})
|
||||
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
if (content)
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
resolved: input.resolved,
|
||||
prepare: input.prepare,
|
||||
reason: "auto",
|
||||
...content,
|
||||
})
|
||||
return yield* failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
})
|
||||
})
|
||||
const compact = (input: AutoInput) => execute({ ...input, reason: "auto" })
|
||||
const required = (input: RequiredInput) => {
|
||||
const config = state.get()
|
||||
if (!config.auto) return false
|
||||
@@ -444,15 +504,14 @@ export const layer = Layer.effect(
|
||||
return estimateTokens(input) >= promptCeiling
|
||||
}
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
if (!content)
|
||||
if (findTailStart(input.messages, state.get().tokens) === undefined)
|
||||
return yield* failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
inputID: input.inputID,
|
||||
})
|
||||
return yield* input.resolveModel(input.session).pipe(
|
||||
return yield* input.resolveContext(input.session).pipe(
|
||||
Effect.matchEffect({
|
||||
onFailure: (cause) =>
|
||||
failed({
|
||||
@@ -461,15 +520,14 @@ export const layer = Layer.effect(
|
||||
error: toSessionError(cause),
|
||||
inputID: input.inputID,
|
||||
}),
|
||||
onSuccess: (resolved) =>
|
||||
onSuccess: (context) =>
|
||||
execute({
|
||||
session: input.session,
|
||||
resolved,
|
||||
context,
|
||||
instructionUpdate: context.instructionUpdate,
|
||||
prepare: input.prepare,
|
||||
reason: "manual",
|
||||
inputID: input.inputID,
|
||||
started: input.started,
|
||||
...content,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -36,6 +36,7 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
|
||||
messages: history.messages,
|
||||
})
|
||||
const prepared = yield* context.prepare({
|
||||
purpose: "generate",
|
||||
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
|
||||
@@ -57,11 +57,14 @@ export interface Prepared {
|
||||
}
|
||||
|
||||
interface PrepareInput {
|
||||
readonly purpose: PluginHooks.Domains["session"]["context"]["purpose"]
|
||||
readonly scope: {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agentID: Agent.ID
|
||||
/** Agent whose context an auxiliary request reuses, without changing its request-hook identity. */
|
||||
readonly contextAgentID?: Agent.ID
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
/** Omitted for requests that carry no tools (title, compaction). */
|
||||
/** Omitted for requests that carry no tool definitions, such as titles. */
|
||||
readonly tools?: Tool.Snapshot
|
||||
}
|
||||
readonly transcript: {
|
||||
@@ -69,12 +72,6 @@ interface PrepareInput {
|
||||
readonly messages: Array<Message>
|
||||
}
|
||||
readonly toolChoice?: LLM.RequestInput["toolChoice"]
|
||||
/**
|
||||
* Session context hooks shape the agent conversation. Requests that are not
|
||||
* part of the conversation (title, compaction) opt out: their transcripts
|
||||
* pass through unchanged.
|
||||
*/
|
||||
readonly contextHooks?: false
|
||||
/** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */
|
||||
readonly webSocket?: "session"
|
||||
}
|
||||
@@ -300,15 +297,16 @@ export const layer = Layer.effect(
|
||||
const definitions = Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition]))
|
||||
const context: PluginHooks.Domains["session"]["context"] = {
|
||||
sessionID: session.id,
|
||||
agent: input.scope.agentID,
|
||||
agent: input.scope.contextAgentID ?? input.scope.agentID,
|
||||
model: resolved.ref,
|
||||
purpose: input.purpose,
|
||||
system: input.transcript.system,
|
||||
messages: input.transcript.messages,
|
||||
tools: definitions,
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
}
|
||||
if (input.contextHooks !== false) yield* hooks.trigger("session", "context", context)
|
||||
yield* hooks.trigger("session", "context", context)
|
||||
// Match each surviving entry back to its tool, by recognizing a moved definition or
|
||||
// by key. Identity wins so a definition moved onto another tool's name still executes
|
||||
// the tool it describes. Entries matching neither were invented by a hook and dropped.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SessionCompaction } from "../compaction.js"
|
||||
import { SessionContext } from "../context.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionInbox } from "../inbox.js"
|
||||
import { SessionHistory } from "../history.js"
|
||||
import { SessionModelRequest } from "../model-request.js"
|
||||
import { SessionModelTransport } from "../model-transport.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
@@ -104,7 +105,22 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
return yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: context.resolveModel,
|
||||
resolveContext: (session) =>
|
||||
Effect.gen(function* () {
|
||||
const selected = yield* context.select(session.id)
|
||||
const model = yield* context.resolveModel(selected.session)
|
||||
// Preview updates without admitting them after the already-delivered compaction marker.
|
||||
const history = yield* SessionHistory.preview(db, session.id, selected.instructions)
|
||||
return {
|
||||
session: selected.session,
|
||||
agent: selected.agent,
|
||||
tools: selected.tools,
|
||||
model,
|
||||
initial: history.initial,
|
||||
messages: history.messages,
|
||||
instructionUpdate: history.instructionUpdate,
|
||||
}
|
||||
}),
|
||||
prepare: context.prepare,
|
||||
messages: yield* store.context(sessionID),
|
||||
inputID: pending.id,
|
||||
@@ -180,12 +196,10 @@ const layer = Layer.effect(
|
||||
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
|
||||
initial = undefined
|
||||
const compactionInput = {
|
||||
session: loaded.session,
|
||||
messages: loaded.messages,
|
||||
resolved: loaded.model,
|
||||
context: loaded,
|
||||
prepare: context.prepare,
|
||||
}
|
||||
if (compaction.required({ ...compactionInput, context: loaded })) {
|
||||
if (compaction.required({ messages: loaded.messages, resolved: loaded.model, context: loaded })) {
|
||||
const compacted = yield* compaction.compact(compactionInput)
|
||||
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
@@ -200,6 +214,7 @@ const layer = Layer.effect(
|
||||
messages: loaded.messages,
|
||||
})
|
||||
const prepared = yield* context.prepare({
|
||||
purpose: "session",
|
||||
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
|
||||
@@ -64,12 +64,12 @@ export const layer = Layer.effect(
|
||||
: Effect.void,
|
||||
)
|
||||
const prepared = yield* context.prepare({
|
||||
purpose: "title",
|
||||
scope: { session: input.session, agentID: input.agent.id, model: input.model },
|
||||
transcript: {
|
||||
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
|
||||
messages: [Message.user(input.text)],
|
||||
},
|
||||
contextHooks: false,
|
||||
})
|
||||
yield* llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
|
||||
@@ -19,6 +19,9 @@ export const Input = Schema.Struct({
|
||||
path: Schema.optionalKey(RelativePath).annotate({
|
||||
description: "Directory to search. Defaults to the current working directory.",
|
||||
}),
|
||||
hidden: FileSystem.GlobInput.fields.hidden.annotate({
|
||||
description: "Include hidden files and directories (default: false).",
|
||||
}),
|
||||
limit: FileSystem.GlobInput.fields.limit.annotate({
|
||||
description: `Maximum number of matching files to return (default: ${FileSystem.DEFAULT_SEARCH_LIMIT})`,
|
||||
}),
|
||||
@@ -76,6 +79,7 @@ export const Plugin = {
|
||||
metadata: {
|
||||
root: searchPath ?? ".",
|
||||
path: searchPath,
|
||||
hidden: input.hidden,
|
||||
limit: input.limit,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
@@ -97,6 +101,7 @@ export const Plugin = {
|
||||
.glob({
|
||||
cwd: root,
|
||||
pattern: input.pattern,
|
||||
hidden: input.hidden,
|
||||
limit: limit + 1,
|
||||
})
|
||||
.pipe(
|
||||
|
||||
@@ -18,7 +18,7 @@ export const Input = Schema.Struct({
|
||||
pattern: FileSystem.GrepInput.fields.pattern
|
||||
.check(Schema.isMinLength(1, { message: "Pattern must not be empty" }))
|
||||
.annotate({
|
||||
description: "Regular expression to search for in file contents (ripgrep syntax)",
|
||||
description: "Regular expression or literal text to match in file contents.",
|
||||
}),
|
||||
path: Schema.optionalKey(RelativePath).annotate({
|
||||
description: "File or directory to search. Defaults to the current working directory.",
|
||||
@@ -26,6 +26,12 @@ export const Input = Schema.Struct({
|
||||
include: FileSystem.GrepInput.fields.include.annotate({
|
||||
description: 'Glob pattern to filter files (for example, "*.js" or "*.{ts,tsx}")',
|
||||
}),
|
||||
literal: FileSystem.GrepInput.fields.literal.annotate({
|
||||
description: "Treat `pattern` as exact text instead of a regular expression (default: false).",
|
||||
}),
|
||||
caseSensitive: FileSystem.GrepInput.fields.caseSensitive.annotate({
|
||||
description: "Use case-sensitive matching (default: true).",
|
||||
}),
|
||||
limit: FileSystem.GrepInput.fields.limit.annotate({
|
||||
description: `Maximum number of matching lines to return (default: ${FileSystem.DEFAULT_SEARCH_LIMIT})`,
|
||||
}),
|
||||
@@ -70,7 +76,7 @@ export const Plugin = {
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
|
||||
"Search file contents using ripgrep's regular expression syntax or literal text matching. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
@@ -92,6 +98,8 @@ export const Plugin = {
|
||||
root: ".",
|
||||
path: input.path,
|
||||
include: input.include,
|
||||
literal: input.literal,
|
||||
caseSensitive: input.caseSensitive,
|
||||
limit: input.limit,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
@@ -112,6 +120,8 @@ export const Plugin = {
|
||||
pattern: input.pattern,
|
||||
file: type === "file" ? path.basename(root) : undefined,
|
||||
include: input.include,
|
||||
literal: input.literal,
|
||||
caseSensitive: input.caseSensitive,
|
||||
limit: limit + 1,
|
||||
})
|
||||
.pipe(
|
||||
|
||||
@@ -190,6 +190,13 @@ describe("Agent", () => {
|
||||
])
|
||||
expect((yield* agent.get(Agent.defaultID))?.system).toBeUndefined()
|
||||
const permissions = (yield* agent.get(Agent.defaultID))?.permissions ?? []
|
||||
const compaction = yield* agent.get(Agent.ID.make("compaction"))
|
||||
expect(compaction?.mode).toBe("primary")
|
||||
expect(compaction?.hidden).toBe(true)
|
||||
expect(compaction?.system).toBeUndefined()
|
||||
expect(compaction?.model).toBeUndefined()
|
||||
expect(compaction?.request).toEqual(Agent.Info.default(Agent.ID.make("compaction")).request)
|
||||
expect(compaction?.permissions).toEqual(permissions.filter((rule) => rule.action !== "question"))
|
||||
expect(
|
||||
Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), permissions).effect,
|
||||
).toBe("allow")
|
||||
|
||||
@@ -42,7 +42,7 @@ const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, SessionModelRequest.node, Config.node, Bus.node]), [
|
||||
llmClient.replace(
|
||||
Layer.mock(LLMClient.Service)({
|
||||
stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })),
|
||||
stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "## Objective\n- summary" })),
|
||||
}),
|
||||
),
|
||||
Config.node.replace(config),
|
||||
@@ -77,25 +77,26 @@ describe("ConfigCompactionPlugin.Plugin", () => {
|
||||
const started = yield* bus
|
||||
.subscribe(SessionEvent.Compaction.Started)
|
||||
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
const messages = [
|
||||
SessionMessage.User.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Older context",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
}),
|
||||
SessionMessage.User.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Recent context",
|
||||
time: { created: DateTime.makeUnsafe(1) },
|
||||
}),
|
||||
]
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: () => Effect.succeed(resolved),
|
||||
resolveContext: () => Effect.succeed({ ...nearInput.context, messages, instructionUpdate: "" }),
|
||||
prepare: modelRequests.prepare,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Older context",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Recent context",
|
||||
time: { created: DateTime.makeUnsafe(1) },
|
||||
},
|
||||
],
|
||||
messages,
|
||||
inputID: SessionMessage.ID.make("msg_compaction_manual"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
|
||||
@@ -411,6 +411,60 @@ describe("ModelsDevPlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("copies model request bodies without reinterpreting literal __proto__ keys", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("acme")
|
||||
const modelID = Model.ID.make("gpt-5.4")
|
||||
// A JSON body may legitimately contain a "__proto__" key; both copy stages must keep it as an own property.
|
||||
const body = JSON.parse('{"__proto__":{"service_tier":"priority"},"keep":true}') as Record<string, unknown>
|
||||
const snapshot = {
|
||||
info: {
|
||||
id: providerID,
|
||||
name: "Acme",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
},
|
||||
environment: [],
|
||||
models: [
|
||||
{
|
||||
id: modelID,
|
||||
modelID,
|
||||
providerID,
|
||||
name: "GPT-5.4",
|
||||
capabilities: { tools: true, input: [], output: [] },
|
||||
variants: [],
|
||||
time: { released: Date.parse("2026-01-01") },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 1_050_000, output: 128_000 },
|
||||
body,
|
||||
},
|
||||
],
|
||||
} satisfies ModelsDev.Snapshot
|
||||
yield* ModelsDevPlugin.effect(
|
||||
host({
|
||||
catalog: catalogHost(catalog),
|
||||
integration: integrationHost(integrations),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
ModelsDev.Service,
|
||||
ModelsDev.Service.of({ get: () => Effect.succeed([snapshot]), refresh: () => Effect.void }),
|
||||
),
|
||||
)
|
||||
|
||||
const copied = (yield* catalog.model.get(providerID, modelID))?.body
|
||||
expect(copied).not.toBe(body)
|
||||
expect(Object.hasOwn(copied ?? {}, "__proto__")).toBe(true)
|
||||
expect(Object.keys(copied ?? {})).toEqual(["__proto__", "keep"])
|
||||
expect(JSON.stringify(copied)).toBe(JSON.stringify(body))
|
||||
expect(copied).not.toHaveProperty("service_tier")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits legacy provider aliases", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
@@ -117,10 +117,15 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
|
||||
return { persisted, contextHook, toolHook, files: Environment.makeFiles(driver), planAgent }
|
||||
})
|
||||
|
||||
const request = (agent: Agent.ID, messages: Array<Message>): SessionContext => ({
|
||||
const request = (
|
||||
agent: Agent.ID,
|
||||
messages: Array<Message>,
|
||||
purpose: SessionContext["purpose"] = "session",
|
||||
): SessionContext => ({
|
||||
sessionID,
|
||||
agent,
|
||||
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test") },
|
||||
purpose,
|
||||
system: [],
|
||||
messages,
|
||||
tools: {},
|
||||
@@ -242,6 +247,17 @@ describe("plan plugin reminders", () => {
|
||||
expect(persisted).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not reconcile reminders for auxiliary requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const { enter } = yield* reminders
|
||||
const { persisted, contextHook } = yield* run()
|
||||
const messages = [Message.user(enter)]
|
||||
yield* contextHook(request(build, messages, "title"))
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(persisted).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("plan plugin mutations", () => {
|
||||
|
||||
@@ -226,6 +226,7 @@ describe("OpenAIPlugin", () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const requests = yield* SessionModelRequest.Service
|
||||
return yield* requests.prepare({
|
||||
purpose: "session",
|
||||
scope: {
|
||||
session: Session.Info.make({
|
||||
id: sessionID,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, LayerMap } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
import { Instance } from "@opencode-ai/core/instance"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Database } from "../../src/database/database"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const id = Plugin.ID.make("account-prompts")
|
||||
|
||||
// Host and instance plugins share one ID; each registers a distinct command so the winner is observable.
|
||||
const greeter = (command: string) =>
|
||||
define({
|
||||
id,
|
||||
effect: (ctx) =>
|
||||
ctx.command.transform((draft) => draft.add({ name: command, execute: () => Effect.void })).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const instances = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.gen(function* () {
|
||||
const map = yield* LayerMap.make(
|
||||
(ref: Location.Ref) =>
|
||||
Instance.layer(ref, { discovery: false, plugins: [greeter("instance-greet")], replacements: bindings }),
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
)
|
||||
const bindings: LayerNode.Replacements = [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)),
|
||||
Instance.node.replace(
|
||||
Layer.succeed(Instance.Service, {
|
||||
provide: (session) => Effect.provide(map.get(session.location)),
|
||||
}),
|
||||
),
|
||||
]
|
||||
return map
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
LocationServiceMap.node.replace(instances),
|
||||
]),
|
||||
)
|
||||
|
||||
describe("PluginSupervisor", () => {
|
||||
it.live("reports a duplicate plugin ID as a failure without dropping the generation", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(greeter("host-greet"))
|
||||
const directory = yield* tmpdirScoped()
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const state = yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
const commands = yield* Command.Service
|
||||
return {
|
||||
inventory: yield* plugins.list(),
|
||||
host: yield* commands.get("host-greet"),
|
||||
instance: yield* commands.get("instance-greet"),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
|
||||
// Earlier boot order wins: the host SDK plugin activates and the instance copy is reported.
|
||||
expect(state.inventory.filter((plugin) => plugin.id === id)).toEqual([
|
||||
{ id, source: { type: "sdk" }, state: { status: "active" }, features: { server: true } },
|
||||
{
|
||||
id,
|
||||
source: { type: "sdk" },
|
||||
state: { status: "failed", error: `Duplicate plugin ID: ${id}` },
|
||||
features: { server: true },
|
||||
},
|
||||
])
|
||||
expect(state.host).toBeDefined()
|
||||
expect(state.instance).toBeUndefined()
|
||||
// Builtins stay active: the duplicate is a plugin failure, not a generation defect.
|
||||
expect(
|
||||
state.inventory.some((plugin) => plugin.id?.startsWith("opencode.") && plugin.state.status === "active"),
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -25,10 +25,15 @@ const makeHost = Effect.gen(function* () {
|
||||
return yield* PluginHost.make(plugins)
|
||||
})
|
||||
|
||||
const context = (id: string, system = fallback): SessionHooks["context"] => ({
|
||||
const context = (
|
||||
id: string,
|
||||
system = fallback,
|
||||
purpose: SessionHooks["context"]["purpose"] = "session",
|
||||
): SessionHooks["context"] => ({
|
||||
sessionID: Session.ID.make("ses_system_prompt"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make(id) }),
|
||||
purpose,
|
||||
system: [SystemPart.make(system)],
|
||||
messages: [],
|
||||
tools: {},
|
||||
@@ -145,6 +150,19 @@ describe("SystemPromptPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves title request prompts", () =>
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
yield* SystemPromptPlugin.OpenAIPlugin.effect(pluginHost)
|
||||
const title = context("gpt-5", fallback, "title")
|
||||
|
||||
yield* hooks.trigger("session", "context", title)
|
||||
|
||||
expect(title.system[0]?.text).toBe(fallback)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("skips the hook when agent lookup fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
|
||||
@@ -7,6 +7,7 @@ import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
@@ -51,7 +52,7 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
stream: (request: LLMRequest) => {
|
||||
requests.push(request)
|
||||
return Stream.make(
|
||||
LLMEvent.textDelta({ id: "summary", text: "manual summary" }),
|
||||
LLMEvent.textDelta({ id: "summary", text: "## Objective\n- manual summary" }),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: { normalized: "stop" },
|
||||
@@ -83,6 +84,7 @@ const it = testEffect(
|
||||
Bus.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
PluginHooks.node,
|
||||
SessionCompaction.node,
|
||||
SessionModelRequest.node,
|
||||
]),
|
||||
@@ -91,7 +93,7 @@ const it = testEffect(
|
||||
)
|
||||
|
||||
test("compaction prompt preserves detailed work state and relevant files", () => {
|
||||
const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] })
|
||||
const prompt = SessionCompaction.buildPrompt(false)
|
||||
|
||||
expect(prompt).toContain("## Work State\n### Completed")
|
||||
expect(prompt).toContain("### Active")
|
||||
@@ -123,29 +125,24 @@ test("compaction truncation does not split surrogate pairs", () => {
|
||||
})
|
||||
|
||||
test("compaction prompt requires the checkpoint headings in order", () => {
|
||||
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
|
||||
const prompt = SessionCompaction.buildPrompt(false)
|
||||
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
|
||||
"## Objective",
|
||||
"## Important Details",
|
||||
"## Requirements",
|
||||
"## Decisions",
|
||||
"## Work State",
|
||||
"### Completed",
|
||||
"### Active",
|
||||
"### Blocked",
|
||||
"## Next Move",
|
||||
"## Relevant Files",
|
||||
"## Additional Context",
|
||||
])
|
||||
expect(prompt).toContain("one or two brief sentences")
|
||||
expect(prompt).toContain("constraints/preferences, decisions and why")
|
||||
expect(prompt).toContain("immediate concrete action")
|
||||
expect(prompt).toContain("next action if known")
|
||||
expect(prompt).toContain("Keep every section, even when empty.")
|
||||
})
|
||||
|
||||
test("compaction points an existing summary to the following history", () => {
|
||||
const prompt = SessionCompaction.buildPrompt({ previousSummary: "Previous summary", context: ["Recent history"] })
|
||||
|
||||
expect(prompt.split("\n", 1)[0]).toBe("Update the anchored summary below using the conversation history below.")
|
||||
expect(prompt).not.toContain("conversation history above")
|
||||
test("compaction prompts prohibit task execution", () => {
|
||||
for (const update of [false, true])
|
||||
expect(SessionCompaction.buildPrompt(update)).toContain("Do not continue the task or call tools")
|
||||
})
|
||||
|
||||
it.effect("auto compaction estimates current content against the buffered prompt ceiling", () =>
|
||||
@@ -305,6 +302,16 @@ const insertSession = (id: Session.ID, overrides?: Partial<typeof SessionTable.$
|
||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die(`session missing: ${id}`))))
|
||||
})
|
||||
|
||||
const loaded = (session: Session.Info, messages: readonly SessionMessage.Info[]) => ({
|
||||
session,
|
||||
messages,
|
||||
model: resolved,
|
||||
agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
|
||||
initial: "Session instructions",
|
||||
instructionUpdate: "",
|
||||
tools: { definitions: [], execute: () => Effect.die("Compaction must not execute tools") },
|
||||
})
|
||||
|
||||
it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
@@ -329,6 +336,32 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
}
|
||||
const session = yield* insertSession(sessionID, { parent_id: parentID })
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const messages = [
|
||||
userMessage,
|
||||
SessionMessage.Shell.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "shell",
|
||||
shellID: Shell.ID.make("sh_background"),
|
||||
status: "exited",
|
||||
command: "pwd",
|
||||
metadata: { background: true },
|
||||
output: { output: "display-only-output", cursor: 19, size: 19, truncated: false },
|
||||
time: { created: DateTime.makeUnsafe(0), completed: DateTime.makeUnsafe(1) },
|
||||
}),
|
||||
SessionMessage.Synthetic.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "synthetic",
|
||||
text: "User shell pwd completed: /project",
|
||||
time: { created: DateTime.makeUnsafe(2) },
|
||||
}),
|
||||
]
|
||||
const purposes: string[] = []
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
purposes.push(event.purpose)
|
||||
}),
|
||||
)
|
||||
|
||||
const delta = yield* bus
|
||||
.subscribe(SessionEvent.Compaction.Delta)
|
||||
@@ -337,33 +370,18 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: () => Effect.succeed(resolved),
|
||||
resolveContext: () => Effect.succeed(loaded(session, messages)),
|
||||
prepare: modelRequests.prepare,
|
||||
messages: [
|
||||
userMessage,
|
||||
SessionMessage.Shell.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "shell",
|
||||
shellID: Shell.ID.make("sh_background"),
|
||||
status: "exited",
|
||||
command: "pwd",
|
||||
metadata: { background: true },
|
||||
output: { output: "display-only-output", cursor: 19, size: 19, truncated: false },
|
||||
time: { created: DateTime.makeUnsafe(0), completed: DateTime.makeUnsafe(1) },
|
||||
}),
|
||||
SessionMessage.Synthetic.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "synthetic",
|
||||
text: "User shell pwd completed: /project",
|
||||
time: { created: DateTime.makeUnsafe(2) },
|
||||
}),
|
||||
],
|
||||
messages,
|
||||
inputID: SessionMessage.ID.make("msg_manual_compaction"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
|
||||
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual([
|
||||
"## Objective\n- manual summary",
|
||||
])
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(purposes).toEqual(["compaction"])
|
||||
expect(requests[0]?.promptCacheKey).toBe(sessionID)
|
||||
expect(requests[0]?.http?.headers).toEqual({
|
||||
"x-session-affinity": sessionID,
|
||||
@@ -380,7 +398,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("User shell pwd completed: /project")
|
||||
expect(JSON.stringify(requests[0]?.messages)).not.toContain("display-only-output")
|
||||
expect(yield* store.context(sessionID)).toMatchObject([
|
||||
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
|
||||
{ type: "compaction", reason: "manual", summary: "## Objective\n- manual summary", recent: "" },
|
||||
])
|
||||
expect(yield* store.get(sessionID)).toMatchObject({
|
||||
cost: 0.0000233,
|
||||
@@ -415,7 +433,7 @@ it.effect("manual compaction records model resolution failures without calling t
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: () =>
|
||||
resolveContext: () =>
|
||||
Effect.fail(
|
||||
new SessionRunnerModel.ModelUnavailableError({
|
||||
providerID: Provider.ID.make("test"),
|
||||
@@ -461,19 +479,20 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
|
||||
fork_boundary: { type: "before", messageID: SessionMessage.ID.create() },
|
||||
})
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const messages = [
|
||||
SessionMessage.User.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Summarize the forked conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
}),
|
||||
]
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: () => Effect.succeed(resolved),
|
||||
resolveContext: () => Effect.succeed(loaded(session, messages)),
|
||||
prepare: modelRequests.prepare,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Summarize the forked conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
],
|
||||
messages,
|
||||
inputID: SessionMessage.ID.make("msg_fork_compaction"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Location } from "@opencode-ai/core/location"
|
||||
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
|
||||
import { ID } from "@opencode-ai/core/model"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -131,6 +132,7 @@ const it = testEffect(
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
Agent.node,
|
||||
PluginHooks.node,
|
||||
InstructionBuiltIns.node,
|
||||
SessionContext.node,
|
||||
llmClient,
|
||||
@@ -302,12 +304,20 @@ it.effect(
|
||||
})
|
||||
instruction = "Changed context"
|
||||
const before = yield* durableState(db, sessionID)
|
||||
const purposes: string[] = []
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
purposes.push(event.purpose)
|
||||
}),
|
||||
)
|
||||
|
||||
const result = yield* SessionGenerate.generate({ session, prompt: "Summarize privately" }).pipe(
|
||||
Effect.provideService(Instance.Service, instances),
|
||||
)
|
||||
|
||||
expect(result).toBe("Transient answer")
|
||||
expect(purposes).toEqual(["generate"])
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.model).toBe(model)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
SystemPart,
|
||||
LanguageModel,
|
||||
ToolFailure,
|
||||
TransportError,
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
UnknownProviderError,
|
||||
} from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
|
||||
import { AnthropicMessages, OpenAIResponses } from "@opencode-ai/ai/protocols"
|
||||
import { compileRequest } from "@opencode-ai/ai/route/client"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -70,7 +73,7 @@ import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
|
||||
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
|
||||
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
|
||||
import { SessionSystemPrompt } from "@opencode-ai/core/session/system-prompt"
|
||||
import { ID } from "@opencode-ai/core/model"
|
||||
import { ID, Model } from "@opencode-ai/core/model"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Scope, Stream } from "effect"
|
||||
@@ -1392,7 +1395,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.admit("Echo before moving")
|
||||
yield* s.llm.push(
|
||||
TestLLM.tool("call-entry", "echo", { text: "moving" }),
|
||||
TestLLM.text("Entry summary", "entry-summary"),
|
||||
TestLLM.text("## Objective\n- Entry summary", "entry-summary"),
|
||||
TestLLM.text("Continued", "entry-continuation"),
|
||||
)
|
||||
const stream = yield* s.llm.gate
|
||||
@@ -1427,8 +1430,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* runner.drain({ sessionID, force: false, continuation: moved.continuation })
|
||||
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1])[0]).toContain("Create a new anchored summary")
|
||||
expect(userTexts(s.requests[2])[0]).toContain("<summary>\nEntry summary\n</summary>")
|
||||
expect(userTexts(s.requests[2])[0]).toContain("<summary>\n## Objective\n- Entry summary\n</summary>")
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
@@ -1895,28 +1897,29 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
scenario("moves the epoch at compaction and narrates later changes", function* (s) {
|
||||
yield* s.runPrompt("First")
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
recent: "",
|
||||
})
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
text: "summary",
|
||||
recent: "",
|
||||
})
|
||||
s.systemBaseline = "Changed before compaction"
|
||||
yield* s.llm.push(TestLLM.text("## Objective\n- summary", "epoch-summary"))
|
||||
yield* s.session.compact({ sessionID })
|
||||
yield* s.resume
|
||||
expect(systemTexts(s.requests[1])).toEqual(["Changed before compaction"])
|
||||
expect((yield* s.context).some((message) => message.type === "system")).toBe(false)
|
||||
s.systemBaseline = "Replacement context"
|
||||
yield* s.runPrompt("Second")
|
||||
|
||||
expect(s.requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
[defaultSystem, "Initial context"],
|
||||
[defaultSystem, "Initial context"],
|
||||
[defaultSystem, "Initial context"],
|
||||
])
|
||||
expect(messageRoles(s.requests[1])).toEqual(["user", "system", "user"])
|
||||
expect(s.requests[1]?.messages.at(1)?.content).toEqual([Expected.text("Replacement context")])
|
||||
expect(messageRoles(s.requests[2])).toEqual(["user", "system", "user"])
|
||||
expect(s.requests[2]?.messages.at(1)?.content).toEqual([Expected.text("Replacement context")])
|
||||
yield* replaySessionProjection(sessionID)
|
||||
yield* s.runPrompt("Third")
|
||||
const latest = yield* s.runPrompt("Third")
|
||||
expect(systemTexts(s.requests[3])).toEqual(["Replacement context"])
|
||||
const fork = yield* s.session.fork({ sessionID, boundary: { type: "before", messageID: latest.id } })
|
||||
expect(
|
||||
(yield* s.session.context(fork.id)).flatMap((message) => (message.type === "system" ? [message.text] : [])),
|
||||
).toEqual(["Replacement context"])
|
||||
})
|
||||
|
||||
scenario("runs steers before queued compaction and later queued input", function* (s) {
|
||||
@@ -1924,7 +1927,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.llm.push(
|
||||
TestLLM.tool("call-active", "echo", { text: "active" }),
|
||||
TestLLM.text("Steer complete", "text-steer"),
|
||||
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
|
||||
[LLMEvent.textDelta({ id: "summary", text: "## Objective\n- durable summary" })],
|
||||
TestLLM.text("Queue complete", "text-queue"),
|
||||
)
|
||||
yield* s.admit("Active work")
|
||||
@@ -1951,13 +1954,12 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(s.requests).toHaveLength(4)
|
||||
expect(userTexts(s.requests[1])).toContain("Steer after compaction")
|
||||
expect(userTexts(s.requests[1])).toContain("Completion after compaction")
|
||||
expect(userTexts(s.requests[2])[0]).toContain("Create a new anchored summary")
|
||||
expect(userTexts(s.requests[3])).toContain("Queue after compaction")
|
||||
expect(yield* SessionInbox.find(s.db, first.id)).toBeUndefined()
|
||||
expect((yield* s.messages).find((message) => message.id === first.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
summary: "durable summary",
|
||||
summary: "## Objective\n- durable summary",
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1966,6 +1968,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.llm.push(
|
||||
TestLLM.text("Active complete", "text-active-failure"),
|
||||
[],
|
||||
[],
|
||||
TestLLM.text("Continued", "text-after-failure"),
|
||||
)
|
||||
yield* s.admit("Active work")
|
||||
@@ -1980,8 +1983,8 @@ describe("SessionRunnerLLM", () => {
|
||||
})
|
||||
yield* active.finish
|
||||
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[2])).toContain("Continue after failure")
|
||||
expect(s.requests).toHaveLength(4)
|
||||
expect(userTexts(s.requests[3])).toContain("Continue after failure")
|
||||
expect(yield* SessionInbox.find(s.db, compaction.id)).toBeUndefined()
|
||||
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
@@ -2020,7 +2023,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.runPrompt("Earlier question")
|
||||
|
||||
s.requests.length = 0
|
||||
yield* s.llm.push(TestLLM.text("Manual summary", "text-manual-unknown-summary"))
|
||||
yield* s.llm.push(TestLLM.text("## Objective\n- Manual summary", "text-manual-unknown-summary"))
|
||||
const compaction = yield* s.session.compact({ sessionID, delivery: "steer" })
|
||||
yield* s.resume
|
||||
|
||||
@@ -2029,7 +2032,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
summary: "Manual summary",
|
||||
summary: "## Objective\n- Manual summary",
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2037,7 +2040,7 @@ describe("SessionRunnerLLM", () => {
|
||||
s.currentModel = recoveryModel
|
||||
yield* s.llm.push(
|
||||
TestLLM.text("Active complete", "text-active-steer-compact"),
|
||||
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
|
||||
[LLMEvent.textDelta({ id: "summary", text: "## Objective\n- durable summary" })],
|
||||
TestLLM.text("Queue complete", "text-queue-after-compact"),
|
||||
)
|
||||
yield* s.admit("Active work")
|
||||
@@ -2050,13 +2053,12 @@ describe("SessionRunnerLLM", () => {
|
||||
// Steer-delivered compaction runs at the boundary after the active step, ahead of
|
||||
// the queued prompt, and consuming it does not trigger an input-free model call.
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1])[0]).toContain("Create a new anchored summary")
|
||||
expect(userTexts(s.requests[2])).toContain("Queued prompt")
|
||||
expect(yield* SessionInbox.find(s.db, compaction.id)).toBeUndefined()
|
||||
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
summary: "durable summary",
|
||||
summary: "## Objective\n- durable summary",
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2064,7 +2066,7 @@ describe("SessionRunnerLLM", () => {
|
||||
s.currentModel = recoveryModel
|
||||
yield* s.llm.push(
|
||||
TestLLM.tool("call-active", "echo", { text: "active" }),
|
||||
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
|
||||
[LLMEvent.textDelta({ id: "summary", text: "## Objective\n- durable summary" })],
|
||||
TestLLM.text("Continued", "text-continued-after-compact"),
|
||||
)
|
||||
yield* s.admit("Active work")
|
||||
@@ -2075,11 +2077,10 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
// The compaction summary is requested before the tool turn's continuation step.
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1])[0]).toContain("Create a new anchored summary")
|
||||
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
summary: "durable summary",
|
||||
summary: "## Objective\n- durable summary",
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2098,6 +2099,148 @@ describe("SessionRunnerLLM", () => {
|
||||
})
|
||||
})
|
||||
|
||||
for (const route of [OpenAIChat.route, OpenAIResponses.route, AnthropicMessages.route]) {
|
||||
for (const reason of ["manual", "auto"] as const) {
|
||||
scenario(`preserves the session request prefix during ${reason} compaction (${route.id})`, function* (s) {
|
||||
const agents = yield* Agent.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const agentID = Agent.ID.make("reviewer")
|
||||
const variant = Model.VariantID.make("test-variant")
|
||||
s.currentModel = LanguageModel.make({
|
||||
id: route === AnthropicMessages.route ? "claude-sonnet-4-6" : "gpt-5",
|
||||
provider: route === AnthropicMessages.route ? "anthropic" : "openai",
|
||||
route,
|
||||
})
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(agentID, (agent) => {
|
||||
agent.system = "Review the project carefully."
|
||||
}),
|
||||
)
|
||||
yield* s.bus.publish(SessionEvent.AgentSelected, { sessionID, agent: agentID })
|
||||
yield* s.bus.publish(SessionEvent.ModelSelected, {
|
||||
sessionID,
|
||||
model: { id: ID.make(s.currentModel.id), providerID: Provider.ID.make(s.currentModel.provider), variant },
|
||||
})
|
||||
const requestAgents: Agent.ID[] = []
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.agent).toBe(agentID)
|
||||
expect(event.model.variant).toBe(variant)
|
||||
event.system.push(SystemPart.make("Hook-provided instructions"))
|
||||
event.tools.echo.description = "Hook-provided tool description"
|
||||
event.generation.maxTokens = 4_000
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "model.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
requestAgents.push(event.agent)
|
||||
}),
|
||||
)
|
||||
yield* s.llm.push(
|
||||
TestLLM.tool("call-prefix", "echo", { text: "x".repeat(4_000) }),
|
||||
TestLLM.textWithUsage("Earlier answer", "prefix-answer", 185_000),
|
||||
TestLLM.text("## Objective\n- Checkpoint summary", "prefix-summary"),
|
||||
)
|
||||
yield* s.runPrompt("Review these changes")
|
||||
if (reason === "manual") {
|
||||
yield* s.session.compact({ sessionID })
|
||||
yield* s.resume
|
||||
}
|
||||
if (reason === "auto") {
|
||||
yield* s.llm.push(TestLLM.text("Continued", "prefix-continued"))
|
||||
yield* s.runPrompt("Retained recent request")
|
||||
}
|
||||
|
||||
const normal = s.requests[1]
|
||||
const compact = s.requests[2]
|
||||
expect(compact.messages.slice(0, normal.messages.length)).toEqual([...normal.messages])
|
||||
expect(compact.messages.at(-1)).toMatchObject({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: SessionCompaction.buildPrompt(false) }],
|
||||
})
|
||||
expect(userTexts(compact)).not.toContain("Retained recent request")
|
||||
for (const field of [
|
||||
"model",
|
||||
"system",
|
||||
"tools",
|
||||
"generation",
|
||||
"providerOptions",
|
||||
"toolChoice",
|
||||
"cache",
|
||||
"promptCacheKey",
|
||||
"http",
|
||||
] as const)
|
||||
expect(compact[field]).toEqual(normal[field])
|
||||
expect(compact.toolChoice).toBeUndefined()
|
||||
expect(compact.system.map((part) => part.text)).toContain("Review the project carefully.")
|
||||
expect(requestAgents[2]).toBe(Agent.ID.make("compaction"))
|
||||
expect(s.executions).toEqual(["x".repeat(4_000)])
|
||||
|
||||
// Compare wire content without the cache breakpoints that move to the new final message.
|
||||
const before = yield* compileRequest(LLMRequest.update(normal, { cache: "none" }))
|
||||
const after = yield* compileRequest(LLMRequest.update(compact, { cache: "none" }))
|
||||
const key = route === OpenAIResponses.route ? "input" : "messages"
|
||||
const input = Schema.decodeUnknownSync(Schema.Array(Schema.Unknown))
|
||||
const prefix = input(before.body[key])
|
||||
expect(input(after.body[key]).slice(0, prefix.length)).toEqual([...prefix])
|
||||
expect(after.body).toMatchObject(
|
||||
Object.fromEntries(Object.entries(before.body).filter(([name]) => name !== key)),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const response of ["tools", "reasoning", "invalid text"] as const) {
|
||||
for (const summary of [true, false]) {
|
||||
scenario(
|
||||
`compaction ${summary ? "recovers" : "stops"} after one template reminder for ${response}`,
|
||||
function* (s) {
|
||||
yield* s.llm.push(TestLLM.text("Earlier answer", "summary-history"))
|
||||
yield* s.runPrompt("Earlier question")
|
||||
s.requests.length = 0
|
||||
const invalid =
|
||||
response === "tools"
|
||||
? TestLLM.tool("call-summary", "echo", { text: "must not execute" })
|
||||
: response === "reasoning"
|
||||
? TestLLM.stop(
|
||||
LLMEvent.reasoningDelta({ id: "summary-reasoning", text: "## Objective\n- Not a summary" }),
|
||||
)
|
||||
: TestLLM.text("Let me search the codebase. I will fill in ## Objective later.", "invalid-summary")
|
||||
yield* s.llm.push(
|
||||
invalid,
|
||||
summary ? TestLLM.text("### Active\n- Recovered summary", "summary-recovered") : invalid,
|
||||
)
|
||||
const compact = yield* s.session.compact({ sessionID })
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests).toHaveLength(2)
|
||||
expect(s.requests[1].messages.slice(0, -1)).toEqual([...s.requests[0].messages])
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("did not fill in the required summary template")
|
||||
expect(s.requests.every((request) => request.toolChoice === undefined)).toBe(true)
|
||||
expect(s.executions).toEqual([])
|
||||
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject(
|
||||
summary
|
||||
? { status: "completed", summary: "### Active\n- Recovered summary" }
|
||||
: {
|
||||
status: "failed",
|
||||
error: {
|
||||
type: "compaction.failed",
|
||||
message:
|
||||
response === "invalid text"
|
||||
? "Compaction summary did not match the required template"
|
||||
: "Compaction produced no summary",
|
||||
},
|
||||
},
|
||||
)
|
||||
if (!summary)
|
||||
expect(
|
||||
(yield* s.context).some((message) => message.type === "user" && message.text === "Earlier question"),
|
||||
).toBe(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
scenario("preserves typed provider failures from manual compaction", function* (s) {
|
||||
yield* s.llm.push(TestLLM.text("Earlier answer", "text-manual-failure-history"))
|
||||
yield* s.runPrompt("Earlier question")
|
||||
@@ -2176,7 +2319,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.runPrompt("Recent exact request ".repeat(180))
|
||||
|
||||
expect(s.requests).toHaveLength(2)
|
||||
expect(userTexts(s.requests[0])[0]).toContain("## Objective")
|
||||
expect(userTexts(s.requests[0]).at(-1)).toContain("## Objective")
|
||||
expect(userTexts(s.requests[1])).toHaveLength(1)
|
||||
expect(userTexts(s.requests[1])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
|
||||
expect(userTexts(s.requests[1])[0]).toContain(`[User]: ${"Recent exact request ".repeat(180)}`)
|
||||
@@ -2186,6 +2329,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(context[0]).toMatchObject({
|
||||
type: "compaction",
|
||||
summary: "## Objective\n- Preserve the task",
|
||||
recent: `[User]: ${"Recent exact request ".repeat(180)}`,
|
||||
})
|
||||
|
||||
s.requests.length = 0
|
||||
@@ -2197,13 +2341,13 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.runPrompt("Newest exact request ".repeat(180))
|
||||
|
||||
expect(s.requests).toHaveLength(2)
|
||||
expect(userTexts(s.requests[0])[0]).toContain(
|
||||
"<previous-summary>\n## Objective\n- Preserve the task\n</previous-summary>",
|
||||
)
|
||||
expect(userTexts(s.requests[0])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
|
||||
expect(userTexts(s.requests[0])[0]).toContain("Recent exact request")
|
||||
expect(userTexts(s.requests[0]).at(-1)).toBe(SessionCompaction.buildPrompt(true))
|
||||
expect((yield* store.context(sessionID))[0]).toMatchObject({
|
||||
type: "compaction",
|
||||
summary: "## Objective\n- Preserve the updated task",
|
||||
recent: `[User]: ${"Newest exact request ".repeat(180)}`,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2259,7 +2403,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.runPrompt("Continue")
|
||||
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1])[0]).toContain("## Objective")
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("## Objective")
|
||||
expect(userTexts(s.requests[2])[0]).toContain("<summary>\n## Objective\n- Recover overflow\n</summary>")
|
||||
expect(yield* s.context).toMatchObject([
|
||||
{ type: "compaction", summary: "## Objective\n- Recover overflow" },
|
||||
@@ -2283,7 +2427,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.admit("Continue")
|
||||
yield* s.llm.push(
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
TestLLM.text("Overflow summary", "overflow-summary"),
|
||||
TestLLM.text("## Objective\n- Overflow summary", "overflow-summary"),
|
||||
TestLLM.text("Recovered", "overflow-recovered"),
|
||||
TestLLM.stop(),
|
||||
TestLLM.stop(),
|
||||
@@ -2320,7 +2464,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(s.requests[2]?.model).toBe(replacementModel)
|
||||
expect(s.requests[2]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
||||
expect(systemTexts(s.requests[2])).toContain("Changed during compaction")
|
||||
expect(userTexts(s.requests[2])[0]).toContain("<summary>\nOverflow summary\n</summary>")
|
||||
expect(userTexts(s.requests[2])[0]).toContain("<summary>\n## Objective\n- Overflow summary\n</summary>")
|
||||
expect(userTexts(s.requests[2]).join("\n")).not.toContain("Queued during compaction")
|
||||
expect(userTexts(s.requests[2]).join("\n")).not.toContain("Steered during compaction")
|
||||
expect((yield* s.inbox).map((item) => item.id)).toEqual([queued.id, steered.id])
|
||||
|
||||
@@ -272,7 +272,13 @@ it.effect("falls back to the primary model when the small model fails", () =>
|
||||
yield* prompt(sessionID, "Fall back when title generation fails")
|
||||
|
||||
const attempted: Model.Ref[] = []
|
||||
const purposes: string[] = []
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
purposes.push(event.purpose)
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "model.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
attempted.push(event.model)
|
||||
@@ -283,6 +289,7 @@ it.effect("falls back to the primary model when the small model fails", () =>
|
||||
yield* title.generate(sessionID)
|
||||
|
||||
expect(requests.map((request) => String(request.model.id))).toEqual(["title-small", "title-model"])
|
||||
expect(purposes).toEqual(["title", "title"])
|
||||
expect(attempted.map((model) => String(model.variant))).toEqual(["low", "high"])
|
||||
const store = yield* SessionStore.Service
|
||||
expect((yield* store.get(sessionID))?.title).toBe("Generated Title")
|
||||
|
||||
@@ -17,3 +17,25 @@ bundle the assets as an application. The resulting app will be in `dist/`.
|
||||
```bash
|
||||
bun run build && bun run package
|
||||
```
|
||||
|
||||
Production builds require a prebuilt V2 CLI distribution. The release workflow supplies the artifact from the same run:
|
||||
|
||||
```bash
|
||||
OPENCODE_CHANNEL=prod OPENCODE_CLI_DIST=/absolute/path/to/packages/cli/dist bun run build
|
||||
OPENCODE_CHANNEL=prod bun run package
|
||||
```
|
||||
|
||||
Set `OPENCODE_CLI_TARGET` when packaging for a different architecture. The CLI is placed outside `app.asar` in the
|
||||
application's resources directory, and packaging fails if it is missing.
|
||||
|
||||
CLI preparation uses these channel rules:
|
||||
|
||||
| Channel | Without `OPENCODE_CLI_DIST` | With `OPENCODE_CLI_DIST` |
|
||||
| -------------------------------------- | ------------------------------ | --------------------------------------------- |
|
||||
| `dev`, `local`, unset, or unrecognized | Download the dev CLI | Download the dev CLI; ignore the distribution |
|
||||
| `beta` | Download the beta CLI | Copy the supplied CLI; fail if it is missing |
|
||||
| `prod`, `latest` | Fail before changing resources | Copy the supplied CLI; fail if it is missing |
|
||||
|
||||
`bun dev` is separate from packaging: it uses local renderer/server mode, the dev app identity, and the CLI source by
|
||||
default. `bun dev --download-server <version>` instead downloads that CLI version for local development. Neither path
|
||||
requires `OPENCODE_CLI_DIST` or runs the production prebuild.
|
||||
|
||||
@@ -18,6 +18,20 @@ const channels = [
|
||||
] as const
|
||||
|
||||
for (const channel of channels) {
|
||||
test(`includes the Windows sandbox permission hook for ${channel.channel}`, async () => {
|
||||
const previous = process.env.OPENCODE_CHANNEL
|
||||
process.env.OPENCODE_CHANNEL = channel.channel
|
||||
try {
|
||||
const config = (await import(`./electron-builder.config.ts?channel=${channel.channel}`)).default as Configuration
|
||||
const include = path.join(import.meta.dirname, "resources/windows/installer.nsh")
|
||||
expect(config.nsis?.include).toBe(include)
|
||||
expect(await Bun.file(include).exists()).toBe(true)
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
|
||||
else process.env.OPENCODE_CHANNEL = previous
|
||||
}
|
||||
})
|
||||
|
||||
test(`uses one Linux desktop identity for ${channel.channel}`, async () => {
|
||||
const previous = process.env.OPENCODE_CHANNEL
|
||||
process.env.OPENCODE_CHANNEL = channel.channel
|
||||
@@ -176,19 +190,8 @@ for (const channel of ["dev", "beta"] as const) {
|
||||
{
|
||||
from: "resources/",
|
||||
to: "",
|
||||
filter: ["opencode-cli*"],
|
||||
filter: ["opencode-cli", "opencode-cli.exe"],
|
||||
},
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
test("does not bundle the CLI in prod builds", async () => {
|
||||
const previous = process.env.OPENCODE_CHANNEL
|
||||
process.env.OPENCODE_CHANNEL = "prod"
|
||||
const module = await import("./electron-builder.config.ts?no-cli-resource=prod")
|
||||
const config = module.default as Configuration
|
||||
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
|
||||
else process.env.OPENCODE_CHANNEL = previous
|
||||
|
||||
expect(config.extraResources).toEqual([])
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { execFile } from "node:child_process"
|
||||
import { stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { promisify } from "node:util"
|
||||
@@ -45,6 +46,7 @@ export function macSignOptions(options: CustomMacSignOptions): CustomMacSignOpti
|
||||
const channel = (() => {
|
||||
const raw = process.env.OPENCODE_CHANNEL
|
||||
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
|
||||
if (raw === "latest") return "prod"
|
||||
return "dev"
|
||||
})()
|
||||
|
||||
@@ -84,16 +86,21 @@ const getBase = (appId: string): Configuration => ({
|
||||
"!**/node_modules/js-yaml/dist/{js-yaml.js,js-yaml.min.js,*.map}",
|
||||
"!**/node_modules/js-yaml/bin{,/**/*}",
|
||||
],
|
||||
extraResources:
|
||||
channel !== "prod"
|
||||
? [
|
||||
{
|
||||
from: "resources/",
|
||||
to: "",
|
||||
filter: ["opencode-cli*"],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
extraResources: [
|
||||
{
|
||||
from: "resources/",
|
||||
to: "",
|
||||
filter: ["opencode-cli", "opencode-cli.exe"],
|
||||
},
|
||||
],
|
||||
afterPack: async (context) => {
|
||||
const cli = path.join(
|
||||
context.packager.getResourcesDir(context.appOutDir),
|
||||
context.electronPlatformName === "win32" ? "opencode-cli.exe" : "opencode-cli",
|
||||
)
|
||||
const file = await stat(cli)
|
||||
if (!file.isFile() || file.size === 0) throw new Error(`Bundled CLI must be a non-empty file: ${cli}`)
|
||||
},
|
||||
mac: {
|
||||
category: "public.app-category.developer-tools",
|
||||
icon: `resources/icons/icon.icns`,
|
||||
@@ -124,6 +131,7 @@ const getBase = (appId: string): Configuration => ({
|
||||
verifyUpdateCodeSignature: false,
|
||||
},
|
||||
nsis: {
|
||||
include: path.join(packageDir, "resources", "windows", "installer.nsh"),
|
||||
oneClick: true,
|
||||
perMachine: false,
|
||||
installerIcon: `resources/icons/icon.ico`,
|
||||
|
||||
@@ -34,7 +34,8 @@ export default defineConfig(({ command }) => ({
|
||||
dedupe: ["effect"],
|
||||
},
|
||||
define: {
|
||||
"import.meta.env.OPENCODE_CHANNEL": JSON.stringify(channel),
|
||||
// Local renderer/server mode still uses the dev application identity and updater policy.
|
||||
"import.meta.env.OPENCODE_CHANNEL": JSON.stringify(channel === "local" ? "dev" : channel),
|
||||
},
|
||||
build: {
|
||||
minify: command === "build",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo -b",
|
||||
"test": "bun test --timeout 30000",
|
||||
"dev": "bun ./scripts/dev.ts",
|
||||
"prebuild": "bun ./scripts/prebuild.ts",
|
||||
"build": "electron-vite build",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
!macro customInstall
|
||||
; Chromium's sandbox needs read/execute access to the installed runtime files.
|
||||
; https://github.com/electron/electron/issues/49143#issuecomment-3618354787
|
||||
nsExec::ExecToLog '"$SYSDIR\icacls.exe" "$INSTDIR" /grant "*S-1-15-2-2:(OI)(CI)(RX)"'
|
||||
Pop $0
|
||||
!macroend
|
||||
@@ -4,9 +4,15 @@ import { $ } from "bun"
|
||||
import { copyBuiltCliToResources, downloadCliToResources, resolveChannel } from "./utils"
|
||||
|
||||
const channel = resolveChannel()
|
||||
if (channel === "prod" && !Bun.env.OPENCODE_CLI_DIST) {
|
||||
throw new Error("OPENCODE_CLI_DIST is required for production desktop builds")
|
||||
}
|
||||
|
||||
await $`bun ./scripts/copy-icons.ts ${channel}`
|
||||
await $`bun ./scripts/copy-metainfo.ts ${channel}`
|
||||
|
||||
if (channel === "dev") await downloadCliToResources()
|
||||
if (channel === "beta" && Bun.env.OPENCODE_CLI_DIST) await copyBuiltCliToResources(Bun.env.OPENCODE_CLI_DIST)
|
||||
if ((channel === "beta" || channel === "prod") && Bun.env.OPENCODE_CLI_DIST) {
|
||||
await copyBuiltCliToResources(Bun.env.OPENCODE_CLI_DIST)
|
||||
}
|
||||
if (channel === "beta" && !Bun.env.OPENCODE_CLI_DIST) await downloadCliToResources("beta")
|
||||
|
||||
@@ -10,6 +10,7 @@ export type Channel = "dev" | "beta" | "prod"
|
||||
export function resolveChannel(): Channel {
|
||||
const raw = Bun.env.OPENCODE_CHANNEL
|
||||
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
|
||||
if (raw === "latest") return "prod"
|
||||
return "dev"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
test.skipIf(process.platform !== "win32")(
|
||||
"installer grants sandbox access without changing unrelated permissions",
|
||||
async () => {
|
||||
const { getMakeNsisPath } = await import("app-builder-lib/out/toolsets/windows")
|
||||
const config = (await import("../electron-builder.config")).default
|
||||
const compiler = await getMakeNsisPath(config.toolsets?.nsis, config.nsis?.customNsisBinary)
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "opencode-installer-"))
|
||||
const run = async (cmd: string[]) => {
|
||||
const result = Bun.spawn(cmd, {
|
||||
env: { ...process.env, ...compiler.env, OPENCODE_INSTALLER_TEST_ROOT: dir },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
new Response(result.stdout).text(),
|
||||
new Response(result.stderr).text(),
|
||||
result.exited,
|
||||
])
|
||||
expect(code, `${stdout}\n${stderr}`).toBe(0)
|
||||
return stdout.trim()
|
||||
}
|
||||
const acl = (file: string) =>
|
||||
run([
|
||||
"pwsh",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
`(Get-Acl -LiteralPath (Join-Path $env:OPENCODE_INSTALLER_TEST_ROOT '${file}')).Sddl`,
|
||||
])
|
||||
|
||||
try {
|
||||
await Bun.write(path.join(dir, "app/runtime/icudtl.dat"), "runtime fixture")
|
||||
await Bun.write(path.join(dir, "unrelated.txt"), "outside the installation")
|
||||
await run(["icacls.exe", path.join(dir, "app"), "/grant", "*S-1-15-2-999-999-999:(OI)(CI)(F)"])
|
||||
const parent = await acl(".")
|
||||
const unrelated = await acl("unrelated.txt")
|
||||
await Bun.write(
|
||||
path.join(dir, "installer.nsi"),
|
||||
`Unicode true
|
||||
RequestExecutionLevel user
|
||||
SilentInstall silent
|
||||
OutFile "${path.join(dir, "installer.exe")}"
|
||||
!include "${config.nsis?.include}"
|
||||
Section
|
||||
StrCpy $INSTDIR "$EXEDIR\\app"
|
||||
!insertmacro customInstall
|
||||
SectionEnd
|
||||
`,
|
||||
)
|
||||
await run([compiler.path, path.join(dir, "installer.nsi")])
|
||||
await run([path.join(dir, "installer.exe")])
|
||||
const installed = await acl("app/runtime/icudtl.dat")
|
||||
expect(installed).toContain("(A;ID;0x1200a9;;;S-1-15-2-2)")
|
||||
expect(installed).toContain("S-1-15-2-999-999-999")
|
||||
await run([path.join(dir, "installer.exe")])
|
||||
expect(await acl("app/runtime/icudtl.dat")).toBe(installed)
|
||||
expect(await acl(".")).toBe(parent)
|
||||
expect(await acl("unrelated.txt")).toBe(unrelated)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user