From dba25658bf172914ae01225785e750d37d2e51ed Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sat, 12 Sep 2026 12:30:39 -0400 Subject: [PATCH] chore: sync overlay to opencode 2c3da73281 (rebuild error panel #1022) Base: upstream/dev 7fe993879f (the merge-base between upstream and local/amicode). Fork: 2c3da73281aa (feat(devtools): structured rebuild error panel). 645 files promoted, 7 deletions. --- packages/app-bundle/manifest.json | 223 +- .../app-bundle/overlay/packages/app/AGENTS.md | 35 - .../performance/timeline-stability/fixture.ts | 566 - .../file-browser-sidebar-tab-switch.spec.ts | 150 - ...session-timeline-locale-projection.spec.ts | 25 - .../session-timeline-transport.spec.ts | 116 - .../subagent-child-navigation.spec.ts | 203 - .../packages/app/e2e/utils/mock-server.ts | 460 - .../packages/app/e2e/utils/sse-transport.ts | 312 - .../src/components/dialog-custom-provider.tsx | 330 - .../src/components/dialog-select-model.tsx | 557 - .../src/components/dialog-select-server.tsx | 722 - .../src/components/dialog-usage-exceeded.tsx | 44 - .../directory-picker-domain.test.ts | 248 - .../src/components/directory-picker-domain.ts | 407 - .../app/src/components/file-tree-v2.tsx | 299 - .../packages/app/src/components/file-tree.tsx | 509 - .../src/components/session/open-in-app-v2.tsx | 98 - .../settings-v2/developer-tools-controller.ts | 36 +- .../settings-v2/developer-tools.tsx | 44 +- .../packages/app/src/constants/file-picker.ts | 89 - .../global-sync/home-session-index.test.ts | 154 - .../context/global-sync/home-session-index.ts | 174 - .../app/src/context/global-sync/utils.test.ts | 133 - .../app/src/context/global-sync/utils.ts | 171 - .../packages/app/src/context/prompt-state.ts | 267 - .../app/src/context/server-session.test.ts | 1641 - .../app/src/context/sync-optimistic.test.ts | 123 - .../overlay/packages/app/src/context/sync.tsx | 119 - .../packages/app/src/desktop-menu.test.ts | 13 - .../app/src/hooks/provider-catalog.test.ts | 59 - .../app/src/hooks/provider-catalog.ts | 27 - .../packages/app/src/hooks/use-providers.ts | 73 - .../packages/app/src/i18n/parity.test.ts | 118 - .../overlay/packages/app/src/index.ts | 30 - .../app/src/pages/home/home-controller.ts | 108 - .../pages/home/home-sessions-controller.tsx | 320 - .../composer/prompt-model-selection.ts | 133 - .../composer/session-followup-dock.tsx | 109 - .../session/v2/session-file-browser-tab.tsx | 181 - .../overlay/packages/app/src/utils/persist.ts | 703 - .../packages/app/src/utils/session-message.ts | 358 - .../packages/app/src/utils/session-title.ts | 7 - .../packages/app/src/utils/session.test.ts | 94 - .../overlay/packages/app/src/utils/session.ts | 37 - .../test-browser/prompt-persistence.test.ts | 119 - .../overlay/packages/core/package.json | 132 - .../overlay/packages/core/src/aisdk.ts | 235 - .../packages/core/src/database/migration.ts | 81 - .../20260410174513_workspace-name.ts | 29 - .../overlay/packages/core/src/plugin/agent.ts | 206 - .../plugin/provider/cloudflare-ai-gateway.ts | 81 - .../core/src/plugin/provider/opencode.ts | 311 - .../src/plugin/skill/customize-opencode.md | 453 - .../overlay/packages/core/src/ripgrep.ts | 281 - .../packages/core/src/session/compaction.ts | 241 - .../packages/core/src/session/projector.ts | 458 - .../packages/core/src/v1/config/provider.ts | 127 - .../core/test/database-migration.test.ts | 644 - .../test/plugin/provider-opencode.test.ts | 423 - .../overlay/packages/core/test/preload.ts | 5 - .../core/test/provider-mistral.test.ts | 282 - .../core/test/provider-xai-responses.test.ts | 32 - .../packages/core/test/ripgrep.test.ts | 65 - .../core/test/session-compaction.test.ts | 27 - .../core/test/session-projector.test.ts | 533 - .../packages/core/test/session-runner.test.ts | 3365 -- .../overlay/packages/sdk/js/package.json | 36 - .../overlay/packages/sdk/openapi.json | 36949 ---------------- .../session-ui/src/components/file.tsx | 1229 - .../src/components/markdown-cache.tsx | 78 - .../src/components/markdown-preload.test.ts | 18 - .../markdown-worker-protocol.test.ts | 81 - .../components/markdown-worker-protocol.ts | 48 - .../src/components/markdown-worker.ts | 122 - .../src/components/message-file.test.ts | 63 - .../session-ui/src/components/message-file.ts | 34 - .../components/tool-count-summary.stories.tsx | 238 - .../src/components/tool-count-summary.tsx | 52 - .../packages/session-ui/src/context/data.tsx | 68 - .../packages/ui/src/amicode/amicode.css | 60 + .../ui/src/components/diff-changes.css | 42 - .../packages/ui/src/components/icon.css | 34 - .../ui/src/components/resize-handle.css | 58 - .../ui/src/components/resize-handle.tsx | 98 - .../ui/src/components/scroll-view.css | 67 - .../packages/ui/src/components/switch.css | 132 - .../packages/ui/src/components/tabs.tsx | 125 - .../packages/ui/src/components/text-field.css | 134 - .../overlay/packages/ui/src/context/i18n.tsx | 38 - .../ui/src/context/marked-code-span.test.ts | 13 - .../ui/src/context/marked-code-span.ts | 17 - .../ui/src/v2/components/button-v2.css | 262 - .../ui/src/v2/components/dialog-v2.tsx | 115 - .../ui/src/v2/components/diff-changes-v2.css | 24 - .../ui/src/v2/components/file-tree-v2.css | 137 - .../ui/src/v2/components/inline-input-v2.tsx | 105 - .../ui/src/v2/components/line-comment-v2.css | 258 - .../ui/src/v2/components/line-comment-v2.tsx | 304 - .../packages/ui/src/v2/components/tabs-v2.css | 224 - .../packages/ui/src/v2/components/tabs-v2.tsx | 147 - .../ui/src/v2/components/text-input-v2.css | 179 - .../ui/src/v2/components/text-input-v2.tsx | 93 - .../ui/src/v2/components/toast-v2.tsx | 277 - 104 files changed, 139 insertions(+), 60575 deletions(-) delete mode 100644 packages/app-bundle/overlay/packages/app/AGENTS.md delete mode 100644 packages/app-bundle/overlay/packages/app/e2e/performance/timeline-stability/fixture.ts delete mode 100644 packages/app-bundle/overlay/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts delete mode 100644 packages/app-bundle/overlay/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts delete mode 100644 packages/app-bundle/overlay/packages/app/e2e/regression/session-timeline-transport.spec.ts delete mode 100644 packages/app-bundle/overlay/packages/app/e2e/regression/subagent-child-navigation.spec.ts delete mode 100644 packages/app-bundle/overlay/packages/app/e2e/utils/mock-server.ts delete mode 100644 packages/app-bundle/overlay/packages/app/e2e/utils/sse-transport.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/components/dialog-custom-provider.tsx delete mode 100644 packages/app-bundle/overlay/packages/app/src/components/dialog-select-model.tsx delete mode 100644 packages/app-bundle/overlay/packages/app/src/components/dialog-select-server.tsx delete mode 100644 packages/app-bundle/overlay/packages/app/src/components/dialog-usage-exceeded.tsx delete mode 100644 packages/app-bundle/overlay/packages/app/src/components/directory-picker-domain.test.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/components/directory-picker-domain.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/components/file-tree-v2.tsx delete mode 100644 packages/app-bundle/overlay/packages/app/src/components/file-tree.tsx delete mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/open-in-app-v2.tsx delete mode 100644 packages/app-bundle/overlay/packages/app/src/constants/file-picker.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/context/global-sync/home-session-index.test.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/context/global-sync/home-session-index.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/context/global-sync/utils.test.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/context/global-sync/utils.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/context/prompt-state.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/context/server-session.test.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/context/sync-optimistic.test.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/context/sync.tsx delete mode 100644 packages/app-bundle/overlay/packages/app/src/desktop-menu.test.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/hooks/provider-catalog.test.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/hooks/provider-catalog.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/hooks/use-providers.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/i18n/parity.test.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/index.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/pages/home/home-controller.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/pages/home/home-sessions-controller.tsx delete mode 100644 packages/app-bundle/overlay/packages/app/src/pages/session/composer/prompt-model-selection.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/pages/session/composer/session-followup-dock.tsx delete mode 100644 packages/app-bundle/overlay/packages/app/src/pages/session/v2/session-file-browser-tab.tsx delete mode 100644 packages/app-bundle/overlay/packages/app/src/utils/persist.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/utils/session-message.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/utils/session-title.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/utils/session.test.ts delete mode 100644 packages/app-bundle/overlay/packages/app/src/utils/session.ts delete mode 100644 packages/app-bundle/overlay/packages/app/test-browser/prompt-persistence.test.ts delete mode 100644 packages/app-bundle/overlay/packages/core/package.json delete mode 100644 packages/app-bundle/overlay/packages/core/src/aisdk.ts delete mode 100644 packages/app-bundle/overlay/packages/core/src/database/migration.ts delete mode 100644 packages/app-bundle/overlay/packages/core/src/database/migration/20260410174513_workspace-name.ts delete mode 100644 packages/app-bundle/overlay/packages/core/src/plugin/agent.ts delete mode 100644 packages/app-bundle/overlay/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts delete mode 100644 packages/app-bundle/overlay/packages/core/src/plugin/provider/opencode.ts delete mode 100644 packages/app-bundle/overlay/packages/core/src/plugin/skill/customize-opencode.md delete mode 100644 packages/app-bundle/overlay/packages/core/src/ripgrep.ts delete mode 100644 packages/app-bundle/overlay/packages/core/src/session/compaction.ts delete mode 100644 packages/app-bundle/overlay/packages/core/src/session/projector.ts delete mode 100644 packages/app-bundle/overlay/packages/core/src/v1/config/provider.ts delete mode 100644 packages/app-bundle/overlay/packages/core/test/database-migration.test.ts delete mode 100644 packages/app-bundle/overlay/packages/core/test/plugin/provider-opencode.test.ts delete mode 100644 packages/app-bundle/overlay/packages/core/test/preload.ts delete mode 100644 packages/app-bundle/overlay/packages/core/test/provider-mistral.test.ts delete mode 100644 packages/app-bundle/overlay/packages/core/test/provider-xai-responses.test.ts delete mode 100644 packages/app-bundle/overlay/packages/core/test/ripgrep.test.ts delete mode 100644 packages/app-bundle/overlay/packages/core/test/session-compaction.test.ts delete mode 100644 packages/app-bundle/overlay/packages/core/test/session-projector.test.ts delete mode 100644 packages/app-bundle/overlay/packages/core/test/session-runner.test.ts delete mode 100644 packages/app-bundle/overlay/packages/sdk/js/package.json delete mode 100644 packages/app-bundle/overlay/packages/sdk/openapi.json delete mode 100644 packages/app-bundle/overlay/packages/session-ui/src/components/file.tsx delete mode 100644 packages/app-bundle/overlay/packages/session-ui/src/components/markdown-cache.tsx delete mode 100644 packages/app-bundle/overlay/packages/session-ui/src/components/markdown-preload.test.ts delete mode 100644 packages/app-bundle/overlay/packages/session-ui/src/components/markdown-worker-protocol.test.ts delete mode 100644 packages/app-bundle/overlay/packages/session-ui/src/components/markdown-worker-protocol.ts delete mode 100644 packages/app-bundle/overlay/packages/session-ui/src/components/markdown-worker.ts delete mode 100644 packages/app-bundle/overlay/packages/session-ui/src/components/message-file.test.ts delete mode 100644 packages/app-bundle/overlay/packages/session-ui/src/components/message-file.ts delete mode 100644 packages/app-bundle/overlay/packages/session-ui/src/components/tool-count-summary.stories.tsx delete mode 100644 packages/app-bundle/overlay/packages/session-ui/src/components/tool-count-summary.tsx delete mode 100644 packages/app-bundle/overlay/packages/session-ui/src/context/data.tsx delete mode 100644 packages/app-bundle/overlay/packages/ui/src/components/diff-changes.css delete mode 100644 packages/app-bundle/overlay/packages/ui/src/components/icon.css delete mode 100644 packages/app-bundle/overlay/packages/ui/src/components/resize-handle.css delete mode 100644 packages/app-bundle/overlay/packages/ui/src/components/resize-handle.tsx delete mode 100644 packages/app-bundle/overlay/packages/ui/src/components/scroll-view.css delete mode 100644 packages/app-bundle/overlay/packages/ui/src/components/switch.css delete mode 100644 packages/app-bundle/overlay/packages/ui/src/components/tabs.tsx delete mode 100644 packages/app-bundle/overlay/packages/ui/src/components/text-field.css delete mode 100644 packages/app-bundle/overlay/packages/ui/src/context/i18n.tsx delete mode 100644 packages/app-bundle/overlay/packages/ui/src/context/marked-code-span.test.ts delete mode 100644 packages/app-bundle/overlay/packages/ui/src/context/marked-code-span.ts delete mode 100644 packages/app-bundle/overlay/packages/ui/src/v2/components/button-v2.css delete mode 100644 packages/app-bundle/overlay/packages/ui/src/v2/components/dialog-v2.tsx delete mode 100644 packages/app-bundle/overlay/packages/ui/src/v2/components/diff-changes-v2.css delete mode 100644 packages/app-bundle/overlay/packages/ui/src/v2/components/file-tree-v2.css delete mode 100644 packages/app-bundle/overlay/packages/ui/src/v2/components/inline-input-v2.tsx delete mode 100644 packages/app-bundle/overlay/packages/ui/src/v2/components/line-comment-v2.css delete mode 100644 packages/app-bundle/overlay/packages/ui/src/v2/components/line-comment-v2.tsx delete mode 100644 packages/app-bundle/overlay/packages/ui/src/v2/components/tabs-v2.css delete mode 100644 packages/app-bundle/overlay/packages/ui/src/v2/components/tabs-v2.tsx delete mode 100644 packages/app-bundle/overlay/packages/ui/src/v2/components/text-input-v2.css delete mode 100644 packages/app-bundle/overlay/packages/ui/src/v2/components/text-input-v2.tsx delete mode 100644 packages/app-bundle/overlay/packages/ui/src/v2/components/toast-v2.tsx diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 7ea37ae5..8e68aeab 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -3,12 +3,11 @@ "scope": "complete fork-vs-base delta of packages/{app,ui,session-ui,schema,core,sdk}", "fork_ref": "local/amicode", "fork_tag": "v1.18.29-amicode.30-11-g0667bccd6d", - "fork_sha": "8f0f5d9097550eb3b3824d9edd76411339786f06", - "upstream_base": "16747470f976aca3d362ad730bcd3fe82ecc2c9a", - "upstream_base_sha": "16747470f976aca3d362ad730bcd3fe82ecc2c9a", - "promoted_at": "2026-09-12T12:58:48.994Z", + "fork_sha": "2c3da73281aaf80290a461d02279bb90859659a0", + "upstream_base": "7fe993879f98", + "upstream_base_sha": "7fe993879f98aa17cecc70f70d3f40d6f0f11689", + "promoted_at": "2026-09-12T16:29:45.041Z", "files": { - "packages/app/AGENTS.md": "6ad84d752791908326d30b96703136918b14a1822f1dd4424a2ebc7ebfa6c78b", "packages/app/CONTEXT.md": "7dc4bf176a32ee578a54593a58a6b97907ffc5a9baf7ae7c16a8fa920d8e6e86", "packages/app/docs/adr/0001-chat-first-landing.md": "d19c42a8aa56f585f92a3e0ffd072c55804ca029c6d19fb7d772c69d7e5de3be", "packages/app/docs/adr/0002-brain-as-chat-background.md": "bda9bc065e16684e1daffdc3f7d8ac5b3c81a82de5c27fa1ba7d6268659c32c1", @@ -16,9 +15,7 @@ "packages/app/docs/adr/0004-context-tree-to-side-panel.md": "271f31a677547be2e11c0c19364ae97b04a2ea353a9ab22c87bde1afab64babc", "packages/app/docs/adr/0005-reconfigurable-titlebar-controls.md": "82c386143ca45f0fd2af3b3e1ffedb47db21e57ac9b7eede661f843eb5655a1b", "packages/app/docs/design/agentic-surfaces.md": "94a117b2790ff0157ee01684fd49dcb32d241b3572e9df77f81f721967dc52bf", - "packages/app/e2e/performance/timeline-stability/fixture.ts": "bc03a107a56ef7f05f671af68913808e81d5d12e0deb12538d900c0c1a6e1b2e", "packages/app/e2e/regression/chrome-surfaces.spec.ts": "c7ff1b82bf84e577fee0e765b0c357c040d4d7cdefdd537702d7dbd186bfedf6", - "packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts": "da6d3a1734c41ef8b0c7cc655358859189d4db97f875c0857d889633248a84bf", "packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts": "b0debddea5051defd859b9c8db2439801592d984fa89ddaf880494722ef2cb21", "packages/app/e2e/regression/new-session-panel-corner.spec.ts": "df11b094012f8206117575c63fd9ac0475d14b54ea4f9d719c6b21aac04d1c4b", "packages/app/e2e/regression/openai-browser-oauth.spec.ts": "7df4521d1d82a186e383afd9351eef64f33f2adf8a3dce86dd5915b5f4828b68", @@ -27,18 +24,13 @@ "packages/app/e2e/regression/project-picker-recent-search.spec.ts": "64f4249f29cfce29d5e755a544c5aa2f27599d9fd874d363628f696f5f0e02b9", "packages/app/e2e/regression/review-line-comment.spec.ts": "953e9c67ed4cee91968369088237cf2288558eaa55e55b61f385571bccad7003", "packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts": "00bfd4f6199c1b350d14a9eda5e3a4c546b020df32e3604609326281869d6d4f", - "packages/app/e2e/regression/session-timeline-locale-projection.spec.ts": "2006241b0e98d4fe0687fd101560f2987c12475013449ceb3220117385375549", "packages/app/e2e/regression/session-timeline-tool-projection.spec.ts": "c0bb866b113d38ddec084e04027030cf5b7c81f1fe4e6ee88fcd7b1b161296c3", - "packages/app/e2e/regression/session-timeline-transport.spec.ts": "9603719737ef511c1c2c45d6cab8b596cefcdd6b0535435886f4928aed1b37eb", - "packages/app/e2e/regression/subagent-child-navigation.spec.ts": "e4f14ff6c67a1c66a0a2bebd48332b138392e0bc81f7daed9cabc77301ceb487", "packages/app/e2e/regression/tab-dblclick-close.spec.ts": "7bf9490c2f39281c7751c39747bb720ec1b5571d7b7ddc803686071aa8ad55aa", "packages/app/e2e/regression/tab-navigate-mousedown.spec.ts": "8521d43183f0df624449edbba8ee4dc646f0ecb84a9c5a04d275613f9da875ae", "packages/app/e2e/regression/workbench-drops.spec.ts": "0188f4f1ff07a40945a4900bfa85e21a4f578be9f18257a803200a18ce41ac05", "packages/app/e2e/regression/workbench-panes.spec.ts": "e0dfb84bd1b8be00a89cf48387aeaab5ec75845e8efbc8e4fa8252204fb27e8f", "packages/app/e2e/smoke/session-timeline.spec.ts": "4006274a5001e36502e16b11ef7b9caee5dfdad2280766380470c72118b7fd90", - "packages/app/e2e/utils/mock-server.ts": "605951544ed8d2c8250806709fae26f52ff90f7106479fcf457b37ba912d9ae0", "packages/app/e2e/utils/pdf-fixture.ts": "86c6c0d11a3c78180133d9e0cd7b87ba4c639f34ccf50a417429e23f80042b87", - "packages/app/e2e/utils/sse-transport.ts": "94ab569367804c19ab5b7fd16955b1d4b189de416e4cca80b0b59ad85294af99", "packages/app/e2e/utils/text-selection.ts": "bb4ada9ee7faa07eb792c5550d244dfef31c089a944d0411a5749eb9a74ba859", "packages/app/e2e/utils/waits.ts": "53959eeae1e6562a2e6c578b261140c74fe9fc30e1e4c46295d3252b6d274a74", "packages/app/index.html": "fa38026be3b7e7ec38958a558d7b4d059140b2aff998c3ac862601543702584d", @@ -67,21 +59,13 @@ "packages/app/src/components/dialog-connect-provider-harmoniqs.test.ts": "7c16914afe3e77ad09691bbfa8223981eda0c2faf3616ed6db2e4e5fa9ce9fdd", "packages/app/src/components/dialog-connect-provider-harmoniqs.ts": "2f070abc5d0274ed1403f963ae0cb0af6e0f635f3732414d757d1b7ba06ea313", "packages/app/src/components/dialog-connect-provider.tsx": "6f23cb5edda411c12606659109c3943f0cd218b9bcba29310f4ea3cda4eea98c", - "packages/app/src/components/dialog-custom-provider.tsx": "49637956da2699fe058ee63df6a8f76c40e86fa3e81b78953245f978526a92ac", "packages/app/src/components/dialog-edit-project-v2.tsx": "f34aaed28604facd9d8a46a4934e2b331421d5f60902a2b160ddcec11af03bd9", "packages/app/src/components/dialog-edit-project.tsx": "5371da3de3a643d4347629af55c0adc2476bffe7b2f061535a92fd67e756e562", "packages/app/src/components/dialog-new-project.tsx": "102fc33e8eeaf3c76ddd1b953b7b97995d30c02a3579f8b7036e47448a1582a8", "packages/app/src/components/dialog-release-notes.tsx": "0ea5b049786787643a7f6b54d4ccf84c4f8ffa4e8272426c0d32f60d445b6890", "packages/app/src/components/dialog-select-directory-v2.css": "2e2f18abdca77f9fe85da157c40f288f3b1641ca92845208749e16455fd58637", "packages/app/src/components/dialog-select-file.tsx": "e6278ee35dd27d203e00ad73f70c709c44a6c06f81ffbd931edfaa0c986b9e03", - "packages/app/src/components/dialog-select-model.tsx": "9c3dd207509be85209ff66190aaf50c90c477baa98fa6cdccc65379255ad4e45", - "packages/app/src/components/dialog-select-server.tsx": "1b9e9b9317c492645dadc1ea3131123aed6fb163411b4458e29d43c19f4cb1cc", - "packages/app/src/components/dialog-usage-exceeded.tsx": "95a419de146a694dbf0ae853185661363def6c90dfa6ba7c8233baac04eb8fb1", - "packages/app/src/components/directory-picker-domain.test.ts": "d3087e7daac0df8070f527fffe129f385e05a29174105538d3d4d43cdcddf99d", - "packages/app/src/components/directory-picker-domain.ts": "a106d1300c9928d21a288ec241e828cab16b73a3fee7bcd3a4dca60879bdb74f", "packages/app/src/components/external-link.tsx": "7130044026ad3ec417de4ba7389cc6059de46f21b370047046269c772aa7a4bb", - "packages/app/src/components/file-tree-v2.tsx": "40ec156e0a2a0a117fda867509cdacbc57e44088d16935c68c13dc0797be62e1", - "packages/app/src/components/file-tree.tsx": "663b14458a79d0ad85437fb9156f729897a404728e8456aefd27d207c8b65629", "packages/app/src/components/help-button.tsx": "780d533f776a784660cfed072a131198bc6fcc8e8fe332e8e1a220d516c9684e", "packages/app/src/components/pane-bridge.tsx": "756b3306b51c963fac6778716f6c6c4626f0f3bf75c93b7cb4cab58ea0bf3d4e", "packages/app/src/components/profile-popover.tsx": "eff742e90544455f13234a9351bb1b82189663cba2cd3172bdcea1a8a37284e8", @@ -103,7 +87,6 @@ "packages/app/src/components/session-context-usage.tsx": "cd25a9af91c73d0cb53000261e066dc16385e1a3ea13e92392fd3a5ca7b33032", "packages/app/src/components/session/context-warning-banner.tsx": "f5fab4d7a9536238b70d64488fc30d44f6a4948fdcffd910a9666f4b6c296019", "packages/app/src/components/session/index.ts": "21473290d4a1a3d0670fd878372ddf21ff22d4a0b30d1aef6c81ea906c1488b4", - "packages/app/src/components/session/open-in-app-v2.tsx": "33029981b0f157e7e5854afadcf698d6014c81248d8188173f49d67efc0f6794", "packages/app/src/components/session/panel-menu.tsx": "42f323046b7375ae158023a51506038eb15f2e1545c407f45acd47446a8ef3e0", "packages/app/src/components/session/pdf-canvas-view.tsx": "89a2c67262d16f262a185bf9c20a44038c6e97abb72c8206d12e720efa4ec6bb", "packages/app/src/components/session/preview-file-view.tsx": "7dad448cf6a166ede82b8908efd028eeb649139b59c7b996780c3f61c6a860ee", @@ -125,12 +108,12 @@ "packages/app/src/components/settings-providers.tsx": "c86c9bf4a6f6e198dc7b0995a69d5b76e851144987bbe601802b1b0120ee4ac4", "packages/app/src/components/settings-v2/data-storage-controller.ts": "fa5d143cc101f3a3b9d5ad445edddc981e0d02783021d52dbfed6c8d8bf62498", "packages/app/src/components/settings-v2/data-storage.tsx": "a56b423b32c952c4379688d88d13a9f43b163a4169b9393f8c35b18a763a1180", - "packages/app/src/components/settings-v2/developer-tools-controller.ts": "f09ed6538b683bd075acd0f7ffb3444a3a3bab606872239ea6ca40377d3d05d2", + "packages/app/src/components/settings-v2/developer-tools-controller.ts": "892426d39057e47192fd4d54c36253d6024253f0901f243ba7b861779376616a", "packages/app/src/components/settings-v2/developer-tools-rebuild-flags.test.ts": "4d11815fe5acfa930bc418d0be82602f03821997300d0deef0aa2da2a743b3b0", "packages/app/src/components/settings-v2/developer-tools-rebuild-flags.ts": "561a75fa456b832d4af6f782c7ecf5269ce768fc2adc59c15a79197a2dde84c0", "packages/app/src/components/settings-v2/developer-tools-request-state.test.ts": "29711b3bc17fddbb8d046e104bc78e775a9ec7c5aa0ee2e181fbcd8b2b5e2135", "packages/app/src/components/settings-v2/developer-tools-request-state.ts": "e9678ed32e7c2562b18143f39a51c0a94dbf0a0884d056653c6c77e4cd07a9bd", - "packages/app/src/components/settings-v2/developer-tools.tsx": "8e41183336f40114d795caf68a4c28a239d984f312f81d15668260ea9557d752", + "packages/app/src/components/settings-v2/developer-tools.tsx": "ee2d586cfa1202f5fc253b502dd8752256166192541e7addab6bb5da18d1d0f5", "packages/app/src/components/settings-v2/dialog-settings-v2.tsx": "a1e1fb7eea7bbad7708b97a721b54a4897c8cb6f582b71d6bb7c8ca7f44930db", "packages/app/src/components/settings-v2/general.tsx": "2abf6c9c7b4b9bf020ed224dbc7fff4574db47711956fc08e1b8e84e781d395a", "packages/app/src/components/settings-v2/permission-scope-relocation.test.ts": "39539c4fc4b7433f08b913100f48a7fa75d6ab47615ba697aa9ae8eee09d316b", @@ -165,20 +148,15 @@ "packages/app/src/components/vault-panel.tsx": "1d9f06f42b94ffb94a28bfd6d2693a36c75ceb969bcd434081b4cd2fbf99665c", "packages/app/src/components/windows-app-menu.tsx": "460669a129e9be61ab2d5f51fe6ecad73579a395295d5f724f235b988cdc667e", "packages/app/src/components/workbench-panel.tsx": "d16f477044098842a214a2e16f9be719e7688a04bcb575889ca544988f23c690", - "packages/app/src/constants/file-picker.ts": "8c08b69c6b504094d3a3920eecc14ceb1e97ec4b8d9d78d6bb38611b4b6e07d9", "packages/app/src/context/command.tsx": "388d6cb12b6dc43ecb0827e3fb918370cb9d16d96161a5990dec10fff05af172", "packages/app/src/context/directory-sync.ts": "a3921044097f3fbcfb20eaec500f24344f12a43e137417c86f15d39dcc6e4b2b", "packages/app/src/context/global-sync/bootstrap.test.ts": "149c0241b378b6ce837aef45e1ba257dbfed88de37c70affa13f513e9c72338d", "packages/app/src/context/global-sync/child-store.ts": "b13a513c93b8fec7b656593d842e9cfa30f346a0911dc0f516783ccc5fa4c07d", "packages/app/src/context/global-sync/event-reducer.test.ts": "600cd7654854b910f25c61b20d8ae69f043f52ed544e6bf9be5ee9e5bbd6c96e", "packages/app/src/context/global-sync/event-reducer.ts": "667fbae1259f6deed77573edd2b742beee4f825b4306b6d861670443e381a55f", - "packages/app/src/context/global-sync/home-session-index.test.ts": "0e794d0426ed9c32ebc33bb15d3b17127f5d14675c358c18bc9244e87007c21f", - "packages/app/src/context/global-sync/home-session-index.ts": "088f31798717ffb47223a55c811528d432d9bee1977d077ff04f3bf803058e45", "packages/app/src/context/global-sync/session-cache.test.ts": "c66d790c6336794b2b1d707e2739fe0992ccad7ed3b96187b124b5c783ef332a", "packages/app/src/context/global-sync/session-cache.ts": "1849408caba0f114809f37153e7d531bf2776572433880a96c5ebd31d7b30aa3", "packages/app/src/context/global-sync/types.ts": "901c9bd1fda63dab676d33dc1a8582a29f8ee00aa20458e843aed5f93414fa84", - "packages/app/src/context/global-sync/utils.test.ts": "baba4eede96ac82cbbd09d24f7e73f05085fb7f4d2d8c88a41cd7c975948ee44", - "packages/app/src/context/global-sync/utils.ts": "5cb514f984bab46b2755df3e4d033f0ded2725c9afcaa8abefbaee6a10cb5e5d", "packages/app/src/context/language.tsx": "cb545e04c128bf981b2b72ac407f9220cf9e80337fe8c87482ffe85407e0cf9a", "packages/app/src/context/layout-side-panel-tabs.test.ts": "47425bbed0ada18d3fcb67a76b63de3b9e1f827cd63a61ffe4eb45430fc5814e", "packages/app/src/context/layout-side-panel-tabs.ts": "e8426fadbdf9e7c49bf457e00ec92793475b44bcc11548e8b5ef9a9fa461be76", @@ -190,28 +168,20 @@ "packages/app/src/context/local.tsx": "3ab8b9fc2db082df4ba485373679f00a95d0ffe3c691a0afee1dc53db7eabd9e", "packages/app/src/context/models.tsx": "8904525767b36ed5ba93e85a0fddb9bf737a8d066dfff084380ce1a67182f535", "packages/app/src/context/platform.tsx": "edd9b1773feb5c084b922a57b6914f6a473eed558b44f9de0eeb1c4d4e2b49da", - "packages/app/src/context/prompt-state.ts": "536399adf6136e78cb3beaa0c7a467c0036d90fb3a19ac1fa934e8dd74973aed", "packages/app/src/context/server-sdk.test.ts": "5cd22962c8ea1e4374915508a765281ceb9f808ad8bbf241c06b00b48621c816", "packages/app/src/context/server-sdk.tsx": "b3450d9bdafde6ed1a95d7dc970fc9f33bef81531b05245c36b789878d09d186", - "packages/app/src/context/server-session.test.ts": "075c7229d437671a9ac01975c3ce8fa8ff0ece5ef70a7039ccecb9a33659bb0f", "packages/app/src/context/server-session.ts": "a9e1f089ad328a705848d11ba25e890c5e374bb3a33066ffe67b6f09bd48ed16", "packages/app/src/context/server-sync.tsx": "43154ffe6227203d778378d190af3c46d7f49098619721fea5e6263e8794dc84", "packages/app/src/context/server.tsx": "08e978e4b4b3094221a2a0b92e069f39cbedb6d6cf95935881e21cde52265ee2", "packages/app/src/context/settings.tsx": "53025fbfbd7eb641cdb0e7c49e21ced3617050f4f98ee45c745affdd8ab17b48", "packages/app/src/context/split.tsx": "891aeb290b369b327a63ce548dcaab1daca08e41fab430648787be7f84e53ec0", - "packages/app/src/context/sync-optimistic.test.ts": "cc9927aeb5cbcdefc0711a83bcd9081ab8d934371f3e2e78e42ee5630ef25f24", - "packages/app/src/context/sync.tsx": "c45886e21012d56d5f89c772993d2918293f16fd1ecc0cd3d865677a9dca990d", "packages/app/src/context/tabs.tsx": "eb494b5785defdd3a4901874be355ba8ddd7bab4c009359b558c413f9ee63ffa", "packages/app/src/context/vault-panel.ts": "f91cb5fb49a9f0d46bd2a1b131f9a218b30147e2ad3617d2a2dc4f1e14599a53", "packages/app/src/context/workbench.tsx": "69a44da2b69989bd2c67ebffdb49941841f3f4718d6a70d659c828b57928d9eb", "packages/app/src/context/zoom-keybind.test.ts": "a1385c93639687a7dac7d21f8f02841d82a55e42465ce3836b97b16c82b52469", "packages/app/src/design-polish.css": "6b860316d2d57a4a9a89c1ff2772de45eca0d0cef2bd3e2ea8572d817e1d1910", - "packages/app/src/desktop-menu.test.ts": "d32e5733527542687dff6cee356ded877b2cfc1bcf523941cddc4a2b2a48b934", "packages/app/src/desktop-menu.ts": "55481604f4fd5dcb74b0678347076c42fda7f928590d90bc2c3d1633bea8bc36", "packages/app/src/entry.tsx": "f35e1017f4c9d478d254b2a38043e5750064b6bef25169c3e07ae9f72ff1049c", - "packages/app/src/hooks/provider-catalog.test.ts": "5890efbad3f11f3e882d12659db0ee8e4ff30137cf9083abf6c569f728ea424f", - "packages/app/src/hooks/provider-catalog.ts": "a82f871bc8633b18b164ee866f8f90d1a113713c2450ce1d4d3fa711857d8e1a", - "packages/app/src/hooks/use-providers.ts": "a1650fadd9dbde8ba94359413b1ffbe2ed228911e284b84f05cbbe9d1ae3e8f8", "packages/app/src/i18n/ar.ts": "82497504fda12f005b8225ee4a0eed0a1741256f6c4ebbb5f27af9832c267ab1", "packages/app/src/i18n/br.ts": "396f9edd949fc8e8de56d3b0d903915245f2ff4b373e052d69e3f7c328a189be", "packages/app/src/i18n/bs.ts": "9b6c8cbac3878719350a4e0c336e26cc32ca8792ffa91631b5d3f685c1c3b965", @@ -223,7 +193,6 @@ "packages/app/src/i18n/ja.ts": "aa5f49228283d01c252f92f2271d526605644b465e58ca63e7f29611387f22f0", "packages/app/src/i18n/ko.ts": "124bfcf65566d9c6e1e8c6c115476d136060c1b893dfd48ca870b33129b10c46", "packages/app/src/i18n/no.ts": "7837b5e039306ccf77fc218c0acef6c2e2d74d7d14cecba7e968c6971a0980ae", - "packages/app/src/i18n/parity.test.ts": "2c8a1d3af6648cbce73c4fee45008c9928d1126a6b044cb0a32dbc566967c161", "packages/app/src/i18n/pl.ts": "204641a31670184fb635ae9c494cad426cf408755d09a226a3f01b85245b99ae", "packages/app/src/i18n/rebuild-label-rename.test.ts": "c9ad20e491a5c9c6b8f6d56858271988e827398afe8b7cbe333e5f970fe76142", "packages/app/src/i18n/ru.ts": "f1298ba64c48ccb20a7fa6502478c7f84543fcdfde3046357c79dda00bcd2885", @@ -233,16 +202,13 @@ "packages/app/src/i18n/zh.ts": "6a5787af0507b7e350a273a5193fae4bc2963c522c72bd7c87bc133e442b5c17", "packages/app/src/i18n/zht.ts": "1b9942df0e71fe98e0c45da8c7e0904d7b8f51ffc4058866d13067f27d794daa", "packages/app/src/index.css": "2c11df3dcebb381358e06626094b5dda3c35688ad7940d643b2cbb6cff1ea9e9", - "packages/app/src/index.ts": "2e2e50c956f444a1306c569845d184c8c1b26127d99f9ed1e65c29493b86353a", "packages/app/src/pages/error.tsx": "db9ba0847cfb205100ad6133cce30c6ea2ce983802af44947e6d2b746c2a6ec5", "packages/app/src/pages/home-projects.test.ts": "a90155c37210a0e5b7a095bbfc3ba2d40681f02927d9726148a706bed5ba7891", "packages/app/src/pages/home-projects.ts": "d55124578dc839b98e2084b488d818e30e4a1abf1ae264499b83e403c2aabca7", "packages/app/src/pages/home-session-archive.test.ts": "e8f1126287a3e9daa455efcc378e5d72aebdd092158bf8526f7779669b72e850", "packages/app/src/pages/home-session-archive.ts": "1b56c173f37f4a4dfd54088ef5fdb0dbcd3d9b62aeb08de4bfa0ed34f07d7ad5", "packages/app/src/pages/home.tsx": "06fb5c7718aacfd29b35e730e05d45076b39d6cd5d936d1270fb1bd9424c32fa", - "packages/app/src/pages/home/home-controller.ts": "90bd2ab5514576e40e9fbd0753fd5a59e55030460da81fc8320061a08603f3f5", "packages/app/src/pages/home/home-projects-view.tsx": "074f1cdd7926db9574164824666cd63f1f5bd0f8e8dfb65453f6fe8f2dca0ec8", - "packages/app/src/pages/home/home-sessions-controller.tsx": "0d67e9748edf296eca7793bc6b95035c54ccf7b8d1182150ae4df1cb216544a9", "packages/app/src/pages/home/home-sessions-view.tsx": "5b73b18e4349a628a15e09a3810ecf2319bde222e18fe8de5d08373ac91fb37c", "packages/app/src/pages/layout-new.tsx": "86e910dcf29536232562ea2652062edcc63be48e7507db85d50d1fd7b0b6ea1d", "packages/app/src/pages/layout.tsx": "a6e6574054f6d85c0d91c1236c9db99674b8d51876643896b0c34f77a28bc85a", @@ -265,7 +231,6 @@ "packages/app/src/pages/session/composer/bug-dock-controller.test.ts": "fe3d68f2f2bb41b04ee5567cd95a9c01671a98fd21585788be9d4cbb01a4bc22", "packages/app/src/pages/session/composer/bug-dock-controller.ts": "ab2c72a761e1992f300b6a0cecc9d0351b00a56e4be08567bcb22d3fcece78f7", "packages/app/src/pages/session/composer/bug-dock.ts": "11eb1a1c911df8764162d47f07e5f135d94fcb622f8a2ad9b41a23233131ba58", - "packages/app/src/pages/session/composer/prompt-model-selection.ts": "ddb191fe43ab8a94a438b728d8d61be2f2c89f431380160b4b46653e06d170d9", "packages/app/src/pages/session/composer/report-bug.test.ts": "d4dc012309c2171298852501a14a077c6fe8cdf5d25538dc84ddd1f8195d3a8c", "packages/app/src/pages/session/composer/report-bug.ts": "bdc3b3dec38b11c7bfa5b867a246db2717e60e1d50692d6bddb3983c9e95e02b", "packages/app/src/pages/session/composer/session-bug-dock.stories.tsx": "a5730bb3699751d63629134924ce8dea021b264722c40f5f6bb98236dc15eda7", @@ -274,7 +239,6 @@ "packages/app/src/pages/session/composer/session-composer-region-controller.ts": "979d2e28d9734581f88e7cb3e0a51c8a8c204f81e644f7a3b5766f60ddf88938", "packages/app/src/pages/session/composer/session-composer-region.tsx": "415cd5c3a77b625a3142b828b6458687e1a35b382d3a208d90ff1555cc7098b1", "packages/app/src/pages/session/composer/session-composer-state.ts": "fcffa11d801309cf58f42a174143bf6eb3ea2b14f860ad91a8ffa4e097df5c16", - "packages/app/src/pages/session/composer/session-followup-dock.tsx": "99f5deb5061d21aa50f989053bc2937cebbe3d0effe7e717bee378077d738feb", "packages/app/src/pages/session/composer/session-question-dock.helpers.test.ts": "a1fe31fe4dfdf2914bc184030a0427bcbdfae0f3e7b146e1d6ee29ce31d85b64", "packages/app/src/pages/session/composer/session-question-dock.helpers.ts": "b16282d0cf5d39152bd76f4ef8bac32f964f68143a3067765ae0827a5f90deed", "packages/app/src/pages/session/composer/session-question-dock.tsx": "44c0814284ef4a5273c8034b8d1f5cd45306406d04e2f4f6646000facae47d7e", @@ -317,7 +281,6 @@ "packages/app/src/pages/session/v2/review-panel-v2-state.test.ts": "ac0cf1939c164948d9a0c54cc15076b30413612d872237e0fbd0912804c1ced0", "packages/app/src/pages/session/v2/review-panel-v2-state.ts": "e27c3aa236d2862e3cbd87128c4e03e6d810974faaaadb12a7e04cdd3b531e1c", "packages/app/src/pages/session/v2/review-panel-v2.tsx": "a3cd899538a738d0236ecbe9e292928ff14e48d3646f2b045dc25880bc7f3ee3", - "packages/app/src/pages/session/v2/session-file-browser-tab.tsx": "7eff48fe5b6d9e9909f93337249f67943801f63b7d36bbb111fcd9ea22364e4c", "packages/app/src/theme-preload.test.ts": "d9e4e96dd39a3491493637682611bedf6ddf4b6e4dbdddcb60bf006211b137a1", "packages/app/src/utils/amicode-bridge.ts": "5104a04c7822f80dc0c564619e7dd2d850ba50035dbc86ea875604ecd97df05f", "packages/app/src/utils/amicode-bug-report.test.ts": "b1bbf49230ff3aa6d1a43ffa9165ba5a84ca4ee18a6d49f52ee5cd887c3e6444", @@ -336,7 +299,6 @@ "packages/app/src/utils/global-clipboard.test.ts": "18ac0ea486d36eed00597f2718f03bffce70a6e6a8f77c79aefe0795790d8771", "packages/app/src/utils/global-clipboard.ts": "b26373289d7402c88e8038575703b11fa5e4ef5a7f50ee05bceb45488022ab26", "packages/app/src/utils/pane-bridge.ts": "a8a8a40dc60a367086f3c4e4c561a289fb85d8b2086a8d62c2b66102de652ce4", - "packages/app/src/utils/persist.ts": "38280282f61c54bc87eec80814e8fccf2a6d7c80214d9036ebb5d6a4f3bbbea5", "packages/app/src/utils/project-type-helpers.test.ts": "602ed23d5719729d1e3b5241a155857775a7a855897a87986964e83fc1b249ec", "packages/app/src/utils/project-type-helpers.ts": "5b9eb86b6fa271186e49d380249f7dce13879106a2e68a503aeaacee2f7d6ba0", "packages/app/src/utils/provider-disconnect.test.ts": "809971964ed28564d379dce2e590bd4727f45eb597e46234c28d25dd2485df26", @@ -344,10 +306,6 @@ "packages/app/src/utils/serialize-session.test.ts": "f8fe318f195dca251eae3f4899f4a5881c3523522f59e02fef208588391f4048", "packages/app/src/utils/serialize-session.ts": "c303c678500cdb64c7aa6928a0bd984c441a36705fe8152368a0e575cbcb459f", "packages/app/src/utils/server-compat.ts": "01413e13b87485770eb3dbec0b882aec4c8247dafc4dc74b7b4669fa5b20cf1a", - "packages/app/src/utils/session-message.ts": "d35bb56438422d3140a3989c5162c6bb5fc13de4b46b2463ea698c076f53c983", - "packages/app/src/utils/session-title.ts": "a37e66190db187057cbfbeabad6f359b96de4315ffe4db1c10ffeb6909e20fcb", - "packages/app/src/utils/session.test.ts": "b1a3c30cabf2177db3909172b6e74e90fb4102a333a6d4b3a1e53ff21b54bb35", - "packages/app/src/utils/session.ts": "96b038da2a430161ca1806967a23e2881e52f1a1f3e847a60931cde8c0fd426f", "packages/app/src/utils/start-prompt.ts": "809d8ad0ab6de76a97503f8368ade25f771431be3f92eb8f039a2c65244ab20f", "packages/app/src/utils/vscode-explorer-icon-theme.test.ts": "351deb0ff77e62d2585858105aaf34ab621298511798fcb626abee97bd644fe6", "packages/app/src/utils/vscode-explorer-icon-theme.ts": "0d700027004f4a08265682e19364bd931313d0658b3d75d7874397b407ade2ab", @@ -360,15 +318,10 @@ "packages/app/src/wsl/settings-model.ts": "35481fe3029714becf30b07024e0f849438c99c369a41b437b5208e48095cbf4", "packages/app/src/wsl/settings.tsx": "1d6bb448f5f7e574ddd2efb2fdb0b6cdafb7a2ca4af9bdd210f2f58da9d9b984", "packages/app/test-browser/keybind-migration.test.ts": "192418880d432ff62d15fd80f6250ece6233e43d0dbe11d5a4fbb5f4d9e45a06", - "packages/app/test-browser/prompt-persistence.test.ts": "703166d8710f36486f351b7825596ebfd03401740ef3ad2843cfb2971beba8ed", - "packages/core/package.json": "646118236d0345509ea65a9577cae6ad1f16cd96a5df536304099c67f8b87732", "packages/core/schema.json": "79ef4e2f0dd8103ac7a4b6ff3ddb0fd10764655f1dc678c1c401dfc43be9a1aa", - "packages/core/src/aisdk.ts": "2df54402689c657fad80094877860ac60ba28bc54fe16b16d04cbf5041706b5d", "packages/core/src/config.ts": "0b6b81bea6a3a09285daa4bd70757e6bbe93cf2508121e1062b71bb24654d692", "packages/core/src/database/database.ts": "9406173aa6cac6acd1c3ec306ae71942e12c8e2d68b7cd3d58a9a094539bc32f", "packages/core/src/database/migration.gen.ts": "689da2c4e571629ba207420c8f31f604c653059ef1b3846683c46d02f1c02c12", - "packages/core/src/database/migration.ts": "33b40a454391ccf9926c9b50d8bc860f2ebf63f82d369475f8c146ad43681045", - "packages/core/src/database/migration/20260410174513_workspace-name.ts": "a2c1c393b33199987d8bc6a91816ba03e2119e725ec6df8390ba1381aa422471", "packages/core/src/database/migration/20260813162312_shocking_karnak.ts": "f4e901f79e3f660b3809e336599a5ba7da7794ff595e6accf6218739c046ab68", "packages/core/src/database/migration/20260820000001_add_session_directories.ts": "bd8ab86f36281b8ccd6d02a6971b81a0a1e877544fac68f969a9a16b559f3f91", "packages/core/src/database/migration/20260828201050_normal_stryfe.ts": "cbf63b27785b76d64d83e4c7fea341278c736985a6e21e4bd6cc816360a84154", @@ -382,15 +335,8 @@ "packages/core/src/permission.ts": "8c46b08adc9edd6bbfb90481b592ea6f00c40743d1ea0ee6a8b2e185dfd2de66", "packages/core/src/permission/provider-saved.ts": "029f02a7120fde4ca1d499294f73f22ca8f0dbaba030a901f94093fb36674025", "packages/core/src/permission/sql.ts": "c8c44e00cf957511282e35cbb192c0be36ed33d41d6b15e98d79eab4378661cd", - "packages/core/src/plugin/agent.ts": "f1017229024856c5f9630047686c88bc2198076bbf81094d77fbd9d10ac88e38", - "packages/core/src/plugin/provider/cloudflare-ai-gateway.ts": "6b17f362563e3e44502acd63a3d786bcd64c9061d80eaeb77aa2b7d22e22bff1", - "packages/core/src/plugin/provider/opencode.ts": "c9203500ef90c9e5ad90d53a36de346f559dd0fa6921fe7377306a9367165564", - "packages/core/src/plugin/skill/customize-opencode.md": "f83e8f42cd3f1422656a9725357b6dca24ee8af4582905d03da97ffb999db11e", "packages/core/src/provider-permission.ts": "a672c1f40fa2efb0cd8923b0b7827b6debe770308bcaef692ad8589ba9b40685", - "packages/core/src/ripgrep.ts": "5841b450f314a9d6a434069e0604289d7e748866b38f785cd7b466d997f2fc27", "packages/core/src/session.ts": "9b0942acb81373bd177ec544eaba74815369c0be55ad35c76a48b7676f2ce315", - "packages/core/src/session/compaction.ts": "9b1ddffb82925b340a55faca2ba0e240fa9a62f5d8a07889c5359bd011d787f2", - "packages/core/src/session/projector.ts": "4048add7aeee140b77358dd39d5877516af3d8ca141f8b4c209a91d723f08bf0", "packages/core/src/session/runner/llm.ts": "e1c9da5f568cab64ebd49a467bff8e11a685306240b3faf672127663f1b02444", "packages/core/src/session/sql.ts": "30267b6bd6f291051942cc1cc870267c073d6866f5dfd984fd7e9777e4c38c0e", "packages/core/src/system-context/builtins.ts": "c5cba75156723c9f8e670c9ab578e95d0be6f81808000d6f0b854eb883a7c1a3", @@ -405,20 +351,10 @@ "packages/core/src/tool/write.ts": "4759d89e4b6cd5fa7d7de4ba091d2bf859d830cb15038828a15751c80fd9b0ec", "packages/core/src/v1/config/config.ts": "6eece12da68ac65a2c8b2fe19b1f2509f397cb63d004dca3998cf6dcc772293e", "packages/core/src/v1/config/migrate.ts": "6c960e1245db9a4bb830f0c1ebd6174e50570b4282d4ce5d7bff3333277abcf4", - "packages/core/src/v1/config/provider.ts": "28763b54e20a4a6a099ad1c02a6edcbcbdf97244d2db5b2bb6a1f7af70f168a4", "packages/core/src/v1/session.ts": "53714b7754a3adecbed4e28d511f5c1ef97ae2fa379e3a07cc68714e1de8469e", - "packages/core/test/database-migration.test.ts": "15b0c3359676e3011847ef0a7d117b1cdf0defcbd7547c3eea57fe346861b1af", "packages/core/test/filesystem/watcher.test.ts": "ade83f34270bc33c0a8cfd91766d5400a29927818f9b7f33e364bc26132ef7d9", "packages/core/test/fixture/location.ts": "8e93d87c20305911dc4583ab1717766b1f70eed3e687dac040ad2c646c25ceea", "packages/core/test/location-mutation.test.ts": "2c4c8faa220cc8fabfe12d57bd74ec8dd8aac8485d0ecafd2761cdee1806d6b0", - "packages/core/test/plugin/provider-opencode.test.ts": "a502b53ae3491bd5eae8536855bac49a18fd783022c74a8984e97daf5ac7fc18", - "packages/core/test/preload.ts": "ae2f3a7a8417ae795c0526c1462148c3e5c13fa8513e9a7241c16639b930f6bd", - "packages/core/test/provider-mistral.test.ts": "d3e7a912b7032a59d3dff0f29de290556a7acd6f4a6e0f234ecb8290983f5da5", - "packages/core/test/provider-xai-responses.test.ts": "9eb18c077de8f7b67246ea81cb87ce00b36158f8602b64e4e3da828d384a0c84", - "packages/core/test/ripgrep.test.ts": "cb4988f14b21387465c7636c26b6d26e9bd40741609da0d34dd9d70729b10952", - "packages/core/test/session-compaction.test.ts": "aebd09239be973a2128a6c167eccf43c84aa6cd52b591cee7e3d45595f70efbc", - "packages/core/test/session-projector.test.ts": "008c9e3d9a02bd0048a87068e8460ca34df0b39b9a1a4bf76166dbe877e5716a", - "packages/core/test/session-runner.test.ts": "a5ae6499bc627ea8deaffaec106019333bc24d51762a06600571b269f91bf55f", "packages/core/test/system-context/builtins.test.ts": "2331a35ab10b3e69f5f5d2dd462ec56000246a747bccec77db783144a91ebbea", "packages/core/test/tool-question.test.ts": "4df41393f15104a1d3a9c3d06e30cef515108490b41fcf4d9a7d9399fd4ed945", "packages/schema/src/index.ts": "007e8bc04cc91c2c0aba3a78212c47c4fed6a8edd0d4be05286667d303448d11", @@ -429,11 +365,9 @@ "packages/schema/src/v1/question.ts": "3ba37742dbd5ef322b7edb42fea0dacf43a1322747dfe997af909bf564bffd35", "packages/schema/src/v1/session.ts": "5804deda9cfcf2a015c759a6905b5bdfac74897239994c7facb246a60dbcf285", "packages/schema/test/question.test.ts": "511f4fabf2841ed89e1fed9594f2e3a1be8eea5bcb50b680d762c49d6ef572d9", - "packages/sdk/js/package.json": "c3c927f5a02508d076c69bbffb60c8f5cf3adb34e402617b1e0f0ecd820f61b1", "packages/sdk/js/src/v2/gen/sdk.gen.ts": "6610638b4fb71bac498da98d232ef9adcd4b011b63e5e81a63fc6dc20d311b17", "packages/sdk/js/src/v2/gen/types.gen.ts": "0c23b262085d1e14b89bd03292a380c9ec8b52e66c4308304a05173362b79339", "packages/sdk/js/test/session-history.test.ts": "f10913af3157694465273d54e05c03cf96a0002df0e34898d9808f92977b11fd", - "packages/sdk/openapi.json": "5bbd6493a1a488ef4294889341c896e420f814ecea95822100aaa9f3f95ab2d1", "packages/session-ui/bunfig.toml": "468114e9b52c3cf926d2385c12d93385992cd70c97ea9e32ed22154617b7799d", "packages/session-ui/happydom.ts": "d81d6b14e988f058fa502c841df34b55307c57fc94a26fb4f8a5deb6aa0d4834", "packages/session-ui/package.json": "15edfe2287a76b544bb359c809da056087bf00e71684a34d22cdcc405a77868a", @@ -443,23 +377,15 @@ "packages/session-ui/src/components/basic-tool.tsx": "ff2678cc17510e1b39657469f6dd1a0311843664cfd5161d385256e617b4e759", "packages/session-ui/src/components/build-trace.test.ts": "6259c529fa667a3514bfee1c771e8f623bcb8c69e37ceb17ea541aa42e207451", "packages/session-ui/src/components/build-trace.ts": "153e3182c3a37e4aca9c88bf0e2052b0ff4ec117a60d74d2c81567ab91180066", - "packages/session-ui/src/components/file.tsx": "350f738bc54d2cc8692073e880e103d1f78e168ab3dedc7e7294e6e9eb998839", "packages/session-ui/src/components/group-parts.test.ts": "a31ce19009e1d62e75e83b682190a4613da0eed334420409ed73a9e21834ae84", "packages/session-ui/src/components/line-comment-styles.ts": "2077836c13bd3c3f1c39c67722119e448a2dfa123377f94bfd3676634e955d00", - "packages/session-ui/src/components/markdown-cache.tsx": "21a145cd97cfb15feda2eb448aa1b22ea492cff0c7f338e099f34107f423a577", "packages/session-ui/src/components/markdown-file-refs.test.ts": "e2b1bed33629d3ab53c521f33266814088ea4122b6e64d6ab294eacc4cf15070", "packages/session-ui/src/components/markdown-file-refs.ts": "b75bddeb565dc0163635fab8f84bdd26723742f9f82d06b6fd5991777b311032", - "packages/session-ui/src/components/markdown-preload.test.ts": "3a2db02aa3c199375ae3a8a240d4505c2c0bd5e84e1ef3a25d19618071a0b810", "packages/session-ui/src/components/markdown-shiki.worker.ts": "2748ddbfcd625bf696b27d9b64895e64ee47a3f316c88930010e260b4ba37f4b", "packages/session-ui/src/components/markdown-stream.test.ts": "b73b1a15ffa2cfea5358b2d6313ce17d26a5a11302b0e3b7d823cde0c98cbc83", "packages/session-ui/src/components/markdown-stream.ts": "50e38abd5d4ff919564655799678be10463dec58502fc5487c1c97a9a8db5399", - "packages/session-ui/src/components/markdown-worker-protocol.test.ts": "fdd7f1a35b208c7c2b57238ce89bc81255d99cde4b8ac1e7f261b7c8feafcd1a", - "packages/session-ui/src/components/markdown-worker-protocol.ts": "de9b2818e115ab854bc6388972facf7fe77fb3d198e373ba10bfbf1cc2c10c52", - "packages/session-ui/src/components/markdown-worker.ts": "9979f3ad12402170f00652dd73539b287b6f300e82c2535c43d04c660b08f978", "packages/session-ui/src/components/markdown.css": "24e7c19218620386647656a5d27e116cdeca911f1916c5d5b8609edca11ff165", "packages/session-ui/src/components/markdown.tsx": "89e132e62d20c873311c9781d0ca4d7cd1a7ff882d99f19c8696aafaf4a8f298", - "packages/session-ui/src/components/message-file.test.ts": "23d322bf59c0771bed99b6080cb86cf67d0a16f0b10d2707101ea740825fdfbe", - "packages/session-ui/src/components/message-file.ts": "78f6ddf5afbe7b392c80af3e9ecddc2924bbaca7b7f106892100fb12ff21474f", "packages/session-ui/src/components/message-part-css.test.ts": "ffab7d2c94e8d40ca35e4a8d4383cc6efd805b133764176b70046fba874a5f06", "packages/session-ui/src/components/message-part-groups.ts": "37be069bf46e926fcde83d3744ee706911ff002e6b532e279290d841aeee96da", "packages/session-ui/src/components/message-part-skill.test.ts": "453bdcb25e4df91ad1004635b8766070a60421355adf18ee53f43cd2fa7d0486", @@ -478,11 +404,8 @@ "packages/session-ui/src/components/session-turn.tsx": "9cbc0d7a35f42e32dad53ace7b3c7d92623fa2d3108edb49e0b7bc868f5c1966", "packages/session-ui/src/components/tool-count-label.test.ts": "c62bbe2c662f6740794d25bd7c33fb3b9b46394a1b41d01d6568a8a2ee2b4e75", "packages/session-ui/src/components/tool-count-label.tsx": "6c0f42d2c3fdc8c63bff3ca5a51aaa6ebcac9dc773f971884cbc2696246502a8", - "packages/session-ui/src/components/tool-count-summary.stories.tsx": "08595ae2df826bca006790716fe75a285911fcc2b0ba39abee78653d2135cb0d", - "packages/session-ui/src/components/tool-count-summary.tsx": "33297eeee30d967836499d7c40884d89fff2bd00ee1ba6e2a3020cf7f2e62f45", "packages/session-ui/src/components/tool-error-card.tsx": "e27b89836b7c709120ac15fbffd2200038944058ea3506e61e5897ffb3537503", "packages/session-ui/src/components/tool-status-title.tsx": "ae4561b58f55985d2294a352802e723b18943ac9a6551d97f1c4a6cc95368c8a", - "packages/session-ui/src/context/data.tsx": "7f7eb7e6d9bc3e2ad3dca7f3e873fade43a1be2ba441950aa210f3254be0f1cb", "packages/session-ui/src/pierre/worker.ts": "7f210f931777956567eaeea15f2f5478befc7874a64a4b685504c59c66503fee", "packages/session-ui/src/util/clipboard.ts": "c37ba53914714ed04135e5a254e8ea90186f097d7470fa0873a83de82f3fef2d", "packages/session-ui/src/v2/components/attachment-card-v2.css": "3b4b9c23d69e76b35488bc3a65fb51211038c3e8aac015df3f531b4e97e96359", @@ -523,7 +446,7 @@ "packages/ui/src/amicode/amico-presence.ts": "b4708117445d3fa5cf9be8c9b2f51bfb1f6b01a76e2fac114cde93107a075e26", "packages/ui/src/amicode/amico-wave.stories.tsx": "fed064c5202bd772308e2c6165c9d63182661fe6ce590c75cac1fd0e9deadf94", "packages/ui/src/amicode/amico-wave.tsx": "478cd8b19a87679016d9dbb0aa494ba7d1f1ef67b3da1d09fb35d28e33de9017", - "packages/ui/src/amicode/amicode.css": "b9771cb1428f1045f22b7068f8f1d9c5f940454cb544c3a98377063f512d6ed5", + "packages/ui/src/amicode/amicode.css": "a7b8e410dad9ee66190cc31d4a4782efa44e8f759d89d701c7525e67002b98c8", "packages/ui/src/amicode/approval-bridge.test.ts": "5b5e5a41c827b7a3cdaf455dbf488cfa7b8d339f46e64f5a35a0ba9226eb0ebf", "packages/ui/src/amicode/approval-bridge.ts": "ceaa6024916794ece1bc80a004b79c36406d2bc512f82e003c25d919c9050e48", "packages/ui/src/amicode/approval-card.tsx": "f55bd13bfded569020ad0ce0d385da3018aee8e833ec573cb45447114658a11b", @@ -668,27 +591,19 @@ "packages/ui/src/components/context-tree-data.tsx": "ca261e7a5eeed09130af14bb755fc2258833d2ac9efd995418cb32927de05d86", "packages/ui/src/components/context-tree-engine.tsx": "92ca28c968cb0c1c346c1693163abd4e604287f875b1d53a434966f1c48cef97", "packages/ui/src/components/dialog.css": "f66f34aa3488b53e2ce1ad19d697a31c3f7cead0307adabaede0d710b17c7401", - "packages/ui/src/components/diff-changes.css": "1bf1d2a698db4e2b6d904f6acd64bad7602897520bc6e51f79397f5b478ee7b9", "packages/ui/src/components/dock-surface.css": "bc8945d4e19eb122078f98e5700e0b542e9e80901782ec927340a048fa0d3be1", "packages/ui/src/components/dropdown-menu.css": "35d1e136a03fb3b59fd5801645de2870c20ecae6810488f69f01595b6abf5a3c", "packages/ui/src/components/favicon.tsx": "809fd1d2621b0e28e69f8ac8092405b89a11fb7a6dbad2938121f7c0f2f5938d", "packages/ui/src/components/hover-card.css": "b6cf61d45ba88cc4252923fb6fb4643659410596eaf3f7456ee5a856dd22dc8f", - "packages/ui/src/components/icon.css": "74cc35995513e788e578f4d02fc3d71cc358703dc35202a0e33a07f1a4b1a85d", "packages/ui/src/components/icon.tsx": "1d25faf5031ce28f12d3bdc4067b51377b5dc538e1b2495cc5d1983097615a04", "packages/ui/src/components/image-preview.css": "fdb49ad0314cc89d95be7d905d9d1d50628e25cd07d536104c8b3d0ef5132beb", "packages/ui/src/components/list.stories.tsx": "ba1302be7924b850a1ba2e8e7f3247fb10fa9a5e5f433164e56cbd1a457ab938", "packages/ui/src/components/logo.css": "f788db461b78bbe33ca2df8d8ff92104fe5c569c7feae124b43c24a5e7e9ab86", "packages/ui/src/components/logo.tsx": "de12f1af573c8bdee867a4c4c2ac40ba0064b5ffb0f2a0c72f89aea6446ebc63", "packages/ui/src/components/popover.css": "cf73a22ed5fb53e6ce235126e0e7e877e611a9da87a0c982df5a8959729b82bc", - "packages/ui/src/components/resize-handle.css": "96c693028dfadef4e1385fe43a533e976f1009ea4cf710467cd516fc5c516f85", "packages/ui/src/components/resize-handle.stories.tsx": "c5624be901cbeb1c4658e65d22dae5659b2b2d4f53c93e619a8ac4d2c6611c55", - "packages/ui/src/components/resize-handle.tsx": "2f930d85321190665252f1322b617c8c0111c442fbd1c2819d946eb87debf90e", - "packages/ui/src/components/scroll-view.css": "fc7a9655f12836d0588252aa2bbb404029bd743806d6565eb995c6a1bd32566d", "packages/ui/src/components/select.css": "487d276d8398b94650505954f99393f8d7d2630510931206d4aee8b35131e5cc", - "packages/ui/src/components/switch.css": "1c236d4886b25feb83ad15e6fdbfcc33dc4d47f109fc0405a465993e0e185216", "packages/ui/src/components/tabs.css": "57d12ea6b52fb499ba39e6e1ae5a325fbc20b9d218b2a88fdd907e5ecf3871a9", - "packages/ui/src/components/tabs.tsx": "6d3bdbcc26807adc152a15e38ff9bcc46a7e423bbf5eb8cd68a7d09032c5db11", - "packages/ui/src/components/text-field.css": "ecc280f4b205cd9f64c930f4cea72d6a46f15d576f975d28bc0cea7b65367fa5", "packages/ui/src/components/text-field.tsx": "5ad1870d4858cc582a0cc7bc0fd1c5332b5e5c96901131639d7bc8f2a38350cf", "packages/ui/src/components/text-reveal.stories.tsx": "393d45b149299dacac37e688dfd8f0bb2eef0294aacb700f50a9416ac6fcaaa9", "packages/ui/src/components/text-strikethrough.stories.tsx": "5117a3b4ed59c18063720f47affcc52f210dcf769934b24ede0ef14a2cbc5c43", @@ -697,9 +612,6 @@ "packages/ui/src/components/toast.css": "60c06eda9f0ccc5641c03c002a6080db99c00d797dbdf7511c789089efc1c546", "packages/ui/src/components/tooltip.css": "a615df9b86e64c1565384baa8054c2f80ebad11856d33f5e6f53902541eacef2", "packages/ui/src/context/dialog.tsx": "876f8a5dc26ebb98f1f5e9e528e7d0bd7aa79c0a107b62027dd9fe78ed2cc620", - "packages/ui/src/context/i18n.tsx": "045f78bcd456f0bd407c7c545992b4ecb24ad47e377a13a2fb310bfa24bfa153", - "packages/ui/src/context/marked-code-span.test.ts": "8ceabe7c81b1cf1291e198748ea26680c3b60c3a38546378070a47b9b14e9708", - "packages/ui/src/context/marked-code-span.ts": "0d6444f6996953b64251abbae67ce6e291dd3b39f5d7cf51d8257d4eafde31b4", "packages/ui/src/context/marked-math.test.ts": "0fe460c09956335a469b382ff631809381875465cbcaf7e7c599f86240b0227c", "packages/ui/src/context/marked.tsx": "b4f693cba3f8020596f860a00293e43855ec75d0bd214e379e36250d02e2be4c", "packages/ui/src/i18n/ar.ts": "8113d320fe4a5300f1bd70150ae1695d68cb783e1c6e2be43b8a429b7dbdb711", @@ -729,27 +641,15 @@ "packages/ui/src/theme/themes/oc-2.json": "539a59886cbee9f2e54362075486d47ac69884ef8a9282f767f7cfd3f1f598a8", "packages/ui/src/util/clipboard.ts": "218d321e1b0567b76514057ef22cfd23cb843fefc36bed7aab91f072e3233ad0", "packages/ui/src/v2/components/badge-v2.css": "32135a9df5bad856a10138e00a2cb3ede7a6fc0cc2398797ad6bed9335d1f845", - "packages/ui/src/v2/components/button-v2.css": "ec92e9e7c3f44fbee0b0e09af994652f01c59962f2e6b1022fc4b393daace27e", "packages/ui/src/v2/components/dialog-v2.css": "ae6f7b1de755929ba529b9dbb0e837c80f620f00a77188b9d2c59f10b0f28bbd", - "packages/ui/src/v2/components/dialog-v2.tsx": "34c91174122280bbe76179710975253f6423e39a763038107cb07445d91de09d", - "packages/ui/src/v2/components/diff-changes-v2.css": "e489462792b765b000a20cf2a9c1c52b91a7fcafb3817a978ec1c4de027a8f3f", - "packages/ui/src/v2/components/file-tree-v2.css": "06d4405c3337ad051f76d63cbf91f960b1e3e8ac6f7bef1474b5aea7d5a52a6f", "packages/ui/src/v2/components/icon-button-v2.tsx": "966e535e3efe503f054045be4476fc20d2fad36092410b52f75e4cf8de6fce6f", "packages/ui/src/v2/components/icon.tsx": "9f702e9c307524f79dfa9cad70f9bea32bda85672f917ffdb0871bb14cf13437", - "packages/ui/src/v2/components/inline-input-v2.tsx": "b1e2fd891dbfa095197dc63fe0a75c4d5d897cf421b9f6b70e5ac00f137d20a8", - "packages/ui/src/v2/components/line-comment-v2.css": "617fa4280e1fd070f7c02a8a78d1096a04799c05d9098330e40d792af5b1cb48", - "packages/ui/src/v2/components/line-comment-v2.tsx": "a7013c62f887eff5f25e01955342860f29da4f4a7f211f1e2ab51d47abf218c5", "packages/ui/src/v2/components/menu-v2.css": "2f262a6c456a8e3cb9027f0b8b3bb3c4327794bd80cadd3cf8c16d08e6125d4d", "packages/ui/src/v2/components/menu-v2.stories.tsx": "4ed1498d7e4cd51ce9c7459061f3540efdfae71751ff618901c78ee413951559", "packages/ui/src/v2/components/radio-v2.css": "bf6eb3457a7fc13e48356f5a7dcf3cbf462382580250a3144a745402ff88e2e6", "packages/ui/src/v2/components/select-v2.css": "4dd4104bee8f661c30ad08ec806e5bddec21d75be6a5d09b693dd297ea10a24c", "packages/ui/src/v2/components/switch-v2.css": "96406f51a412f931c99c005ecd6a889898ad80160143cca0a81cacfa13714f8f", - "packages/ui/src/v2/components/tabs-v2.css": "b657ec564385ddf2af3c0fd28c1043ec9bbb755f72b942da41e8882e2d7be20e", - "packages/ui/src/v2/components/tabs-v2.tsx": "71dd8bb479767f61500b568422d78fbcb319f1e1833dd00ce95b9a0e2526cbf7", - "packages/ui/src/v2/components/text-input-v2.css": "7a31b2872afd00473cdc5ee928df50abbf5076502a348063807eaf1b19bf2b4e", - "packages/ui/src/v2/components/text-input-v2.tsx": "5deee8b8a7826c51d7493969bca961b2231a13c97d79e8b2c720e48ef1755de8", "packages/ui/src/v2/components/toast-v2.css": "9b652d908f39e35cdf1764f1675e4db3dea1d7abf43cb792cfa2d27b699f2723", - "packages/ui/src/v2/components/toast-v2.tsx": "751610ba2e3eefdd321568d103520ea0863560965378b17bd5b0228f8120bfd7", "packages/ui/src/v2/components/tooltip-v2.css": "7cbd2523d9f183b7e24bfbc1f523c370abf8e32836f8be90cfdf75e1c70f05a9", "packages/ui/src/v2/components/wordmark-v2.tsx": "6037a68db862ddd67235cbae8dae5a95c9f7403e7c5f268b78fdaaf013950166", "packages/ui/src/v2/styles/theme.css": "3b6fc14460c39ed8b48c71f7527ab510282c014fc385ed6a08cae3686fd145bc" @@ -758,119 +658,10 @@ "packages/app/public/amico.svg" ], "deletions": [ - "packages/app/e2e/AGENTS.md", - "packages/app/e2e/regression/session-rename.spec.ts", "packages/app/src/components/debug-bar.tsx", - "packages/app/src/i18n/am.ts", - "packages/app/src/i18n/az.ts", - "packages/app/src/i18n/bg.ts", - "packages/app/src/i18n/bn.ts", - "packages/app/src/i18n/ca.ts", - "packages/app/src/i18n/cs.ts", - "packages/app/src/i18n/desktop-native.test.ts", - "packages/app/src/i18n/desktop-native.ts", - "packages/app/src/i18n/dv.ts", - "packages/app/src/i18n/dz.ts", - "packages/app/src/i18n/el.ts", - "packages/app/src/i18n/et.ts", - "packages/app/src/i18n/fa.ts", - "packages/app/src/i18n/fi.ts", - "packages/app/src/i18n/fo.ts", - "packages/app/src/i18n/hi.ts", - "packages/app/src/i18n/hr.ts", - "packages/app/src/i18n/hu.ts", - "packages/app/src/i18n/hy.ts", - "packages/app/src/i18n/id.ts", - "packages/app/src/i18n/is.ts", - "packages/app/src/i18n/it.ts", - "packages/app/src/i18n/ka.ts", - "packages/app/src/i18n/km.ts", - "packages/app/src/i18n/lo.ts", - "packages/app/src/i18n/lt.ts", - "packages/app/src/i18n/lv.ts", - "packages/app/src/i18n/mk.ts", - "packages/app/src/i18n/mn.ts", - "packages/app/src/i18n/ms.ts", - "packages/app/src/i18n/my.ts", - "packages/app/src/i18n/ne.ts", - "packages/app/src/i18n/nl.ts", - "packages/app/src/i18n/pa.ts", - "packages/app/src/i18n/ro.ts", - "packages/app/src/i18n/si.ts", - "packages/app/src/i18n/sk.ts", - "packages/app/src/i18n/sl.ts", - "packages/app/src/i18n/sq.ts", - "packages/app/src/i18n/sr.ts", - "packages/app/src/i18n/sv.ts", - "packages/app/src/i18n/tg.ts", - "packages/app/src/i18n/tk.ts", - "packages/app/src/i18n/ur.ts", - "packages/app/src/i18n/uz.ts", - "packages/app/src/i18n/vi.ts", - "packages/app/src/pages/session/session-archive.ts", - "packages/app/src/utils/session-export.test.ts", - "packages/app/src/utils/session-export.ts", - "packages/core/test/preload.test.ts", - "packages/core/test/provider-groq.test.ts", - "packages/session-ui/AGENTS.md", - "packages/session-ui/src/components/markdown-projection.ts", - "packages/session-ui/src/components/markdown.worker.ts", - "packages/session-ui/src/components/part-default-open.test.ts", - "packages/session-ui/src/components/part-default-open.ts", - "packages/ui/AGENTS.md", "packages/ui/src/components/text-shimmer.css", "packages/ui/src/components/text-shimmer.stories.tsx", "packages/ui/src/components/text-shimmer.tsx", - "packages/ui/src/context/i18n.test.ts", - "packages/ui/src/context/marked-parser.test.ts", - "packages/ui/src/context/marked-parser.tsx", - "packages/ui/src/context/marked-regression.test.ts", - "packages/ui/src/context/marked-theme-register.tsx", - "packages/ui/src/context/marked-theme.tsx", - "packages/ui/src/i18n/am.ts", - "packages/ui/src/i18n/az.ts", - "packages/ui/src/i18n/bg.ts", - "packages/ui/src/i18n/bn.ts", - "packages/ui/src/i18n/ca.ts", - "packages/ui/src/i18n/cs.ts", - "packages/ui/src/i18n/dv.ts", - "packages/ui/src/i18n/dz.ts", - "packages/ui/src/i18n/el.ts", - "packages/ui/src/i18n/et.ts", - "packages/ui/src/i18n/fa.ts", - "packages/ui/src/i18n/fi.ts", - "packages/ui/src/i18n/fo.ts", - "packages/ui/src/i18n/hi.ts", - "packages/ui/src/i18n/hr.ts", - "packages/ui/src/i18n/hu.ts", - "packages/ui/src/i18n/hy.ts", - "packages/ui/src/i18n/id.ts", - "packages/ui/src/i18n/is.ts", - "packages/ui/src/i18n/it.ts", - "packages/ui/src/i18n/ka.ts", - "packages/ui/src/i18n/km.ts", - "packages/ui/src/i18n/lo.ts", - "packages/ui/src/i18n/lt.ts", - "packages/ui/src/i18n/lv.ts", - "packages/ui/src/i18n/mk.ts", - "packages/ui/src/i18n/mn.ts", - "packages/ui/src/i18n/ms.ts", - "packages/ui/src/i18n/my.ts", - "packages/ui/src/i18n/ne.ts", - "packages/ui/src/i18n/nl.ts", - "packages/ui/src/i18n/pa.ts", - "packages/ui/src/i18n/ro.ts", - "packages/ui/src/i18n/si.ts", - "packages/ui/src/i18n/sk.ts", - "packages/ui/src/i18n/sl.ts", - "packages/ui/src/i18n/sq.ts", - "packages/ui/src/i18n/sr.ts", - "packages/ui/src/i18n/sv.ts", - "packages/ui/src/i18n/tg.ts", - "packages/ui/src/i18n/tk.ts", - "packages/ui/src/i18n/ur.ts", - "packages/ui/src/i18n/uz.ts", - "packages/ui/src/i18n/vi.ts", "packages/ui/src/v2/components/text-shimmer-v2.css", "packages/ui/src/v2/components/text-shimmer-v2.stories.tsx", "packages/ui/src/v2/components/text-shimmer-v2.tsx" diff --git a/packages/app-bundle/overlay/packages/app/AGENTS.md b/packages/app-bundle/overlay/packages/app/AGENTS.md deleted file mode 100644 index 2e56066e..00000000 --- a/packages/app-bundle/overlay/packages/app/AGENTS.md +++ /dev/null @@ -1,35 +0,0 @@ -## Priorities - -- Prioritise, in this order: stability, simplicity, performance. -- Before changing session or timeline code, record a production benchmark baseline and compare it after the change. - -## Debugging - -- NEVER try to restart the app, or the server process, EVER. - -## Local Dev - -- `opencode dev web` proxies `https://app.opencode.ai`, so local UI/CSS changes will not show there. -- For local UI changes, run the backend and app dev servers separately. -- Backend (from `packages/opencode`): `bun run --conditions=browser ./src/index.ts serve --port 4096` -- App (from `packages/app`): `bun dev -- --port 4444` -- Open `http://localhost:4444` to verify UI changes (it targets the backend at `http://localhost:4096`). - -## SolidJS - -- Always prefer `createStore` over multiple `createSignal` calls - -## Tool Calling - -- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. - -## Browser Automation - -Use `agent-browser` for web automation. Run `agent-browser --help` for all commands. - -Core workflow: - -1. `agent-browser open ` - Navigate to page -2. `agent-browser snapshot -i` - Get interactive elements with refs (@e1, @e2) -3. `agent-browser click @e1` / `fill @e2 "text"` - Interact using refs -4. Re-snapshot after page changes diff --git a/packages/app-bundle/overlay/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app-bundle/overlay/packages/app/e2e/performance/timeline-stability/fixture.ts deleted file mode 100644 index df67da5a..00000000 --- a/packages/app-bundle/overlay/packages/app/e2e/performance/timeline-stability/fixture.ts +++ /dev/null @@ -1,566 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { Event } from "@opencode-ai/schema/event" -import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event" -import { SessionV1 } from "@opencode-ai/schema/session-v1" -import type { - AssistantMessage, - GlobalEvent, - Message, - Part, - Session, - SessionStatus, - ToolPart, - ToolState, - UserMessage, -} from "@opencode-ai/sdk/v2/client" -import { expect, type Page } from "@playwright/test" -import { Schema } from "effect" -import { mockOpenCodeServer } from "../../utils/mock-server" -import { installSseTransport } from "../../utils/sse-transport" -import { expectSessionTitle } from "../../utils/waits" - -export const directory = "C:/OpenCode/TimelineStability" -export const projectID = "proj_timeline_stability" -export const sessionID = "ses_timeline_stability" -export const userID = "msg_1000_timeline_user" -export const assistantID = "msg_1001_timeline_assistant" -export const title = "Timeline visual stability" -export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } - -type TimelinePayload = Extract< - GlobalEvent["payload"], - { - type: - | "message.updated" - | "message.removed" - | "message.part.updated" - | "message.part.removed" - | "message.part.delta" - | "session.status" - } -> - -type DeepReadonly = Value extends readonly unknown[] - ? { readonly [Key in keyof Value]: DeepReadonly } - : Value extends object - ? { readonly [Key in keyof Value]: DeepReadonly } - : Value - -export type TimelineEvent = DeepReadonly & { payload: TimelinePayload }> -export type EventPayload = TimelineEvent -export type ToolStatus = ToolState["status"] -export type TimelineMessage = { info: UserMessage; parts: Part[] } | { info: AssistantMessage; parts: Part[] } - -type UserPart = Extract -type AssistantPart = Exclude -type OwnedPart = Owner extends "user" ? UserPart : AssistantPart -export type PartSeed = - OwnedPart extends infer Candidate - ? Candidate extends Part - ? Omit - : never - : never - -type ToolOptions = State extends "pending" - ? { output?: never; title?: never; metadata?: never; error?: never } - : State extends "running" - ? { title?: string; metadata?: Record; output?: never; error?: never } - : State extends "error" - ? { error?: string; metadata?: Record; output?: never; title?: never } - : { output?: string; title?: string; metadata?: Record; error?: never } - -const decodeOptions = { errors: "all", onExcessProperty: "error" } as const -const decodeMessage = Schema.decodeUnknownSync(SessionV1.WithParts) -const decodePart = Schema.decodeUnknownSync(SessionV1.Part) -const decodeStatus = Schema.decodeUnknownSync(SessionStatusEvent.Info) -const timelineEventSchema = Schema.Union([ - eventSchema("message.updated", SessionV1.Event.MessageUpdated.data), - eventSchema("message.removed", SessionV1.Event.MessageRemoved.data), - eventSchema("message.part.updated", SessionV1.Event.PartUpdated.data), - eventSchema("message.part.removed", SessionV1.Event.PartRemoved.data), - eventSchema("message.part.delta", SessionV1.Event.PartDelta.data), - eventSchema("session.status", SessionStatusEvent.Status.data), -]) -const decodeEvent = Schema.decodeUnknownSync(timelineEventSchema) -let eventSequence = 0 - -export async function setupTimeline( - page: Page, - input: { - messages?: TimelineMessage[] - settings?: Record - sessions?: Session[] - cpuRate?: number - viewport?: { width: number; height: number } - eventRetry?: number - reducedMotion?: boolean - locale?: string - deviceScaleFactor?: number - seedHistory?: boolean - protocol?: "v1" | "v2" - } = {}, -) { - const sessions = input.sessions ?? [session()] - const messages = validateTimelineMessages([ - ...(input.seedHistory ? historyMessages(18) : []), - ...(input.messages ?? [userMessage(), assistantMessage()]), - ]) - const active = messages.findLast((message) => message.info.role === "assistant") - const initialStatus = decodeStatus( - active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" }, - decodeOptions, - ) - const transport = await installSseTransport(page, { - server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, - retry: input.eventRetry ?? 20, - }) - await mockOpenCodeServer(page, { - protocol: input.protocol, - directory, - project: project(), - provider: provider(), - sessions, - sessionStatus: { [sessionID]: initialStatus }, - pageMessages: () => ({ - items: messages, - }), - }) - await page.addInitScript((settings) => { - localStorage.setItem( - "settings.v3", - JSON.stringify({ - general: { - editToolPartsExpanded: false, - shellToolPartsExpanded: false, - showReasoningSummaries: false, - showSessionProgressBar: true, - ...settings, - }, - }), - ) - if (settings.newLayoutDesigns === false) { - localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" })) - } - }, input.settings ?? {}) - if (input.locale) { - await page.addInitScript((locale) => { - localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale })) - }, input.locale) - } - if (input.reducedMotion) await page.emulateMedia({ reducedMotion: "reduce" }) - await page.setViewportSize(input.viewport ?? { width: 1400, height: 900 }) - if (input.deviceScaleFactor) { - const devtools = await page.context().newCDPSession(page) - const viewport = input.viewport ?? { width: 1400, height: 900 } - await devtools.send("Emulation.setDeviceMetricsOverride", { - width: viewport.width, - height: viewport.height, - deviceScaleFactor: input.deviceScaleFactor, - mobile: false, - }) - } - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await transport.waitForConnection() - await expectSessionTitle(page, title) - if (input.cpuRate && input.cpuRate > 1) { - const devtools = await page.context().newCDPSession(page) - await devtools.send("Emulation.setCPUThrottlingRate", { rate: input.cpuRate }) - } - - return { - transport, - async send(event: TimelineEvent, delay = 0) { - const valid = validateTimelineEvent(event) - await transport.send(valid, { marker: describeEvent(valid) }) - if (delay) await page.waitForTimeout(delay) - }, - async sendAll(sequence: { event: TimelineEvent; delay: number }[]) { - for (const item of sequence) { - const valid = validateTimelineEvent(item.event) - await transport.send(valid, { marker: describeEvent(valid) }) - await page.waitForTimeout(item.delay) - } - }, - async settle(frames = 3) { - await page.evaluate( - (frames) => - new Promise((resolve) => { - let remaining = frames - const tick = () => { - remaining-- - if (remaining <= 0) return resolve() - requestAnimationFrame(tick) - } - requestAnimationFrame(tick) - }), - frames, - ) - }, - async waitForPart(partID: string) { - await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible() - }, - } -} - -function describeEvent(event: EventPayload) { - if (event.payload.type === "message.part.updated") { - const part = event.payload.properties.part - return [ - event.payload.type, - part.id, - part.type === "tool" ? part.tool : part.type, - part.type === "tool" ? part.state.status : undefined, - ] - .filter(Boolean) - .join(":") - } - if (event.payload.type === "session.status") { - const status = event.payload.properties.status - return [event.payload.type, status.type, status.type === "retry" ? status.attempt : undefined] - .filter((value) => value !== undefined) - .join(":") - } - return event.payload.type -} - -export function event( - type: Type, - properties: Extract["properties"], -): TimelineEvent -export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent { - return validateTimelineEvent({ - directory, - payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties }, - }) -} - -export function validateTimelineEvent(input: unknown): TimelineEvent { - return decodeEvent(input, decodeOptions) -} - -export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] { - input.forEach((message) => decodeMessage(message, decodeOptions)) - const messages = [...input] - const messageIDs = new Set() - const partIDs = new Set() - const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id)) - - messages.forEach((message) => { - if (messageIDs.has(message.info.id)) - throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`) - messageIDs.add(message.info.id) - if (message.info.role === "assistant" && !users.has(message.info.parentID)) - throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`) - message.parts.forEach((part) => { - if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id) - throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`) - if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type)) - throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`) - if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type)) - throw new Error(`Timeline assistant message ${message.info.id} cannot own ${part.type} part ${part.id}`) - if (partIDs.has(part.id)) throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`) - partIDs.add(part.id) - }) - }) - return messages -} - -export async function waitForVisualSettle(page: Page, selectors: string[], stableFrames = 3) { - await page.waitForFunction( - ({ selectors, stableFrames }) => { - const elements = selectors.map((selector) => document.querySelector(selector)) - if (elements.some((element) => !element)) return false - return new Promise((resolve) => { - let stable = 0 - let previous = "" - const sample = () => { - const signature = JSON.stringify( - elements.map((element) => { - const rect = element!.getBoundingClientRect() - return [Math.round(rect.top * 10), Math.round(rect.bottom * 10), Math.round(rect.height * 10)] - }), - ) - stable = signature === previous ? stable + 1 : 0 - previous = signature - const ordered = elements - .slice(1) - .every( - (element, index) => - elements[index]!.getBoundingClientRect().bottom <= element!.getBoundingClientRect().top + 0.5, - ) - if (stable >= stableFrames && ordered) return resolve(true) - requestAnimationFrame(sample) - } - requestAnimationFrame(sample) - }) - }, - { selectors, stableFrames }, - ) -} - -export function historyMessages(count: number): TimelineMessage[] { - return Array.from({ length: count }, (_, index) => { - const value = String(index).padStart(4, "0") - const historyUserID = `msg_0${value}_history_a_user` - return [ - userMessage(undefined, { id: historyUserID, created: 1690000000000 + index * 10_000 }), - assistantMessage( - [ - { - id: `prt_0${value}_history_text`, - type: "text", - text: `Historical response ${index}. ${"Existing session content keeps the virtual timeline realistic. ".repeat(5)}`, - }, - ], - { - id: `msg_0${value}_history_b_assistant`, - parentID: historyUserID, - created: 1690000001000 + index * 10_000, - }, - ), - ] - }).flat() -} - -export function partUpdated(part: Part | PartSeed<"assistant">) { - const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID } - decodePart(owned, decodeOptions) - return event("message.part.updated", { - sessionID, - part: owned, - time: 1700000002000, - }) -} - -export function partDelta(partID: string, delta: string, messageID = assistantID) { - return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta }) -} - -export function messageUpdated(info: Message) { - return event("message.updated", { sessionID, info }) -} - -export function status(type: SessionStatus["type"], attempt = 1) { - return event("session.status", { - sessionID, - status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type }, - }) -} - -export function userMessage( - parts?: PartSeed<"user">[], - input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {}, -): Extract { - const id = input.id ?? userID - const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })] - const message = { - info: { - id, - sessionID, - role: "user", - time: { created: input.created ?? 1700000000000 }, - summary: input.summary ?? { diffs: [] }, - agent: "build", - model, - }, - parts: seeds.map((part) => ({ - ...part, - sessionID, - messageID: id, - })), - } satisfies Extract - decodeMessage(message, decodeOptions) - return message -} - -export function assistantMessage( - parts: PartSeed<"assistant">[] = [], - input: { - id?: string - parentID?: string - completed?: boolean - error?: AssistantMessage["error"] - created?: number - } = {}, -): Extract { - const id = input.id ?? assistantID - const message = { - info: { - id, - sessionID, - role: "assistant", - time: { - created: input.created ?? 1700000001000, - ...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }), - }, - parentID: input.parentID ?? userID, - modelID: model.modelID, - providerID: model.providerID, - mode: "build", - agent: "build", - path: { cwd: directory, root: directory }, - cost: 0.01, - tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, - variant: "max", - ...(input.error ? { error: input.error } : {}), - }, - parts: parts.map((part) => ({ ...part, sessionID, messageID: id })), - } satisfies Extract - decodeMessage(message, decodeOptions) - return message -} - -export function userText( - text: string, - input: Partial, { type: "text" }>, "type" | "text">> = {}, -): Extract, { type: "text" }> { - return { id: "prt_user_text", type: "text", text, ...input } -} - -export function textPart(id: string, text: string): Extract, { type: "text" }> { - return { id, type: "text", text } -} - -export function reasoningPart(id: string, text: string): Extract, { type: "reasoning" }> { - return { id, type: "reasoning", text, time: { start: 1700000001000 } } -} - -export function toolPart( - id: string, - tool: string, - state: "pending", - input: Record, - options?: ToolOptions<"pending">, -): Omit -export function toolPart( - id: string, - tool: string, - state: "running", - input: Record, - options?: ToolOptions<"running">, -): Omit -export function toolPart( - id: string, - tool: string, - state: "completed", - input: Record, - options?: ToolOptions<"completed">, -): Omit -export function toolPart( - id: string, - tool: string, - state: "error", - input: Record, - options?: ToolOptions<"error">, -): Omit -export function toolPart( - id: string, - tool: string, - state: ToolStatus, - input: Record, - options: ToolOptions = {}, -): Omit { - const base = { id, type: "tool" as const, callID: `call_${id}`, tool } - if (state === "pending") return { ...base, state: { status: state, input, raw: "" } } - if (state === "running") - return { - ...base, - state: { - status: state, - input, - title: options.title, - metadata: options.metadata ?? {}, - time: { start: 1700000001000 }, - }, - } - if (state === "error") - return { - ...base, - state: { - status: state, - input, - error: options.error ?? "Tool failed", - metadata: options.metadata ?? {}, - time: { start: 1700000001000, end: 1700000002000 }, - }, - } - return { - ...base, - state: { - status: state, - input, - output: options.output ?? "Completed", - title: options.title ?? tool, - metadata: options.metadata ?? {}, - time: { start: 1700000001000, end: 1700000002000 }, - }, - } -} - -export function shell( - id: string, - state: ToolStatus, - output = "", - command = `echo ${id}`, -): Omit { - if (state === "pending") return toolPart(id, "bash", state, { command }) - if (state === "running") - return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } }) - if (state === "error") - return toolPart(id, "bash", state, { command }, { error: output || undefined, metadata: { command, output } }) - return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } }) -} - -export function completedAssistantInfo(info: AssistantMessage): AssistantMessage { - return { ...info, time: { ...info.time, completed: 1700000003000 } } -} - -export function project() { - return { - id: projectID, - worktree: directory, - vcs: "git", - name: "timeline-stability", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - } -} - -export function session(input: Partial = {}): Session { - return { - id: sessionID, - slug: "timeline-stability", - projectID, - directory, - title, - version: "dev", - time: { created: 1700000000000, updated: 1700000000000 }, - ...input, - } -} - -function eventSchema< - const Type extends TimelinePayload["type"], - const Properties extends Schema.Codec, ->(type: Type, properties: Properties) { - return Schema.Struct({ - directory: Schema.String, - project: Schema.optional(Schema.String), - workspace: Schema.optional(Schema.String), - payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }), - }) -} - -function provider() { - return { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "claude-opus-4-6" }, - } -} diff --git a/packages/app-bundle/overlay/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts b/packages/app-bundle/overlay/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts deleted file mode 100644 index 136232c2..00000000 --- a/packages/app-bundle/overlay/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectSessionTitle } from "../utils/waits" - -const directory = "C:/OpenCode/FileBrowserSidebar" -const projectID = "proj_file_browser_sidebar" -const sessionID = "ses_file_browser_sidebar" -const title = "File browser sidebar" -const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` -const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`) -// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable. -const PROBE = "original" - -test.use({ viewport: { width: 1440, height: 900 } }) - -// The file-browser sidebar must stay mounted across preview/pinned file-tab -// switches. Remounting resets scroll and filter state. -test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => { - await setup(page) - - await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) - await expectSessionTitle(page, title) - - const panel = page.locator("#review-panel") - await panel.getByRole("button", { name: "Open file" }).click() - await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") - - const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]') - await expect(sidebar).toBeVisible() - await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible() - - await panel.getByRole("button", { name: "file-00.ts" }).click() - await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "") - await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible() - - const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') - await viewport.hover() - await page.mouse.wheel(0, 100_000) - await expect - .poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) - .toBeLessThanOrEqual(1) - const scrolled = await viewport.evaluate((element) => element.scrollTop) - expect(scrolled).toBeGreaterThan(0) - await writeProbe(page) - - await panel.getByRole("button", { name: "file-79.ts" }).click() - await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "") - await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible() - expect(await readProbe(page)).toBe(PROBE) - await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled) - - await panel.getByRole("button", { name: "file-78.ts" }).dblclick() - await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "") - await panel.getByRole("button", { name: "file-79.ts" }).click() - await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "") - await panel.getByRole("tab", { name: "file-78.ts" }).click() - await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "") - expect(await readProbe(page)).toBe(PROBE) - await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled) -}) - -type Probed = HTMLElement & { __e2eProbe?: string } - -async function writeProbe(page: Page) { - await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => { - ;(el as Probed).__e2eProbe = probe - }, PROBE) -} - -async function readProbe(page: Page) { - return page - .locator('#review-panel [data-component="session-review-v2-sidebar-root"]') - .evaluate((el) => (el as Probed).__e2eProbe) -} - -async function setup(page: Page) { - await mockOpenCodeServer(page, { - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "file-browser-sidebar", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "test" }, - }, - sessions: [ - { - id: sessionID, - slug: sessionID, - projectID, - directory, - title, - version: "dev", - time: { created: 1700000000000, updated: 1700000000000 }, - }, - ], - vcsDiff: [], - fileList: (path) => { - if (path) return [] - return files.map((name) => ({ - name, - path: name, - absolute: `${directory}/${name}`, - type: "file" as const, - ignored: false, - })) - }, - fileContent: (path) => ({ type: "text", content: `contents:${path}` }), - pageMessages: () => ({ items: [] }), - }) - - await page.addInitScript( - ({ directory, server, sessionID }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: [{ worktree: directory, expanded: true }] }, - lastProject: { local: directory }, - }), - ) - localStorage.setItem( - "opencode.global.dat:layout", - JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), - ) - localStorage.setItem( - "opencode.global.dat:review-panel-v2", - JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }), - ) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify([{ type: "session", server, sessionId: sessionID }]), - ) - }, - { directory, server, sessionID }, - ) -} diff --git a/packages/app-bundle/overlay/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts b/packages/app-bundle/overlay/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts deleted file mode 100644 index 3901f886..00000000 --- a/packages/app-bundle/overlay/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { expect, test } from "@playwright/test" -import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" - -for (const profile of [ - { locale: "de", label: "Erkundet" }, - { locale: "ar", label: "تم الاستكشاف" }, -] as const) { - test(`projects translated context status in ${profile.locale}`, async ({ page }) => { - const ids = [`prt_locale_${profile.locale}_01_read`, `prt_locale_${profile.locale}_02_glob`] - await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }), - toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }), - ]), - ], - locale: profile.locale, - }) - - const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`) - await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", profile.label) - await expect(page.locator("html")).toHaveAttribute("lang", profile.locale) - }) -} diff --git a/packages/app-bundle/overlay/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app-bundle/overlay/packages/app/e2e/regression/session-timeline-transport.spec.ts deleted file mode 100644 index 778ff3a3..00000000 --- a/packages/app-bundle/overlay/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - assistantMessage, - partUpdated, - setupTimeline, - status, - textPart, - userMessage, -} from "../performance/timeline-stability/fixture" - -test("keeps one connection open while delivering multiple events", async ({ page }) => { - const timeline = await setupTimeline(page) - - const first = await timeline.transport.send(partUpdated(textPart("prt_transport_first", "first event"))) - const second = await timeline.transport.send(partUpdated(textPart("prt_transport_second", "second event"))) - - await timeline.waitForPart("prt_transport_first") - await timeline.waitForPart("prt_transport_second") - expect(first.connectionID).toBe(second.connectionID) - expect(await timeline.transport.connections()).toHaveLength(1) - expect(await timeline.transport.acknowledgements()).toHaveLength(2) -}) - -test("delivers a burst from one stream chunk", async ({ page }) => { - const timeline = await setupTimeline(page) - const acknowledgements = await timeline.transport.burst([ - partUpdated(textPart("prt_transport_burst_a", "burst a")), - partUpdated(textPart("prt_transport_burst_b", "burst b")), - ]) - - await timeline.waitForPart("prt_transport_burst_a") - await timeline.waitForPart("prt_transport_burst_b") - expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1]) - expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(2) -}) - -test("parses split JSON and a split multibyte code point", async ({ page }) => { - const timeline = await setupTimeline(page) - const payload = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603")) - const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`) - const snowman = new TextEncoder().encode("\u2603")[0]! - const multibyte = encoded.indexOf(snowman) - - const acknowledgement = await timeline.transport.split(payload, [9, multibyte + 1, multibyte + 2]) - - await timeline.waitForPart("prt_transport_split") - await expect(page.locator('[data-timeline-part-id="prt_transport_split"]')).toContainText( - "split snowman \u2603\u2603\u2603", - ) - expect(acknowledgement.chunkCount).toBe(4) -}) - -test("delivers server heartbeat without mutating the timeline", async ({ page }) => { - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])], - }) - const before = await page.locator("[data-timeline-row]").allTextContents() - - await timeline.transport.heartbeat() - await timeline.settle() - - expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before) - expect(await timeline.transport.connections()).toHaveLength(1) -}) - -test("reconnects after a clean close", async ({ page }) => { - const timeline = await setupTimeline(page, { eventRetry: 10 }) - const first = await timeline.transport.waitForConnection() - - await timeline.transport.close() - const second = await timeline.transport.waitForConnection({ after: first.id }) - await timeline.transport.send(partUpdated(textPart("prt_transport_close", "after close"))) - - await timeline.waitForPart("prt_transport_close") - expect(second.id).toBeGreaterThan(first.id) - expect((await timeline.transport.connections())[0]?.endedBy).toBe("close") -}) - -test("reconnects after a stream error", async ({ page }) => { - const timeline = await setupTimeline(page, { eventRetry: 10 }) - const first = await timeline.transport.waitForConnection() - - await timeline.transport.error("contract failure") - const second = await timeline.transport.waitForConnection({ after: first.id }) - await timeline.transport.send(status("busy")) - - await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2) - expect(second.id).toBeGreaterThan(first.id) - expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") -}) - -test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { - const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" }) - const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { - id: "timeline-event-7", - }) - await timeline.waitForPart("prt_transport_id") - - await timeline.transport.error("retry with event id") - const connection = await timeline.transport.waitForConnection({ after: first.connectionID }) - - expect(first.eventID).toBe("timeline-event-7") - expect(connection.headers["last-event-id"]).toBeUndefined() -}) - -test("passes through non-event fetches", async ({ page }) => { - const timeline = await setupTimeline(page) - - const health = await page.evaluate(async () => { - const response = await fetch("/global/health") - return response.json() - }) - - expect(health).toEqual({ healthy: true }) - expect(await timeline.transport.connections()).toHaveLength(1) -}) diff --git a/packages/app-bundle/overlay/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app-bundle/overlay/packages/app/e2e/regression/subagent-child-navigation.spec.ts deleted file mode 100644 index 019cc156..00000000 --- a/packages/app-bundle/overlay/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" -import { currentSession, mockOpenCodeServer } from "../utils/mock-server" -import { expectSessionTitle } from "../utils/waits" - -const directory = "C:/OpenCode/SubagentNavigation" -const projectID = "proj_subagent_navigation" -const parentID = "ses_subagent_parent" -const childID = "ses_subagent_child" -const parentTitle = "Parent session" -const childTitle = "Subagent child session" -// Child session pages derive their heading from the task part that spawned them. -const taskDescription = "Inspect child navigation" - -type EventPayload = { directory: string; payload: Record } - -test.use({ viewport: { width: 1440, height: 900 } }) - -test("navigates to a subagent child session missing from the session list", async ({ page }) => { - await setup(page) - await openChildFromParent(page) - - 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) -}) - -test("shows the not found fallback when the viewed session is deleted", async ({ page }) => { - const events: EventPayload[] = [] - await setup(page, () => events.splice(0, 1)) - await openChildFromParent(page) - await expectSessionTitle(page, taskDescription) - - events.push({ - directory, - payload: { type: "session.deleted", properties: { info: childSession() } }, - }) - - await expect(page.getByText("This session cannot be found")).toBeVisible() - await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible() - await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0) -}) - -async function setup(page: Page, events?: () => EventPayload[]) { - await mockOpenCodeServer(page, { - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "subagent-navigation", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { - "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } }, - }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "claude-opus-4-6" }, - }, - sessions: [session(parentID, parentTitle, 1700000000000), childSession()], - pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }), - events, - eventRetry: events ? 16 : undefined, - }) - // The child session resolves by ID but is absent from the session list, - // matching a subagent session that has not been loaded into the list cache yet. - await page.route( - (url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), - (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ - data: [currentSession(session(parentID, parentTitle, 1700000000000))], - cursor: {}, - }), - }), - ) - await configurePage(page) -} - -async function openChildFromParent(page: Page) { - await page.goto(sessionHref(parentID)) - await expectSessionTitle(page, parentTitle) - - const card = page.locator(`a[href="${sessionHref(childID)}"]`) - await expect(card).toBeVisible() - await card.click() - - await expect(page).toHaveURL(new RegExp(`/server/.+/session/${childID}$`), { timeout: 15_000 }) -} - -function session(id: string, title: string, created: number, extra?: Record) { - return { - id, - slug: id, - projectID, - directory, - title, - version: "dev", - time: { created, updated: created }, - ...extra, - } -} - -function childSession() { - return session(childID, childTitle, 1700000001000, { parentID }) -} - -function parentMessages() { - const userID = "msg_user_0001" - const assistantID = "msg_assistant_0001" - return [ - { - info: { - id: userID, - sessionID: parentID, - role: "user", - time: { created: 1700000000000 }, - agent: "build", - model: { providerID: "opencode", modelID: "claude-opus-4-6" }, - }, - parts: [ - { - id: "prt_user_text_0001", - sessionID: parentID, - messageID: userID, - type: "text", - text: "Delegate work to a subagent", - }, - ], - }, - { - info: { - id: assistantID, - sessionID: parentID, - role: "assistant", - time: { created: 1700000001000, completed: 1700000002000 }, - parentID: userID, - modelID: "claude-opus-4-6", - providerID: "opencode", - mode: "build", - agent: "build", - path: { cwd: directory, root: directory }, - cost: 0.01, - tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, - finish: "stop", - }, - parts: [ - { - id: "prt_tool_task_0001", - sessionID: parentID, - messageID: assistantID, - type: "tool", - callID: "call_task_0001", - tool: "task", - state: { - status: "completed", - input: { description: taskDescription, subagent_type: "explore" }, - output: "Subagent finished", - title: taskDescription, - metadata: { sessionId: childID }, - time: { start: 1700000001000, end: 1700000002000 }, - }, - }, - ], - }, - ] -} - -async function configurePage(page: Page) { - const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` - await page.addInitScript( - ({ directory, server, sessionId }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: [{ worktree: directory, expanded: true }] }, - lastProject: { local: directory }, - }), - ) - localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ type: "session", server, sessionId }])) - }, - { directory, server, sessionId: parentID }, - ) -} - -function sessionHref(sessionID: string) { - const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` - return `/server/${base64Encode(server)}/session/${sessionID}` -} diff --git a/packages/app-bundle/overlay/packages/app/e2e/utils/mock-server.ts b/packages/app-bundle/overlay/packages/app/e2e/utils/mock-server.ts deleted file mode 100644 index 76987421..00000000 --- a/packages/app-bundle/overlay/packages/app/e2e/utils/mock-server.ts +++ /dev/null @@ -1,460 +0,0 @@ -import type { Page, Route } from "@playwright/test" - -const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"]) -const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) - -export interface MockServerConfig { - protocol?: "v1" | "v2" - provider: unknown | (() => unknown) - integrationMethods?: Record - onConnectKey?: (input: { integrationID: string; body: unknown }) => void - onInstanceDispose?: () => void - directory: string - project: unknown - sessions: ({ id: string } & Record)[] - pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string } - vcsDiff?: unknown[] - messageDelay?: number - beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise - onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void - message?: (sessionID: string, messageID: string) => unknown - onMessage?: (input: { sessionID: string; messageID: string }) => void - events?: () => unknown[] - eventRetry?: number - todos?: (sessionID: string) => unknown[] - permissions?: unknown[] | (() => unknown[]) - questions?: unknown[] | (() => unknown[]) - fileList?: (path: string) => unknown | Promise - fileContent?: (path: string) => unknown | Promise - findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown - sessionStatus?: Record | (() => Record) -} - -export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { - const cursors = new Map() - let nextCursor = 0 - const staticRoutes: Record = { - "/path": { - state: config.directory, - config: config.directory, - worktree: config.directory, - directory: config.directory, - home: "C:/OpenCode", - }, - "/project": [config.project], - "/project/current": config.project, - "/agent": [{ name: "build", mode: "primary" }], - "/vcs": { branch: "main", default_branch: "main" }, - "/session": config.sessions, - } - - await page.route("**/*", async (route) => { - const url = new URL(route.request().url()) - const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" - const appPort = new URL( - process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`, - ).port - if (url.port !== targetPort && url.port !== appPort) return route.fallback() - - const path = url.pathname - if (path === "/global/event" || path === "/event" || path === "/api/event") { - const events = config.events?.() - return sse( - route, - path === "/api/event" - ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])] - : [ - ...(path === "/global/event" - ? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }] - : []), - ...(events ?? []), - ], - config.eventRetry, - ) - } - if (path === "/global/health") - return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true }) - if (path === "/api/health" && config.protocol === "v2") - return json(route, { healthy: true, version: "2.0.0", pid: 1 }) - if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true }) - if (path === "/provider") - return json(route, typeof config.provider === "function" ? config.provider() : config.provider) - if (path === "/provider/auth") return json(route, config.integrationMethods ?? {}) - const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1] - if (legacyAuth && route.request().method() === "PUT") { - config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() }) - return json(route, true) - } - if (path === "/instance/dispose" && route.request().method() === "POST") { - config.onInstanceDispose?.() - return json(route, true) - } - if (path === "/permission") - return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) - if (path === "/question") - return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? [])) - if (path === "/session/status") - return json( - route, - typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}), - ) - if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff) - if (path === "/file" && config.fileList) - return json(route, await config.fileList(url.searchParams.get("path") ?? "")) - if (path === "/file/content" && config.fileContent) - return json(route, await config.fileContent(url.searchParams.get("path") ?? "")) - if (path === "/find/file" && config.findFiles) - return json( - route, - await config.findFiles({ - query: url.searchParams.get("query") ?? "", - dirs: url.searchParams.get("dirs") ?? undefined, - limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined, - }), - ) - if (path === "/api/reference") - return json(route, { - location: { - directory: config.directory, - project: { id: (config.project as { id?: string }).id, directory: config.directory }, - }, - data: [], - }) - if (path === "/api/agent") - return json(route, { - location: location(config), - data: [ - { - id: "build", - name: "Build", - mode: "primary", - hidden: false, - request: { settings: {}, headers: {}, body: {} }, - permissions: [], - }, - ], - }) - if (path === "/api/command") return json(route, { location: location(config), data: [] }) - if (path === "/api/mcp") return json(route, { location: location(config), data: [] }) - if (path === "/api/mcp/resource") - return json(route, { location: location(config), data: { resources: [], templates: [] } }) - const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1] - if (integration && route.request().method() === "GET") - return json(route, { - location: location(config), - data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] }, - }) - const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1] - if (integrationConnect && route.request().method() === "POST") { - config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() }) - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if (path === "/api/project") return json(route, [config.project]) - if (path === "/api/project/current") - return json(route, { id: (config.project as { id?: string }).id, directory: config.directory }) - if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project) - if (path === "/api/path") - return json(route, { - state: config.directory, - config: config.directory, - worktree: config.directory, - directory: config.directory, - home: "C:/OpenCode", - }) - if (path === "/api/permission/request") - return json(route, { - location: location(config), - data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map( - currentPermission, - ), - }) - if (path === "/api/question/request") - return json(route, { - location: location(config), - data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []), - }) - if (path === "/api/vcs") - return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } }) - if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] }) - if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] }) - if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] }) - if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path)) - return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }) - if (emptyObject.has(path)) return json(route, {}) - if (emptyList.has(path)) return json(route, []) - if (path === "/api/session") { - const directory = url.searchParams.get("directory") - const parentID = url.searchParams.get("parentID") - const limit = Number(url.searchParams.get("limit") ?? 50) - const offset = Number(url.searchParams.get("cursor") ?? 0) - const sessions = config.sessions - .filter((session) => !directory || session.directory === directory) - .filter((session) => parentID !== "null" || session.parentID === undefined) - .filter((session) => { - const search = url.searchParams.get("search")?.toLowerCase() - return ( - !search || - String(session.title ?? "") - .toLowerCase() - .includes(search) - ) - }) - const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions - const data = ordered.slice(offset, offset + limit) - const next = offset + limit < ordered.length ? String(offset + limit) : undefined - return json(route, { - data: data.map((session) => currentSession(session, config.directory)), - cursor: { next }, - }) - } - if (path === "/api/session/active") { - const statuses = (config.sessionStatus ?? {}) as Record - return json(route, { - data: Object.fromEntries( - Object.entries(statuses).flatMap(([id, status]) => - status.type === "idle" ? [] : [[id, { type: "running" }]], - ), - ), - }) - } - if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { - return json(route, true) - } - if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") { - return json(route, true) - } - if ( - /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) && - route.request().method() === "POST" - ) { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if (path in staticRoutes) return json(route, staticRoutes[path]) - - const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/) - if (currentSessionMatch) { - const session = config.sessions.find((item) => item.id === currentSessionMatch[1]) - if (!session) return json(route, { error: "Session not found" }, undefined, 404) - return json(route, { - data: currentSession(session, config.directory), - }) - } - - const sessionMatch = path.match(/^\/session\/([^/]+)$/) - if (sessionMatch) { - const session = config.sessions.find((s) => s.id === sessionMatch[1]) - return json(route, session ?? {}) - } - - const projectMatch = path.match(/^\/project\/([^/]+)$/) - if (projectMatch) return json(route, config.project) - - const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/) - if (messageMatch) { - config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! }) - if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) - const message = config.message?.(messageMatch[1]!, messageMatch[2]!) - if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404) - return json(route, message) - } - - const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/) - if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) - if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) - - const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/) - if (currentMessagesMatch) { - const token = url.searchParams.get("cursor") ?? undefined - const before = token ? cursors.get(token) : undefined - if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) - config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" }) - await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before }) - if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) - const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before) - config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" }) - const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined - if (cursor) cursors.set(cursor, pageData.cursor!) - return json(route, { - data: pageData.items.map(currentMessage).reverse(), - cursor: { next: cursor }, - }) - } - - const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) - if (messagesMatch) { - const token = url.searchParams.get("before") ?? undefined - const before = token ? cursors.get(token) : undefined - if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) - config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" }) - await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before }) - if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) - const limit = Number(url.searchParams.get("limit") ?? 80) - const pageData = config.pageMessages(messagesMatch[1], limit, before) - config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" }) - if (!pageData.cursor) return json(route, pageData.items) - const cursor = `cursor_${++nextCursor}` - cursors.set(cursor, pageData.cursor) - return json(route, pageData.items, { "x-next-cursor": cursor }) - } - - if (url.port === targetPort && targetPort !== appPort) return json(route, {}) - return route.fallback() - }) -} - -function location(config: MockServerConfig) { - return { - directory: config.directory, - project: { id: (config.project as { id?: string }).id, directory: config.directory }, - } -} - -function currentPermission(value: unknown) { - const permission = value as Record - if (permission.action) return permission - const tool = permission.tool as { messageID?: string; callID?: string } | undefined - return { - id: permission.id, - sessionID: permission.sessionID, - action: permission.permission, - resources: permission.patterns ?? [], - save: permission.always, - metadata: permission.metadata, - source: - tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined, - } -} - -export function currentSession(session: { id: string } & Record, fallbackDirectory?: string) { - const time = session.time && typeof session.time === "object" ? session.time : {} - return { - id: session.id, - parentID: session.parentID, - projectID: session.projectID ?? "project", - agent: session.agent ?? "build", - model: session.model ?? { id: "mock-model", providerID: "mock-provider" }, - cost: session.cost ?? 0, - tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { - created: "created" in time && typeof time.created === "number" ? time.created : 0, - updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0, - ...(session.time && typeof session.time === "object" && "archived" in session.time - ? { archived: session.time.archived } - : {}), - }, - title: session.title ?? session.id, - location: { - directory: typeof session.directory === "string" ? session.directory : fallbackDirectory, - ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}), - }, - subpath: session.path, - revert: session.revert, - } -} - -function currentMessage(value: unknown) { - const item = value as { - info: Record & { id: string; role: "user" | "assistant"; time: { created: number } } - parts: Array & { type: string }> - } - if (item.info.role === "user") { - return { - id: item.info.id, - type: "user", - time: item.info.time, - text: item.parts - .flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : [])) - .join("\n"), - } - } - return { - id: item.info.id, - type: "assistant", - time: item.info.time, - agent: item.info.agent ?? "build", - model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" }, - cost: item.info.cost, - tokens: item.info.tokens, - error: item.info.error, - content: item.parts.flatMap((part) => { - if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }] - if (part.type !== "tool") return [] - const state = part.state as Record - return [ - { - type: "tool", - id: part.id, - name: part.tool, - time: state.time ?? { created: item.info.time.created }, - state: - state.status === "pending" - ? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) } - : state.status === "completed" - ? { - status: "completed", - input: state.input ?? {}, - structured: state.metadata ?? {}, - content: [{ type: "text", text: state.output ?? "" }], - } - : state.status === "error" - ? { - status: "error", - input: state.input ?? {}, - structured: state.metadata ?? {}, - content: [], - error: { type: "ToolError", message: state.error ?? "Tool failed" }, - } - : { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] }, - }, - ] - }), - } -} - -function json(route: Route, body: unknown, headers?: Record, status = 200) { - return route.fulfill({ - status, - contentType: "application/json", - headers: { - "access-control-allow-origin": "*", - "access-control-expose-headers": "x-next-cursor", - ...headers, - }, - body: JSON.stringify(body ?? null), - }) -} - -function sse(route: Route, events?: unknown[], retry?: number) { - return route.fulfill({ - status: 200, - contentType: "text/event-stream", - body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`, - }) -} - -function currentEvent(input: unknown) { - if (!input || typeof input !== "object" || !("payload" in input)) return input - const envelope = input as { directory?: string; payload?: unknown } - if (!envelope.payload || typeof envelope.payload !== "object") return input - const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } - if (!payload.type) return input - return { - id: payload.id ?? `evt_mock_${Date.now()}`, - created: Date.now(), - type: payload.type, - data: payload.properties ?? {}, - location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, - } -} diff --git a/packages/app-bundle/overlay/packages/app/e2e/utils/sse-transport.ts b/packages/app-bundle/overlay/packages/app/e2e/utils/sse-transport.ts deleted file mode 100644 index b0e3b74c..00000000 --- a/packages/app-bundle/overlay/packages/app/e2e/utils/sse-transport.ts +++ /dev/null @@ -1,312 +0,0 @@ -import type { Page } from "@playwright/test" - -export type SseConnectionRecord = { - id: number - url: string - path: "/global/event" | "/event" | "/api/event" - headers: Record - openedAt: number - endedAt?: number - endedBy?: "close" | "disconnect" | "error" | "abort" - error?: string -} - -export type SseDeliveryAcknowledgement = { - deliveryID: number - connectionID: number - bytes: number - chunkCount: number - deliveredAt: number - eventID?: string -} - -export type SseEventOptions = { - id?: string - event?: string - retry?: number - marker?: string -} - -export type SseTransport = { - server: string - waitForConnection(options?: { after?: number; timeout?: number }): Promise - send(payload: T, options?: SseEventOptions): Promise - burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise - split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise - heartbeat(options?: SseEventOptions): Promise - writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise - close(): Promise - disconnect(message?: string): Promise - error(message?: string): Promise - connections(): Promise - acknowledgements(): Promise -} - -type BrowserCommand = - | { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] } - | { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string } - | { type: "end"; mode: "close" | "disconnect" | "error"; message?: string } - | { type: "connections" } - | { type: "acknowledgements" } - -type BrowserTransport = Window & { - __testSseTransport?: { - command: (command: BrowserCommand) => unknown - } -} - -export async function installSseTransport( - page: Page, - options: { server: string; retry?: number }, -): Promise> { - const server = new URL(options.server).origin - await page.addInitScript( - ({ server, retry }) => { - type Connection = SseConnectionRecord & { controller: ReadableStreamDefaultController } - type ProbeWindow = Window & { - __visualStabilityProbe?: { startedAt: number; markers: { at: number; label: string }[] } - } - const originalFetch = window.fetch.bind(window) - const connections: Connection[] = [] - const acknowledgements: SseDeliveryAcknowledgement[] = [] - const encoder = new TextEncoder() - let nextConnectionID = 0 - let nextDeliveryID = 0 - - const current = () => connections.findLast((connection) => connection.endedAt === undefined) - const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => { - const boundaries = [...new Set(cuts ?? [])] - .filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength) - .sort((a, b) => a - b) - return [0, ...boundaries].map((start, index) => bytes.slice(start, boundaries[index] ?? bytes.byteLength)) - } - const marker = (label?: string) => { - if (!label) return - const probe = (window as ProbeWindow).__visualStabilityProbe - if (!probe) return - probe.markers.push({ at: performance.now() - probe.startedAt, label }) - } - const frame = (payload: unknown, eventOptions: SseEventOptions = {}) => - [ - eventOptions.event === undefined ? "" : `event: ${eventOptions.event}\n`, - eventOptions.id === undefined ? "" : `id: ${eventOptions.id}\n`, - eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`, - `data: ${JSON.stringify(payload)}\n\n`, - ].join("") - const currentEvent = (input: unknown) => { - if (!input || typeof input !== "object" || !("payload" in input)) return input - const envelope = input as { directory?: string; payload?: unknown } - if (!envelope.payload || typeof envelope.payload !== "object") return input - const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } - if (!payload.type) return input - return { - id: payload.id ?? `evt_mock_${Date.now()}`, - created: Date.now(), - type: payload.type, - data: payload.properties ?? {}, - location: - envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, - } - } - const acknowledge = ( - connection: Connection, - bytes: number, - chunkCount: number, - eventID?: string, - ): SseDeliveryAcknowledgement => { - const acknowledgement = { - deliveryID: ++nextDeliveryID, - connectionID: connection.id, - bytes, - chunkCount, - deliveredAt: performance.now(), - ...(eventID === undefined ? {} : { eventID }), - } - acknowledgements.push(acknowledgement) - return acknowledgement - } - const end = (mode: "close" | "disconnect" | "error", message?: string) => { - const connection = current() - if (!connection) throw new Error("SSE transport has no active connection") - connection.endedAt = performance.now() - connection.endedBy = mode - if (message) connection.error = message - if (mode === "close") { - connection.controller.close() - return - } - const error = new DOMException( - message ?? "SSE connection disconnected", - mode === "error" ? "Error" : "NetworkError", - ) - connection.controller.error(error) - } - - const command = (input: BrowserCommand) => { - if (input.type === "connections") - return connections.map(({ controller: _controller, ...connection }) => connection) - if (input.type === "acknowledgements") return acknowledgements - if (input.type === "end") return end(input.mode, input.message) - const connection = current() - if (!connection) throw new Error("SSE transport has no active connection") - if (input.type === "raw") { - marker(input.marker) - const output = chunks(new Uint8Array(input.bytes), input.cuts) - output.forEach((chunk) => connection.controller.enqueue(chunk)) - return acknowledge(connection, input.bytes.length, output.length) - } - const encoded = input.deliveries.map((delivery) => { - const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload - return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) } - }) - encoded.forEach((item) => marker(item.delivery.options?.marker)) - if (input.burst) { - const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join("")) - connection.controller.enqueue(bytes) - return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id)) - } - const output = chunks(encoded[0]!.bytes, input.cuts) - output.forEach((chunk) => connection.controller.enqueue(chunk)) - return acknowledge(connection, encoded[0]!.bytes.byteLength, output.length, encoded[0]!.delivery.options?.id) - } - - ;(window as BrowserTransport).__testSseTransport = { command } - const fetch = (input: RequestInfo | URL, init?: RequestInit) => { - const request = new Request(input, init) - const url = new URL(request.url) - if ( - url.origin !== server || - (url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event") - ) - return originalFetch(request) - - const id = ++nextConnectionID - const record = { - id, - url: url.href, - path: url.pathname, - headers: Object.fromEntries(request.headers.entries()), - openedAt: performance.now(), - } as Connection - const stream = new ReadableStream({ - start(controller) { - record.controller = controller - connections.push(record) - if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) - if (url.pathname === "/api/event") - controller.enqueue( - encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), - ) - if (url.pathname === "/global/event") - controller.enqueue( - encoder.encode( - frame({ - payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} }, - }), - ), - ) - request.signal.addEventListener( - "abort", - () => { - if (record.endedAt !== undefined) return - record.endedAt = performance.now() - record.endedBy = "abort" - controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError")) - }, - { once: true }, - ) - }, - cancel() { - if (record.endedAt !== undefined) return - record.endedAt = performance.now() - record.endedBy = "disconnect" - }, - }) - return Promise.resolve( - new Response(stream, { - status: 200, - headers: { - "cache-control": "no-cache", - "content-type": "text/event-stream", - }, - }), - ) - } - Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch }) - }, - { server, retry: options.retry }, - ) - - const command = (input: BrowserCommand) => - page.evaluate((input) => { - const transport = (window as BrowserTransport).__testSseTransport - if (!transport) throw new Error("SSE transport was not installed before page load") - return transport.command(input as BrowserCommand) - }, input) as Promise - - return { - server, - async waitForConnection(input = {}) { - await page.waitForFunction( - (after) => { - const transport = (window as BrowserTransport).__testSseTransport - const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined - return connections?.some((connection) => connection.id > after) - }, - input.after ?? 0, - { timeout: input.timeout }, - ) - return (await command({ type: "connections" })).findLast( - (connection) => connection.id > (input.after ?? 0), - )! - }, - send(payload, eventOptions) { - return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false }) - }, - burst(payloads, eventOptions = []) { - return command({ - type: "send", - deliveries: payloads.map((payload, index) => ({ payload, options: eventOptions[index] })), - burst: true, - }) - }, - split(payload, cuts, eventOptions) { - return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, cuts: [...cuts] }) - }, - heartbeat(eventOptions) { - return command({ - type: "send", - deliveries: [ - { - payload: { directory: "global", payload: { type: "server.heartbeat", properties: {} } } as T, - options: eventOptions, - }, - ], - burst: false, - }) - }, - writeRaw(value, cuts, marker) { - return command({ - type: "raw", - bytes: Array.from(typeof value === "string" ? new TextEncoder().encode(value) : value), - cuts: cuts ? [...cuts] : undefined, - marker, - }) - }, - close() { - return command({ type: "end", mode: "close" }) - }, - disconnect(message) { - return command({ type: "end", mode: "disconnect", message }) - }, - error(message) { - return command({ type: "end", mode: "error", message }) - }, - connections() { - return command({ type: "connections" }) - }, - acknowledgements() { - return command({ type: "acknowledgements" }) - }, - } -} diff --git a/packages/app-bundle/overlay/packages/app/src/components/dialog-custom-provider.tsx b/packages/app-bundle/overlay/packages/app/src/components/dialog-custom-provider.tsx deleted file mode 100644 index e34e4c39..00000000 --- a/packages/app-bundle/overlay/packages/app/src/components/dialog-custom-provider.tsx +++ /dev/null @@ -1,330 +0,0 @@ -import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { Dialog } from "@opencode-ai/ui/dialog" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { useMutation } from "@tanstack/solid-query" -import { TextField } from "@opencode-ai/ui/text-field" -import { showToast } from "@/utils/toast" -import { batch, For } from "solid-js" -import { createStore, produce } from "solid-js/store" -import { ExternalLink } from "@/components/external-link" -import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" -import { useLanguage } from "@/context/language" -import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form" - -type Props = { - onBack: () => void -} - -export function DialogCustomProvider(props: Props) { - const language = useLanguage() - - return ( - - } - transition - > - - - ) -} - -export function CustomProviderForm(props: { autofocus?: boolean } = {}) { - const dialog = useDialog() - const serverSync = useServerSync() - const serverSDK = useServerSDK() - const language = useLanguage() - - const [form, setForm] = createStore({ - providerID: "", - name: "", - baseURL: "", - apiKey: "", - models: [modelRow()], - headers: [headerRow()], - err: {}, - }) - - const addModel = () => { - setForm( - "models", - produce((rows) => { - rows.push(modelRow()) - }), - ) - } - - const removeModel = (index: number) => { - if (form.models.length <= 1) return - setForm( - "models", - produce((rows) => { - rows.splice(index, 1) - }), - ) - } - - const addHeader = () => { - setForm( - "headers", - produce((rows) => { - rows.push(headerRow()) - }), - ) - } - - const removeHeader = (index: number) => { - if (form.headers.length <= 1) return - setForm( - "headers", - produce((rows) => { - rows.splice(index, 1) - }), - ) - } - - const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => { - setForm(key, value) - if (key === "apiKey") return - setForm("err", key, undefined) - } - - const setModel = (index: number, key: "id" | "name", value: string) => { - batch(() => { - setForm("models", index, key, value) - setForm("models", index, "err", key, undefined) - }) - } - - const setHeader = (index: number, key: "key" | "value", value: string) => { - batch(() => { - setForm("headers", index, key, value) - setForm("headers", index, "err", key, undefined) - }) - } - - const validate = () => { - const output = validateCustomProvider({ - form, - t: language.t, - disabledProviders: serverSync().data.config.disabled_providers ?? [], - existingProviderIDs: new Set(serverSync().data.provider.all.keys()), - }) - batch(() => { - setForm("err", output.err) - output.models.forEach((err, index) => setForm("models", index, "err", err)) - output.headers.forEach((err, index) => setForm("headers", index, "err", err)) - }) - return output.result - } - - const saveMutation = useMutation(() => ({ - mutationFn: async (result: NonNullable>) => { - if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server") - const disabledProviders = serverSync().data.config.disabled_providers ?? [] - const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) - - if (result.key) { - await serverSDK().client.auth.set({ - providerID: result.providerID, - auth: { - type: "api", - key: result.key, - }, - }) - } - - await serverSync().updateConfig({ - provider: { [result.providerID]: result.config }, - disabled_providers: nextDisabled, - }) - return result - }, - onSuccess: (result) => { - dialog.close() - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("provider.connect.toast.connected.title", { provider: result.name }), - description: language.t("provider.connect.toast.connected.description", { provider: result.name }), - }) - }, - onError: (err) => { - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) - }, - })) - - const save = (e: SubmitEvent) => { - e.preventDefault() - if (saveMutation.isPending) return - - const result = validate() - if (!result) return - saveMutation.mutate(result) - } - - return ( -
-
- -
{language.t("provider.custom.title")}
-
- -
-

- {language.t("provider.custom.description.prefix")} - - {language.t("provider.custom.description.link")} - - {language.t("provider.custom.description.suffix")} -

- -
- setField("providerID", v)} - validationState={form.err.providerID ? "invalid" : undefined} - error={form.err.providerID} - /> - setField("name", v)} - validationState={form.err.name ? "invalid" : undefined} - error={form.err.name} - /> - setField("baseURL", v)} - validationState={form.err.baseURL ? "invalid" : undefined} - error={form.err.baseURL} - /> - setField("apiKey", v)} - /> -
- -
- - - {(m, i) => ( -
-
- setModel(i(), "id", v)} - validationState={m.err.id ? "invalid" : undefined} - error={m.err.id} - /> -
-
- setModel(i(), "name", v)} - validationState={m.err.name ? "invalid" : undefined} - error={m.err.name} - /> -
- removeModel(i())} - disabled={form.models.length <= 1} - aria-label={language.t("provider.custom.models.remove")} - /> -
- )} -
- -
- -
- - - {(h, i) => ( -
-
- setHeader(i(), "key", v)} - validationState={h.err.key ? "invalid" : undefined} - error={h.err.key} - /> -
-
- setHeader(i(), "value", v)} - validationState={h.err.value ? "invalid" : undefined} - error={h.err.value} - /> -
- removeHeader(i())} - disabled={form.headers.length <= 1} - aria-label={language.t("provider.custom.headers.remove")} - /> -
- )} -
- -
- - -
-
- ) -} diff --git a/packages/app-bundle/overlay/packages/app/src/components/dialog-select-model.tsx b/packages/app-bundle/overlay/packages/app/src/components/dialog-select-model.tsx deleted file mode 100644 index 9066f724..00000000 --- a/packages/app-bundle/overlay/packages/app/src/components/dialog-select-model.tsx +++ /dev/null @@ -1,557 +0,0 @@ -import { Popover as Kobalte } from "@kobalte/core/popover" -import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js" -import { createStore } from "solid-js/store" -import { useLocal } from "@/context/local" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { popularProviders } from "@/hooks/use-providers" -import { Button } from "@opencode-ai/ui/button" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { ScrollView } from "@opencode-ai/ui/scroll-view" -import { Tag } from "@opencode-ai/ui/tag" -import { Dialog } from "@opencode-ai/ui/dialog" -import { List } from "@opencode-ai/ui/list" -import { Tooltip } from "@opencode-ai/ui/tooltip" -import { Icon } from "@opencode-ai/ui/v2/icon" -import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2" -import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { ModelTooltip } from "./model-tooltip" -import { useLanguage } from "@/context/language" -import { decode64 } from "@/utils/base64" -import { handleDocumentSearchKeydown } from "@/utils/search-keydown" -import { createMenuDismissController } from "@/utils/menu-dismiss-controller" -import { createEventListener } from "@solid-primitives/event-listener" -import { matchesModelSearch } from "./dialog-select-model-search" - -const isFree = (provider: string, cost: { input: number } | undefined) => - provider === "opencode" && (!cost || cost.input === 0) - -type ModelState = ReturnType["model"] -type ModelItem = ReturnType[number] - -const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}` -const manageKey = "action:manage" - -const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { category: string; items: ModelItem[] }) => { - const aIndex = popularProviders.indexOf(a.category) - const bIndex = popularProviders.indexOf(b.category) - const aPopular = aIndex >= 0 - const bPopular = bIndex >= 0 - - if (aPopular && !bPopular) return -1 - if (!aPopular && bPopular) return 1 - if (aPopular && bPopular) return aIndex - bIndex - return a.items[0].provider.name.localeCompare(b.items[0].provider.name) -} - -const ModelList: Component<{ - provider?: string - class?: string - onSelect: () => void - action?: JSX.Element - model?: ModelState -}> = (props) => { - const model = props.model ?? useLocal().model - const language = useLanguage() - - const models = createMemo(() => - model - .list() - .filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id })) - .filter((m) => (props.provider ? m.provider.id === props.provider : true)), - ) - - return ( - `${x.provider.id}:${x.id}`} - items={models} - current={model.current()} - filterKeys={["provider.name", "name", "id"]} - sortBy={(a, b) => a.name.localeCompare(b.name)} - groupBy={(x) => x.provider.name} - sortGroupsBy={(a, b) => { - const aProvider = a.items[0].provider.id - const bProvider = b.items[0].provider.id - if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 - if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 - return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) - }} - itemWrapper={(item, node) => ( - } - > - {node} - - )} - onSelect={(x) => { - model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) - props.onSelect() - }} - > - {(i) => ( -
- {i.name} - - {language.t("model.tag.free")} - - - {language.t("model.tag.latest")} - -
- )} -
- ) -} - -type ModelSelectorTriggerProps = Omit, "as" | "ref"> -type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element -type Dismiss = "escape" | "outside" | "select" | "manage" | "provider" - -export function ModelSelectorPopover(props: { - provider?: string - model?: ModelState - trigger: ModelSelectorTrigger - onClose?: (cause: "escape" | "select") => void -}) { - const [store, setStore] = createStore<{ - open: boolean - dismiss: Dismiss | null - }>({ - open: false, - dismiss: null, - }) - const dialog = useDialog() - const local = useLocal() - const directory = () => decode64(local.slug()) - - const close = (dismiss: Dismiss) => { - setStore("dismiss", dismiss) - setStore("open", false) - } - - const handleManage = () => { - close("manage") - void import("./dialog-manage-models").then((x) => { - dialog.show(() => ) - }) - } - - const handleConnectProvider = () => { - close("provider") - void import("./dialog-connect-provider").then((x) => { - void dialog.show(() => ) - }) - } - const language = useLanguage() - - return ( - { - if (next) setStore("dismiss", null) - setStore("open", next) - }} - modal={false} - placement="top-start" - gutter={4} - > - - - { - close("escape") - event.preventDefault() - event.stopPropagation() - }} - onPointerDownOutside={() => close("outside")} - onFocusOutside={() => close("outside")} - onCloseAutoFocus={(event) => { - const dismiss = store.dismiss - if (dismiss === "outside") event.preventDefault() - if (dismiss === "escape" || dismiss === "select") { - event.preventDefault() - props.onClose?.(dismiss) - } - setStore("dismiss", null) - }} - > - {language.t("dialog.model.select.title")} - close("select")} - class="p-1" - action={ -
- - - - - - -
- } - /> -
-
-
- ) -} - -export function ModelSelectorPopoverV2(props: { - provider?: string - model?: ModelState - trigger: ModelSelectorTrigger - onClose?: () => void -}) { - const dialog = useDialog() - const controller = createModelSelectorController({ - model: props.model, - provider: () => props.provider, - onSelect: () => props.onClose?.(), - }) - - return ( - { - void import("./dialog-manage-models").then((module) => { - void dialog.show(() => ) - }) - }} - onClose={() => props.onClose?.()} - /> - ) -} - -function createModelSelectorController(input: { - provider: () => string | undefined - model?: ModelState - onSelect: () => void -}) { - const model = input.model ?? useLocal().model - const allModels = createMemo(() => - model - .list() - .filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id })) - .filter((item) => (input.provider() ? item.provider.id === input.provider() : true)), - ) - - return { - models: (search: string) => { - const query = search.trim() - const filtered = query - ? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name])) - : allModels() - return [...filtered].sort((a, b) => a.name.localeCompare(b.name)) - }, - groups: (models: ModelItem[]) => { - const byProvider = new Map() - for (const item of models) { - byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item]) - } - return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups) - }, - current: () => { - const value = model.current() - return value ? modelKey(value) : undefined - }, - select: (item: ModelItem) => { - model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true }) - input.onSelect() - }, - } -} - -function ModelSelectorPopoverV2View(props: { - trigger: ModelSelectorTrigger - models: (search: string) => ModelItem[] - groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[] - current: () => string | undefined - select: (item: ModelItem) => void - onManage: () => void - onClose: () => void -}) { - const language = useLanguage() - const [store, setStore] = createStore({ open: false, search: "", active: "" }) - let searchRef: HTMLInputElement | undefined - let contentRef: HTMLDivElement | undefined - const dismiss = createMenuDismissController(() => contentRef) - - const models = createMemo(() => props.models(store.search)) - const groups = createMemo(() => props.groups(models())) - const keys = () => [...models().map(modelKey), manageKey] - const initialActive = () => { - const selected = props.current() - const options = keys() - if (selected && options.includes(selected)) return selected - return options[0] ?? "" - } - const activeItem = () => - store.active ? contentRef?.querySelector(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined - const setOpen = (open: boolean) => { - if (open) { - dismiss.allowTriggerRestore() - setStore({ open: true, active: initialActive() }) - setTimeout(() => - requestAnimationFrame(() => { - searchRef?.focus() - activeItem()?.scrollIntoView({ block: "nearest" }) - }), - ) - return - } - setStore({ open: false, search: "", active: "" }) - } - const selectModel = (item: ModelItem) => { - dismiss.preventTriggerRestore() - setOpen(false) - dismiss.afterClose(() => props.select(item)) - } - const manage = () => { - dismiss.preventTriggerRestore() - setOpen(false) - dismiss.afterClose(props.onManage) - } - const selectActive = () => { - const item = models().find((item) => modelKey(item) === store.active) - if (item) { - selectModel(item) - return - } - if (store.active === manageKey) manage() - } - const moveActive = (delta: number) => { - const options = keys() - if (options.length === 0) return - const index = options.indexOf(store.active) - const start = index === -1 ? 0 : index - setStore("active", options[(start + delta + options.length) % options.length]) - queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" })) - } - const setSearch = (value: string) => { - const first = props.models(value)[0] - setStore({ search: value, active: first ? modelKey(first) : manageKey }) - } - - createEffect(() => { - if (!store.open) return - createEventListener( - document, - "keydown", - (event: KeyboardEvent) => handleDocumentSearchKeydown(searchRef, event, store.search, setSearch), - true, - ) - }) - - return ( - - - - (contentRef = element)} - class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none" - onPointerDownOutside={dismiss.preventTriggerRestore} - onFocusOutside={dismiss.preventTriggerRestore} - onCloseAutoFocus={dismiss.onCloseAutoFocus} - > -
-
- - (searchRef = el)} - value={store.search} - placeholder={language.t("dialog.model.search.placeholder")} - class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - onInput={(event) => setSearch(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Tab") return - event.stopPropagation() - if (event.key === "Escape") { - event.preventDefault() - dismiss.preventTriggerRestore() - setOpen(false) - dismiss.afterClose(props.onClose) - return - } - if (event.altKey || event.metaKey) return - if (event.key === "ArrowDown") { - event.preventDefault() - moveActive(1) - return - } - if (event.key === "ArrowUp") { - event.preventDefault() - moveActive(-1) - return - } - if (event.key === "Enter" && !event.isComposing) { - event.preventDefault() - selectActive() - } - }} - /> - - - -
-
-
- -
- 0} - fallback={ -
- {language.t("dialog.model.empty")} -
- } - > - - {(group) => ( - - - {group.items[0].provider.name} - - - - {(item) => ( - - } - > - { - setStore("active", modelKey(item)) - setTimeout(() => searchRef?.focus()) - }} - onSelect={() => selectModel(item)} - > - {item.name} - - {language.t("model.tag.free")} - - - {language.t("model.tag.latest")} - - - - )} - - - - )} - -
-
-
-
-
- { - setStore("active", manageKey) - setTimeout(() => searchRef?.focus()) - }} - onSelect={manage} - > - - {language.t("dialog.model.manage")} - -
- - - - ) -} - -export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => { - const dialog = useDialog() - const language = useLanguage() - const local = useLocal() - const directory = () => decode64(local.slug()) - - const provider = () => { - void import("./dialog-connect-provider").then((x) => { - void dialog.show(() => ) - }) - } - - const manage = () => { - void import("./dialog-manage-models").then((x) => { - dialog.show(() => ) - }) - } - - return ( - - {language.t("command.provider.connect")} - - } - > - dialog.close()} /> - - - ) -} diff --git a/packages/app-bundle/overlay/packages/app/src/components/dialog-select-server.tsx b/packages/app-bundle/overlay/packages/app/src/components/dialog-select-server.tsx deleted file mode 100644 index aa169762..00000000 --- a/packages/app-bundle/overlay/packages/app/src/components/dialog-select-server.tsx +++ /dev/null @@ -1,722 +0,0 @@ -import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { Dialog } from "@opencode-ai/ui/dialog" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Icon } from "@opencode-ai/ui/icon" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { List } from "@opencode-ai/ui/list" -import { TextField } from "@opencode-ai/ui/text-field" -import { useMutation } from "@tanstack/solid-query" -import { showToast } from "@/utils/toast" -import { useNavigate } from "@solidjs/router" -import { createEffect, createMemo, createResource, Show } from "solid-js" -import { createStore } from "solid-js/store" -import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" -import { useGlobal } from "@/context/global" -import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" -import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" -import { detectServerProtocol } from "@/utils/server-protocol" -import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" -import { useSettings } from "@/context/settings" -import { useTabs } from "@/context/tabs" - -const DEFAULT_USERNAME = "opencode" - -interface ServerFormProps { - value: string - name: string - username: string - password: string - placeholder: string - busy: boolean - error: string - status: boolean | undefined - onChange: (value: string) => void - onNameChange: (value: string) => void - onUsernameChange: (value: string) => void - onPasswordChange: (value: string) => void - onSubmit: () => void - onBack: () => void -} - -function showRequestError(language: ReturnType, err: unknown) { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) -} - -function useDefaultServer() { - const language = useLanguage() - const platform = usePlatform() - const [defaultKey, defaultUrlActions] = createResource( - async () => { - try { - const key = await platform.getDefaultServer?.() - if (!key) return null - return key - } catch (err) { - showRequestError(language, err) - return null - } - }, - { initialValue: null }, - ) - - const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer) - const setDefault = async (key: ServerConnection.Key | null) => { - try { - await platform.setDefaultServer?.(key) - defaultUrlActions.mutate(key) - } catch (err) { - showRequestError(language, err) - } - } - - return { defaultKey: () => defaultKey.latest, canDefault, setDefault } -} - -function useServerPreview() { - const checkServerHealth = useCheckServerHealth() - - const looksComplete = (value: string) => { - const normalized = normalizeServerUrl(value) - if (!normalized) return false - const host = normalized.replace(/^https?:\/\//, "").split("/")[0] - if (!host) return false - if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true - return host.includes(".") || host.includes(":") - } - - const previewStatus = async ( - value: string, - username: string, - password: string, - setStatus: (value: boolean | undefined) => void, - ) => { - setStatus(undefined) - if (!looksComplete(value)) return - const normalized = normalizeServerUrl(value) - if (!normalized) return - const http: ServerConnection.HttpBase = { url: normalized } - if (username) http.username = username - if (password) http.password = password - const result = await checkServerHealth(http) - setStatus(result.healthy) - } - - return { previewStatus } -} - -function ServerForm(props: ServerFormProps) { - const language = useLanguage() - const keyDown = (event: KeyboardEvent) => { - event.stopPropagation() - if (event.key === "Escape") { - event.preventDefault() - props.onBack() - return - } - if (event.key !== "Enter" || event.isComposing) return - event.preventDefault() - props.onSubmit() - } - - return ( -
-
-
- -
- -
- - -
-
-
- ) -} - -export function DialogSelectServer() { - const dialog = useDialog() - const controller = useServerManagementController({ onSelect: dialog.close }) - - return ( - -
- }> - - -
-
- ) -} - -export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) { - const navigate = useNavigate() - const server = useServer() - const tabs = useTabs() - const global = useGlobal() - const platform = usePlatform() - const language = useLanguage() - const { defaultKey, canDefault, setDefault } = useDefaultServer() - const { previewStatus } = useServerPreview() - const checkServerHealth = useCheckServerHealth() - const [store, setStore] = createStore({ - addServer: { - url: "", - name: "", - username: DEFAULT_USERNAME, - password: "", - error: "", - showForm: false, - status: undefined as boolean | undefined, - }, - editServer: { - id: undefined as string | undefined, - value: "", - name: "", - username: "", - password: "", - error: "", - status: undefined as boolean | undefined, - }, - }) - - const resetAdd = () => { - setStore("addServer", { - url: "", - name: "", - username: DEFAULT_USERNAME, - password: "", - error: "", - showForm: false, - status: undefined, - }) - } - const resetEdit = () => { - setStore("editServer", { - id: undefined, - value: "", - name: "", - username: "", - password: "", - error: "", - status: undefined, - }) - } - - const addMutation = useMutation(() => ({ - mutationFn: async (value: string) => { - const normalized = normalizeServerUrl(value) - if (!normalized) { - resetAdd() - return - } - - const conn: ServerConnection.Http = { - type: "http", - http: { url: normalized }, - } - if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim() - if (store.addServer.password) conn.http.password = store.addServer.password - if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username - const result = await checkServerHealth(conn.http) - if (!result.healthy) { - setStore("addServer", { error: language.t("dialog.server.add.error") }) - return - } - if ( - !settings.general.newLayoutDesigns() && - (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2" - ) { - setStore("addServer", { error: language.t("dialog.server.add.error") }) - return - } - - resetAdd() - if (options.navigateOnAdd === false) { - server.add(conn) - options.onSelect?.() - return - } - await select(conn, true) - }, - })) - - const editMutation = useMutation(() => ({ - mutationFn: async (input: { original: ServerConnection.Any; value: string }) => { - if (input.original.type !== "http") return - const normalized = normalizeServerUrl(input.value) - if (!normalized) { - resetEdit() - return - } - - const name = store.editServer.name.trim() || undefined - const username = store.editServer.username || undefined - const password = store.editServer.password || undefined - const existingName = input.original.displayName - if ( - normalized === input.original.http.url && - name === existingName && - username === input.original.http.username && - password === input.original.http.password - ) { - resetEdit() - return - } - - const conn: ServerConnection.Http = { - type: "http", - displayName: name, - http: { url: normalized, username, password }, - } - const result = await checkServerHealth(conn.http) - if (!result.healthy) { - setStore("editServer", { error: language.t("dialog.server.add.error") }) - return - } - if ( - !settings.general.newLayoutDesigns() && - (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2" - ) { - setStore("editServer", { error: language.t("dialog.server.add.error") }) - return - } - if (normalized === input.original.http.url) { - server.add(conn) - } else { - replaceServer(input.original, conn) - } - - resetEdit() - }, - })) - - const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => { - const originalKey = ServerConnection.key(original) - const active = server.key - tabs.removeServer(originalKey) - const newConn = server.add(next) - if (!newConn) return - const nextActive = active === originalKey ? ServerConnection.key(newConn) : active - if (nextActive) server.setActive(nextActive) - server.remove(originalKey) - } - - const items = createMemo(() => { - const current = server.current - const list = server.list - if (!current) return list - if (!list.includes(current)) return [current, ...list] - return [current, ...list.filter((x) => x !== current)] - }) - - const settings = useSettings() - const current = createMemo(() => - settings.general.newLayoutDesigns() - ? undefined - : (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]), - ) - - const sortedItems = createMemo(() => { - const raw = items() - const list = settings.general.newLayoutDesigns() - ? raw - : raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2") - if (!list.length) return list - const active = current() - const order = new Map(list.map((url, index) => [url, index] as const)) - const rank = (value?: ServerHealth) => { - if (value?.healthy === true) return 0 - if (value?.healthy === false) return 2 - return 1 - } - return list.slice().sort((a, b) => { - if (a === active) return -1 - if (b === active) return 1 - const diff = - rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)]) - if (diff !== 0) return diff - return (order.get(a) ?? 0) - (order.get(b) ?? 0) - }) - }) - - async function select(conn: ServerConnection.Any, persist?: boolean) { - if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return - options.onSelect?.() - if (persist && conn.type === "http") { - server.add(conn) - navigate("/") - return - } - navigate("/") - queueMicrotask(() => server.setActive(ServerConnection.key(conn))) - } - - const handleAddChange = (value: string) => { - if (addMutation.isPending) return - setStore("addServer", { url: value, error: "" }) - void previewStatus(value, store.addServer.username, store.addServer.password, (next) => - setStore("addServer", { status: next }), - ) - } - - const handleAddNameChange = (value: string) => { - if (addMutation.isPending) return - setStore("addServer", { name: value, error: "" }) - } - - const handleAddUsernameChange = (value: string) => { - if (addMutation.isPending) return - setStore("addServer", { username: value, error: "" }) - void previewStatus(store.addServer.url, value, store.addServer.password, (next) => - setStore("addServer", { status: next }), - ) - } - - const handleAddPasswordChange = (value: string) => { - if (addMutation.isPending) return - setStore("addServer", { password: value, error: "" }) - void previewStatus(store.addServer.url, store.addServer.username, value, (next) => - setStore("addServer", { status: next }), - ) - } - - const handleEditChange = (value: string) => { - if (editMutation.isPending) return - setStore("editServer", { value, error: "" }) - void previewStatus(value, store.editServer.username, store.editServer.password, (next) => - setStore("editServer", { status: next }), - ) - } - - const handleEditNameChange = (value: string) => { - if (editMutation.isPending) return - setStore("editServer", { name: value, error: "" }) - } - - const handleEditUsernameChange = (value: string) => { - if (editMutation.isPending) return - setStore("editServer", { username: value, error: "" }) - void previewStatus(store.editServer.value, value, store.editServer.password, (next) => - setStore("editServer", { status: next }), - ) - } - - const handleEditPasswordChange = (value: string) => { - if (editMutation.isPending) return - setStore("editServer", { password: value, error: "" }) - void previewStatus(store.editServer.value, store.editServer.username, value, (next) => - setStore("editServer", { status: next }), - ) - } - - const mode = createMemo<"list" | "add" | "edit">(() => { - if (store.editServer.id) return "edit" - if (store.addServer.showForm) return "add" - return "list" - }) - - const editing = createMemo(() => { - if (!store.editServer.id) return - return items().find((x) => x.type === "http" && x.http.url === store.editServer.id) - }) - - const resetForm = () => { - resetAdd() - resetEdit() - } - - const startAdd = () => { - resetEdit() - setStore("addServer", { - showForm: true, - url: "", - name: "", - username: DEFAULT_USERNAME, - password: "", - error: "", - status: undefined, - }) - } - - const startEdit = (conn: ServerConnection.Http) => { - resetAdd() - setStore("editServer", { - id: conn.http.url, - value: conn.http.url, - name: conn.displayName ?? "", - username: conn.http.username ?? "", - password: conn.http.password ?? "", - error: "", - status: global.servers.health[ServerConnection.key(conn)]?.healthy, - }) - } - - const submitForm = () => { - if (mode() === "add") { - if (addMutation.isPending) return - setStore("addServer", { error: "" }) - addMutation.mutate(store.addServer.url) - return - } - const original = editing() - if (!original) return - if (editMutation.isPending) return - setStore("editServer", { error: "" }) - editMutation.mutate({ original, value: store.editServer.value }) - } - - const isFormMode = createMemo(() => mode() !== "list") - const isAddMode = createMemo(() => mode() === "add") - const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending)) - - const formTitle = createMemo(() => { - if (!isFormMode()) return language.t("dialog.server.title") - return ( -
- - {isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")} -
- ) - }) - - createEffect(() => { - if (!store.editServer.id) return - if (editing()) return - resetEdit() - }) - - async function handleRemove(key: ServerConnection.Key) { - try { - if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key) - tabs.removeServer(key) - server.remove(key) - if ((await platform.getDefaultServer?.()) === key) { - await setDefault(null) - } - } catch (err) { - showRequestError(language, err) - } - } - - return { - defaultKey, - canDefault, - current, - sortedItems, - status: () => global.servers.health, - isFormMode, - isAddMode, - formTitle, - formBusy, - formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value), - formName: () => (isAddMode() ? store.addServer.name : store.editServer.name), - formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username), - formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password), - formError: () => (isAddMode() ? store.addServer.error : store.editServer.error), - formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status), - select, - setDefault, - startAdd, - startEdit, - resetForm, - submitForm, - handleRemove, - handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange), - handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange), - handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange), - handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange), - } -} - -export function ServerConnectionList(props: { controller: ReturnType }) { - const language = useLanguage() - const settings = useSettings() - - return ( -
- x.http.url} - onSelect={(x) => { - if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x) - }} - divider={true} - > - {(i) => { - const key = ServerConnection.key(i) - return ( -
-
- -
- - - {language.t("dialog.server.status.default")} - - - } - showCredentials - /> -
- - - - - - - e.stopPropagation()} - onPointerDown={(e: PointerEvent) => e.stopPropagation()} - /> - - - { - if (i.type !== "http") return - props.controller.startEdit(i) - }} - > - {language.t("dialog.server.menu.edit")} - - - props.controller.setDefault(key)}> - {language.t("dialog.server.menu.default")} - - - - props.controller.setDefault(null)}> - - {language.t("dialog.server.menu.defaultRemove")} - - - - - props.controller.handleRemove(ServerConnection.key(i))} - class="text-text-on-critical-base hover:bg-surface-critical-weak" - > - {language.t("dialog.server.menu.delete")} - - - - - -
-
- ) - }} -
- -
- -
-
- ) -} - -export function ServerConnectionForm(props: { controller: ReturnType }) { - const language = useLanguage() - - return ( -
- -
- -
-
- ) -} diff --git a/packages/app-bundle/overlay/packages/app/src/components/dialog-usage-exceeded.tsx b/packages/app-bundle/overlay/packages/app/src/components/dialog-usage-exceeded.tsx deleted file mode 100644 index bf5da751..00000000 --- a/packages/app-bundle/overlay/packages/app/src/components/dialog-usage-exceeded.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { usePlatform } from "@/context/platform" -import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { Dialog } from "@opencode-ai/ui/dialog" -import { JSX } from "solid-js" - -export type DialogGoUpsellProps = { - title: string - description: JSX.Element - link?: string - actionLabel: string - onClose?: (dontShowAgain?: boolean) => void -} - -export function DialogUsageExceeded(props: DialogGoUpsellProps) { - const dialog = useDialog() - const platform = usePlatform() - - const runAction = () => { - if (props.link) platform.openExternal(props.link) - props.onClose?.() - dialog.close() - } - - const dismiss = () => { - props.onClose?.(true) - dialog.close() - } - - return ( - -
-
- - -
-
-
- ) -} diff --git a/packages/app-bundle/overlay/packages/app/src/components/directory-picker-domain.test.ts b/packages/app-bundle/overlay/packages/app/src/components/directory-picker-domain.test.ts deleted file mode 100644 index 1bc9af08..00000000 --- a/packages/app-bundle/overlay/packages/app/src/components/directory-picker-domain.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { expect, test } from "bun:test" -import { - absoluteTreePath, - activeTreeNavigation, - advanceTreePreload, - nextSuggestionIndex, - nextTreeScrollTop, - pickerTreeEntries, - pickerSearchEntries, - pickerFileSearchQuery, - pickerMode, - preloadTreeDirectories, - selectedTreePath, - treeEntries, - treePathWithin, - currentPickerSuggestions, - createDirectorySearch, - createPriorityTaskQueue, - displayPickerPath, - pickerParent, - pickerRoot, - pickerAbsoluteInput, -} from "./directory-picker-domain" - -test("maps server directory entries into Pierre paths", () => { - expect( - treeEntries("src/", [ - { name: "components", type: "directory" }, - { name: "index.ts", type: "file" }, - ]), - ).toEqual(["src/components/", "src/index.ts"]) -}) - -test("maps Pierre paths back to the selected server root", () => { - expect(absoluteTreePath("C:/Users/luke", "src/components/")).toBe("C:/Users/luke/src/components") - expect(absoluteTreePath("C:/", "")).toBe("C:/") - expect(absoluteTreePath("C:/", "README.md")).toBe("C:/README.md") - expect(absoluteTreePath("/home/luke", "README.md")).toBe("/home/luke/README.md") -}) - -test("includes files only when the picker selects files", () => { - const nodes = [ - { name: "components", type: "directory" as const }, - { name: "index.ts", type: "file" as const }, - ] - expect(pickerTreeEntries("", nodes, "directory")).toEqual(["components/"]) - expect(pickerTreeEntries("", nodes, "file")).toEqual(["components/", "index.ts"]) -}) - -test("includes files in file autocomplete while preserving directory navigation", () => { - const nodes = [ - { name: "src", absolute: "/repo/src", type: "directory" as const }, - { name: "README.md", absolute: "/repo/README.md", type: "file" as const }, - ] - expect(pickerSearchEntries(nodes, "directory")).toEqual([nodes[0]]) - expect(pickerSearchEntries(nodes, "file")).toEqual(nodes) -}) - -test("centralizes file and directory selection policy", () => { - const file = pickerMode("file", "/repo") - expect(file.includeFiles).toBeTrue() - expect(file.selection("/repo/src", "index.ts")).toBe("src/index.ts") - expect(file.selection("/repo", "src/")).toBeUndefined() - expect(file.result("/repo", "src/index.ts")).toBe("src/index.ts") - expect(file.selection("/tmp", "example.txt")).toBeUndefined() - expect(file.navigation("/repo/src")).toBe("/repo/src") - expect(file.navigation("/tmp")).toBeUndefined() - - const directory = pickerMode("directory") - expect(directory.includeFiles).toBeFalse() - expect(directory.selection("/repo", "src/")).toBe("/repo/src") - expect(directory.selection("C:/Users/luke", "repos/")).toBe("C:\\Users\\luke\\repos") - expect(directory.selection("//Server/Share", "repo/")).toBe("\\\\Server\\Share\\repo") - expect(directory.navigation("/tmp")).toBe("/tmp") - expect(directory.result("/repo", "")).toBe("/repo") - expect(directory.result("C:/Users/luke", "")).toBe("C:\\Users\\luke") - expect(directory.result("//Server/Share/repo", "")).toBe("\\\\Server\\Share\\repo") - expect(directory.result("/repo", "", false)).toBeUndefined() -}) - -test("accepts mutations only from the active navigation", () => { - expect(activeTreeNavigation(3, 3)).toBeTrue() - expect(activeTreeNavigation(2, 3)).toBeFalse() -}) - -test("preserves POSIX case while matching Windows drives case-insensitively", () => { - expect(treePathWithin("/repo", "/Repo")).toBeFalse() - expect(treePathWithin("C:/Repo", "c:/repo/src")).toBeTrue() - expect(treePathWithin("//Server/Share/Repo", "//server/share/repo/src")).toBeTrue() - expect(pickerMode("file", "//Server/Share/Repo").selection("//server/share/repo/src", "file.ts")).toBe("src/file.ts") - expect(treePathWithin("/repo", "/repo/../tmp")).toBeFalse() - expect(treePathWithin("/", "/src")).toBeTrue() - expect(pickerMode("file", "C:/Repo").selection("c:/repo/src", "file.ts")).toBe("src/file.ts") - expect(pickerMode("file", "C:/").selection("C:/", "file.ts")).toBe("file.ts") -}) - -test("displays paths using the selected server path format", () => { - expect(displayPickerPath("C:/Users/luke/repos", "C:/Users/luke/repos", "C:/Users/luke")).toBe( - "C:\\Users\\luke\\repos", - ) - expect(displayPickerPath("C:/Users/luke/repos", "C:\\Users\\luke\\repos", "C:/Users/luke")).toBe( - "C:\\Users\\luke\\repos", - ) - expect(displayPickerPath("/home/luke/repos", "repos", "/home/luke")).toBe("~/repos") - expect(displayPickerPath("/home/luke/repos", "~/repos", "/home/luke")).toBe("~/repos") -}) - -test("treats the server share prefix as the UNC root", () => { - expect(pickerRoot("//Server/Share/repo/src")).toBe("//Server/Share") - expect(pickerRoot("\\\\Server\\Share\\repo\\src")).toBe("//Server/Share") - expect(pickerParent("//Server/Share")).toBe("//Server/Share") - expect(pickerParent("//Server/Share/repo")).toBe("//Server/Share") -}) - -test("resolves relative input against the current picker root", () => { - expect(pickerAbsoluteInput("src", "/home/luke", "/home/luke/repo")).toBe("/home/luke/repo/src") - expect(pickerAbsoluteInput("../other", "/home/luke", "/home/luke/repo")).toBe("/home/luke/other") - expect(pickerAbsoluteInput("~/.config", "/home/luke", "/home/luke/repo")).toBe("/home/luke/.config") - expect(pickerAbsoluteInput("src", "C:/Users/luke", "C:/Users/luke/repo")).toBe("C:/Users/luke/repo/src") -}) - -test("exposes autocomplete results only for their source query", () => { - const result = { query: "/repo/src", items: ["/repo/src/index.ts"] } - expect(currentPickerSuggestions(result, "/repo/src")).toEqual(result.items) - expect(currentPickerSuggestions(result, "/repo/test")).toEqual([]) -}) - -test("scopes file autocomplete to the current browser root", () => { - expect(pickerFileSearchQuery("/home/luke/repos", "/home/luke/repos/src/in", "/home/luke")).toBe("src/in") - expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op") -}) - -test("resolves directory autocomplete from the current browser root", async () => { - const directories: string[] = [] - const sdk = { - api: { - file: { - find: (input: { location?: { directory?: string } }) => { - directories.push(input.location?.directory ?? "") - return Promise.resolve({ data: [] }) - }, - }, - }, - } as unknown as Parameters[0]["sdk"] - let base = "/repo" - const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base }) - - await search("components") - base = "/repo/src" - await search("components") - - expect(directories).toEqual(["/repo", "/repo/src"]) -}) - -test("searches from an absolute root without a default base", async () => { - const directories: string[] = [] - const sdk = { - api: { - file: { - list: (input: { location?: { directory?: string } }) => { - directories.push(input.location?.directory ?? "") - return Promise.resolve({ - data: [ - { path: "Users/", type: "directory" }, - { path: "tmp/", type: "directory" }, - ], - }) - }, - }, - }, - } as unknown as Parameters[0]["sdk"] - const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined }) - - expect(await search("/")).toEqual(["/Users", "/tmp"]) - expect(directories).toEqual(["/"]) -}) - -test("identifies the next directory level to preload", () => { - expect( - preloadTreeDirectories("src/", [ - { name: "components", type: "directory" }, - { name: "index.ts", type: "file" }, - { name: "utils", type: "directory" }, - ]), - ).toEqual(["src/components/", "src/utils/"]) -}) - -test("advances preloading once for every expanded directory", () => { - const advanced = new Set() - expect(advanceTreePreload(advanced, "")).toBeTrue() - expect(advanceTreePreload(advanced, "")).toBeFalse() - expect(advanceTreePreload(advanced, "repos/")).toBeTrue() -}) - -test("limits background tasks and prioritizes newly requested work", async () => { - const queue = createPriorityTaskQueue(2) - const first = Promise.withResolvers() - const second = Promise.withResolvers() - const started: string[] = [] - let active = 0 - let maximum = 0 - const task = (name: string, blocker?: Promise) => async () => { - started.push(name) - active++ - maximum = Math.max(maximum, active) - await blocker - active-- - } - - const running = [ - queue.schedule("first", "background", task("first", first.promise)), - queue.schedule("second", "background", task("second", second.promise)), - queue.schedule("preload", "background", task("preload")), - queue.schedule("opened", "user", task("opened")), - ] - await Promise.resolve() - expect(started).toEqual(["first", "second"]) - - first.resolve() - await running[0] - await Promise.resolve() - expect(started).toEqual(["first", "second", "opened"]) - - second.resolve() - await Promise.all(running) - expect(started).toEqual(["first", "second", "opened", "preload"]) - expect(maximum).toBe(2) -}) - -test("clamps bridged tree wheel scrolling", () => { - expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140) - expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0) - expect(nextTreeScrollTop(290, 40, 500, 200)).toBe(300) -}) - -test("wraps autocomplete keyboard navigation", () => { - expect(nextSuggestionIndex(-1, 1, 4)).toBe(0) - expect(nextSuggestionIndex(3, 1, 4)).toBe(0) - expect(nextSuggestionIndex(0, -1, 4)).toBe(3) - expect(nextSuggestionIndex(0, 1, 0)).toBe(-1) -}) - -test("returns absolute directories and relative files", () => { - expect(selectedTreePath("/home/luke/repo", "src/", "directory")).toBe("/home/luke/repo/src") - expect(selectedTreePath("/home/luke/repo", "src/index.ts", "file")).toBe("src/index.ts") - expect(selectedTreePath("/home/luke/repo/src", "index.ts", "file", "/home/luke/repo")).toBe("src/index.ts") - expect(selectedTreePath("/home/luke/repo", "src/", "file")).toBeUndefined() -}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/directory-picker-domain.ts b/packages/app-bundle/overlay/packages/app/src/components/directory-picker-domain.ts deleted file mode 100644 index 9539ae1d..00000000 --- a/packages/app-bundle/overlay/packages/app/src/components/directory-picker-domain.ts +++ /dev/null @@ -1,407 +0,0 @@ -export function treeEntries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) { - const prefix = parent.replace(/^\/+|\/+$/g, "") - return nodes.map((node) => { - const path = prefix ? `${prefix}/${node.name}` : node.name - return node.type === "directory" ? path + "/" : path - }) -} - -export function pickerTreeEntries( - parent: string, - nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>, - mode: "directory" | "file", -) { - return treeEntries(parent, mode === "directory" ? nodes.filter((node) => node.type === "directory") : nodes) -} - -export function pickerSearchEntries( - nodes: readonly T[], - mode: "directory" | "file", -) { - return mode === "directory" ? nodes.filter((node) => node.type === "directory") : [...nodes] -} - -export function pickerMode(mode: "directory" | "file", base?: string) { - if (mode === "file") { - return { - includeFiles: true, - action: "file" as const, - entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) { - return treeEntries(parent, nodes) - }, - navigation(path: string) { - return treePathWithin(base, path) ? path : undefined - }, - result(root: string, selected: string) { - return selected || undefined - }, - selection(root: string, path: string) { - if (!treePathWithin(base, root)) return - return selectedTreePath(root, path, "file", base) - }, - } - } - return { - includeFiles: false, - action: "directory" as const, - entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) { - return treeEntries( - parent, - nodes.filter((node) => node.type === "directory"), - ) - }, - navigation(path: string) { - return path - }, - result(root: string, selected: string, valid = true) { - if (!valid) return - return selected || (root ? nativePickerPath(root) : undefined) - }, - selection(root: string, path: string) { - return selectedTreePath(root, path, "directory") - }, - } -} - -export function pickerFileSearchQuery(root: string, input: string, home: string) { - const value = input - .replace(/\\/g, "/") - .replace(/^~(?=\/|$)/, home) - .replace(/\/+$/, "") - const base = root.replace(/\\/g, "/").replace(/\/+$/, "") - if (value === base) return "" - if (value.startsWith(base + "/")) return value.slice(base.length + 1) - return value -} - -export function pickerAbsoluteInput(input: string, home: string, current: string) { - const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home)) - const absolute = pickerRoot(value) ? value : joinPickerPath(current, value) - return canonicalPickerPath(absolute) -} - -export function treePathWithin(base: string | undefined, path: string) { - return pickerRelativePath(base, path) !== undefined -} - -export function canonicalPickerPath(path: string) { - const value = normalizePickerDrive(path) - const root = pickerRoot(value) - const parts = value.slice(root.length).split("/") - const resolved = parts.reduce((output, part) => { - if (!part || part === ".") return output - if (part === "..") { - output.pop() - return output - } - output.push(part) - return output - }, []) - return joinPickerPath(root, resolved.join("/")) -} - -export function pickerRelativePath(base: string | undefined, path: string) { - if (!base) return - const rootPath = canonicalPickerPath(base) - const targetPath = canonicalPickerPath(path) - const insensitive = /^[A-Za-z]:\//.test(rootPath) || rootPath.startsWith("//") - const root = insensitive ? rootPath.toLowerCase() : rootPath - const target = insensitive ? targetPath.toLowerCase() : targetPath - if (target === root) return "" - const prefix = root.endsWith("/") ? root : root + "/" - if (!target.startsWith(prefix)) return - return targetPath.slice(prefix.length) -} - -export function currentPickerSuggestions(result: { query: string; items: readonly T[] } | undefined, query: string) { - if (result?.query !== query) return [] - return result.items -} - -export function preloadTreeDirectories( - parent: string, - nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>, -) { - return treeEntries( - parent, - nodes.filter((node) => node.type === "directory"), - ) -} - -export function advanceTreePreload(advanced: Set, path: string) { - if (advanced.has(path)) return false - advanced.add(path) - return true -} - -export function activeTreeNavigation(request: number, current: number) { - return request === current -} - -export function createPriorityTaskQueue(concurrency: number) { - type Job = { - key: string - priority: "user" | "background" - promise: Promise - run: () => void - } - - const jobs = new Map() - const user: Job[] = [] - const background: Job[] = [] - let active = 0 - - const drain = () => { - while (active < concurrency) { - const job = user.pop() ?? background.shift() - if (!job) return - active++ - job.run() - } - } - - const schedule = (key: string, priority: Job["priority"], task: () => Promise) => { - const existing = jobs.get(key) - if (existing) { - if (priority === "user") promote(key) - return existing.promise - } - - const deferred = Promise.withResolvers() - const job: Job = { - key, - priority, - promise: deferred.promise, - run: () => { - const complete = () => { - active-- - jobs.delete(key) - drain() - } - Promise.resolve() - .then(task) - .then( - (value) => { - complete() - deferred.resolve(value) - }, - (error) => { - complete() - deferred.reject(error) - }, - ) - }, - } - jobs.set(key, job) - ;(priority === "user" ? user : background).push(job) - drain() - return job.promise - } - - const promote = (key: string) => { - const job = jobs.get(key) - if (!job || job.priority === "user") return - const index = background.indexOf(job) - if (index === -1) return - background.splice(index, 1) - job.priority = "user" - user.push(job) - } - - return { schedule, promote } -} - -export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) { - return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta)) -} - -export function nextSuggestionIndex(current: number, delta: -1 | 1, count: number) { - if (count === 0) return -1 - return (current + delta + count) % count -} - -export function absoluteTreePath(root: string, path: string) { - const base = trimPickerPath(root) - const relative = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "") - if (!relative) return base || "/" - if (!base || base === "/") return "/" + relative - if (base.endsWith("/")) return base + relative - return `${base}/${relative}` -} - -export function selectedTreePath(root: string, path: string, mode: "directory" | "file", base?: string) { - const directory = path.endsWith("/") - if (mode === "file") { - if (directory) return - if (!base) return path - const absolute = absoluteTreePath(root, path) - return pickerRelativePath(base, absolute) - } - return directory ? nativePickerPath(absoluteTreePath(root, path)) : undefined -} - -export function nativePickerPath(path: string) { - const value = trimPickerPath(path) - if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\") - return value -} -import { getFilename } from "@opencode-ai/core/util/path" -import fuzzysort from "fuzzysort" -import { ServerSDK } from "@/context/server-sdk" - -export function cleanPickerInput(value: string) { - const first = (value ?? "").split(/\r?\n/)[0] ?? "" - return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() -} - -export function normalizePickerPath(input: string) { - const value = input.replaceAll("\\", "/") - if (value.startsWith("//") && !value.startsWith("///")) return "//" + value.slice(2).replace(/\/+/g, "/") - return value.replace(/\/+/g, "/") -} - -export function normalizePickerDrive(input: string) { - const value = normalizePickerPath(input) - if (/^[A-Za-z]:$/.test(value)) return value + "/" - return value -} - -export function trimPickerPath(input: string) { - const value = normalizePickerDrive(input) - if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value - return value.replace(/\/+$/, "") -} - -export function joinPickerPath(base: string | undefined, relative: string) { - const root = trimPickerPath(base ?? "") - const path = trimPickerPath(relative).replace(/^\/+/, "") - if (!root) return path - if (!path) return root - if (root.endsWith("/")) return root + path - return root + "/" + path -} - -export function pickerRoot(input: string) { - const value = normalizePickerDrive(input) - if (value.startsWith("//")) { - const [server, share] = value.slice(2).split("/") - if (server && share) return `//${server}/${share}` - return "//" - } - if (value.startsWith("/")) return "/" - if (/^[A-Za-z]:\//.test(value)) return value.slice(0, 3) - return "" -} - -export function pickerParent(input: string) { - const value = trimPickerPath(input) - const root = pickerRoot(value) - if (value === root) return value - if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value - const index = value.lastIndexOf("/") - if (index < root.length) return root - if (index <= 0) return "/" - if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3) - return value.slice(0, index) -} - -function pickerTilde(absolute: string, home: string) { - const path = trimPickerPath(absolute) - if (!home) return "" - const root = trimPickerPath(home) - if (/^[A-Za-z]:\//.test(root)) return "" - if (path === root) return "~" - if (path.startsWith(root + "/")) return "~" + path.slice(root.length) - return "" -} - -export function displayPickerPath(path: string, input: string, home: string) { - const value = trimPickerPath(path) - if (/^[A-Za-z]:\//.test(trimPickerPath(home)) || /^[A-Za-z]:\//.test(value)) return value.replaceAll("/", "\\") - return pickerTilde(value, home) || value -} - -export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) { - const cache = new Map>>() - let current = 0 - - const scoped = (value: string) => { - const raw = normalizePickerDrive(value) - const root = pickerRoot(raw) - if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) } - const base = args.base() - if (!base) return - if (!raw) return { directory: trimPickerPath(base), path: "" } - const home = args.home() - if (raw === "~") return { directory: trimPickerPath(home || base), path: "" } - if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) } - return { directory: trimPickerPath(base), path: raw } - } - - const directories = async (directory: string) => { - const key = trimPickerPath(directory) - const existing = cache.get(key) - if (existing) return existing - const request = args.sdk.api.file - .list({ location: { directory: key } }) - .then((result) => result.data) - .catch(() => []) - .then((nodes) => - nodes - .filter((node) => node.type === "directory") - .map((node) => { - const relative = trimPickerPath(normalizePickerDrive(node.path)) - return { name: getFilename(relative), absolute: joinPickerPath(key, relative) } - }), - ) - cache.set(key, request) - return request - } - - const match = async (directory: string, query: string, limit: number) => { - const items = await directories(directory) - if (!query) return items.slice(0, limit).map((item) => item.absolute) - return fuzzysort.go(query, items, { key: "name", limit }).map((item) => item.obj.absolute) - } - - return async (filter: string) => { - const token = ++current - const active = () => token === current - const value = cleanPickerInput(filter) - const input = scoped(value) - if (!input) return [] as string[] - const raw = normalizePickerDrive(value) - const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") - const query = normalizePickerDrive(input.path) - if (!pathInput) { - const results = await args.sdk.api.file - .find({ location: { directory: input.directory }, query, type: "directory", limit: 50 }) - .then((result) => result.data.map((entry) => entry.path)) - .catch(() => []) - if (!active()) return [] - return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) - } - const segments = query.replace(/^\/+/, "").split("/") - const head = segments.slice(0, -1).filter((part) => part && part !== ".") - const tail = segments.at(-1) ?? "" - let paths = [input.directory] - for (const part of head) { - if (!active()) return [] - if (part === "..") { - paths = paths.map(pickerParent) - continue - } - paths = Array.from(new Set((await Promise.all(paths.map((path) => match(path, part, 4)))).flat())).slice(0, 12) - if (!active() || paths.length === 0) return [] - } - const matches = Array.from(new Set((await Promise.all(paths.map((path) => match(path, tail, 50)))).flat())) - if (!active()) return [] - const base = raw.startsWith("~") ? trimPickerPath(input.directory) : "" - if (raw.endsWith("/") || !tail) return Array.from(new Set([base, ...matches].filter(Boolean))).slice(0, 50) - const target = matches.find((path) => getFilename(path).toLowerCase() === tail.toLowerCase()) - if (!target) return matches.slice(0, 50) - const children = await match(target, "", 30) - if (!active()) return [] - return Array.from(new Set([base, ...matches, ...children].filter(Boolean))).slice(0, 50) - } -} diff --git a/packages/app-bundle/overlay/packages/app/src/components/file-tree-v2.tsx b/packages/app-bundle/overlay/packages/app/src/components/file-tree-v2.tsx deleted file mode 100644 index 26cf49d4..00000000 --- a/packages/app-bundle/overlay/packages/app/src/components/file-tree-v2.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import { useFile } from "@/context/file" -import { FileIcon } from "@opencode-ai/ui/file-icon" -import "@opencode-ai/ui/v2/file-tree-v2.css" -import { - createEffect, - createMemo, - createSignal, - For, - Show, - splitProps, - type ComponentProps, - type ParentProps, -} from "solid-js" -import { Dynamic } from "solid-js/web" -import type { FileNode } from "@opencode-ai/sdk/v2" -import { Icon } from "@opencode-ai/ui/v2/icon" -import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" -import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" -import { - buildFileTreeV2Model, - flattenFileTreeV2, - flattenLiveFileTreeV2, - normalizeFileTreeV2Path, - type FileTreeV2Node, -} from "@/components/file-tree-v2-model" -import { virtualScrollElement } from "@/components/virtual-scroll-element" - -export type { Kind } from "@/components/file-tree" - -const INDENT_STEP = 16 - -function rowPaddingLeft(level: number, type: FileNode["type"]) { - if (type === "directory") return 8 + level * INDENT_STEP - if (level === 0) return 8 - return 8 + level * INDENT_STEP - INDENT_STEP -} - -function guideLineLeft(level: number) { - return rowPaddingLeft(level, "directory") + 8 -} - -export const kindLabel = (kind: Kind) => { - if (kind === "add") return "A" - if (kind === "del") return "D" - return "M" -} - -export const kindChange = (kind: Kind) => { - if (kind === "add") return "added" - if (kind === "del") return "deleted" - return "modified" -} - -const FileTreeNodeV2 = ( - p: ParentProps & - ComponentProps<"div"> & - ComponentProps<"button"> & { - node: FileNode - level: number - active?: string - draggable: boolean - kinds?: ReadonlyMap - as?: "div" | "button" - }, -) => { - const [local, rest] = splitProps(p, [ - "node", - "level", - "active", - "draggable", - "kinds", - "as", - "children", - "class", - "classList", - ]) - const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path)) - - return ( - { - if (!local.draggable) return - event.dataTransfer?.setData("text/plain", `file:${local.node.path}`) - event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) - if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy" - withFileDragImage(event) - }} - {...rest} - > - {local.children} - {local.node.name} - {(() => { - const value = kind() - if (!value || local.node.type !== "file") return null - return ( - - {kindLabel(value)} - - ) - })()} - - ) -} - -function GuideLines(props: { level: number }) { - return ( - - {(_, index) =>
} - - ) -} - -export default function FileTreeV2(props: { - active?: string - allowed?: readonly string[] - kinds?: ReadonlyMap - draggable?: boolean - onFileClick?: (file: FileNode) => void - onFileDoubleClick?: (file: FileNode) => void -}) { - const file = useFile() - const live = () => props.allowed === undefined - const draggable = () => props.draggable ?? true - const active = () => normalizeFileTreeV2Path(props.active ?? "") - const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? []))) - const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live() - const rows = createMemo(() => { - if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded) - return flattenFileTreeV2(model()!, expanded) - }) - const [root, setRoot] = createSignal() - const [focused, setFocused] = createSignal() - const virtualizer = createVirtualizer({ - get count() { - return rows().length - }, - getScrollElement: () => virtualScrollElement(root()), - initialRect: { width: 0, height: 600 }, - estimateSize: () => 28, - gap: 2, - overscan: 10, - get getItemKey() { - const current = rows() - return (index: number) => current[index]?.node.path ?? index - }, - rangeExtractor: (range) => { - const indexes = defaultRangeExtractor(range) - const path = focused() - const index = path ? rows().findIndex((row) => row.node.path === path) : -1 - if (index < 0 || indexes.includes(index)) return indexes - return [...indexes, index].sort((a, b) => a - b) - }, - }) - - createEffect(() => { - if (!live()) return - void file.tree.list("") - }) - - // Only scroll when the active path changes (or first appears in the tree). - // Do not re-scroll when expand/collapse reshuffles `rows()`. - let scrolledActive: string | undefined - createEffect(() => { - const path = active() - if (!path) { - scrolledActive = undefined - return - } - const index = rows().findIndex((row) => row.node.path === path) - if (index < 0) return - if (scrolledActive === path) return - scrolledActive = path - queueMicrotask(() => { - const next = rows().findIndex((row) => row.node.path === path) - if (next < 0) return - if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return - virtualizer.scrollToIndex(next, { align: "auto" }) - }) - }) - - const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => { - action?.({ - ...node, - path: node.originalPath, - absolute: node.originalPath, - }) - } - - const toggleDirectory = (path: string, originalPath: string) => { - if (expanded(path)) { - file.tree.collapse(originalPath) - return - } - file.tree.expand(originalPath, live() ? undefined : { list: false }) - } - - const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const))) - const virtualItemByKey = createMemo( - () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), - ) - const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key)) - - return ( -
- - {(key) => ( - - {(item) => ( -
- - {(row) => ( - setFocused(row().node.path)} - onBlur={() => setFocused(undefined)} - onClick={() => selectFile(row().node, props.onFileClick)} - onDblClick={() => selectFile(row().node, props.onFileDoubleClick)} - > - - 0}> -
- - - - - - - } - > - setFocused(row().node.path)} - onBlur={() => setFocused(undefined)} - aria-expanded={expanded(row().node.path)} - onClick={() => toggleDirectory(row().node.path, row().node.originalPath)} - > - -
- -
-
- - )} - -
- )} -
- )} - -
- ) -} diff --git a/packages/app-bundle/overlay/packages/app/src/components/file-tree.tsx b/packages/app-bundle/overlay/packages/app/src/components/file-tree.tsx deleted file mode 100644 index 718e2f34..00000000 --- a/packages/app-bundle/overlay/packages/app/src/components/file-tree.tsx +++ /dev/null @@ -1,509 +0,0 @@ -import { useFile } from "@/context/file" -import { encodeFilePath } from "@/context/file/path" -import { Collapsible } from "@opencode-ai/ui/collapsible" -import { FileIcon } from "@opencode-ai/ui/file-icon" -import { Icon } from "@opencode-ai/ui/icon" -import { - createEffect, - createMemo, - For, - Match, - on, - Show, - splitProps, - Switch, - untrack, - type ComponentProps, - type ParentProps, -} from "solid-js" -import { Dynamic } from "solid-js/web" -import type { FileNode } from "@opencode-ai/sdk/v2" - -const MAX_DEPTH = 128 - -export function pathToFileUrl(filepath: string): string { - return `file://${encodeFilePath(filepath)}` -} - -export type Kind = "add" | "del" | "mix" - -export type Filter = { - files: Set - dirs: Set -} - -export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) { - if (input.level !== 0) return false - if (input.dir?.loaded) return false - if (input.dir?.loading) return false - return true -} - -export function shouldListExpanded(input: { - level: number - dir?: { expanded?: boolean; loaded?: boolean; loading?: boolean } -}) { - if (input.level === 0) return false - if (!input.dir?.expanded) return false - if (input.dir.loaded) return false - if (input.dir.loading) return false - return true -} - -export function dirsToExpand(input: { - level: number - filter?: { dirs: Set } - expanded: (dir: string) => boolean -}) { - if (input.level !== 0) return [] - if (!input.filter) return [] - return [...input.filter.dirs].filter((dir) => !input.expanded(dir)) -} - -const kindLabel = (kind: Kind) => { - if (kind === "add") return "A" - if (kind === "del") return "D" - return "M" -} - -const kindTextColor = (kind: Kind) => { - if (kind === "add") return "color: var(--icon-diff-add-base)" - if (kind === "del") return "color: var(--icon-diff-delete-base)" - return "color: var(--icon-diff-modified-base)" -} - -const kindDotColor = (kind: Kind) => { - if (kind === "add") return "background-color: var(--icon-diff-add-base)" - if (kind === "del") return "background-color: var(--icon-diff-delete-base)" - return "background-color: var(--icon-diff-modified-base)" -} - -export const visibleKind = (node: FileNode, kinds?: ReadonlyMap, marks?: Set) => { - const kind = kinds?.get(node.path) - if (!kind) return - if (!marks?.has(node.path)) return - return kind -} - -const buildDragImage = (target: HTMLElement) => { - const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg") - const text = target.querySelector("span") - if (!icon || !text) return - - const image = document.createElement("div") - image.className = - "flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong" - image.style.position = "absolute" - image.style.top = "-1000px" - image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML - return image -} - -export const withFileDragImage = (event: DragEvent) => { - const image = buildDragImage(event.currentTarget as HTMLElement) - if (!image) return - document.body.appendChild(image) - event.dataTransfer?.setDragImage(image, 0, 12) - setTimeout(() => document.body.removeChild(image), 0) -} - -const FileTreeNode = ( - p: ParentProps & - ComponentProps<"div"> & - ComponentProps<"button"> & { - node: FileNode - level: number - active?: string - nodeClass?: string - draggable: boolean - kinds?: ReadonlyMap - marks?: Set - as?: "div" | "button" - }, -) => { - const [local, rest] = splitProps(p, [ - "node", - "level", - "active", - "nodeClass", - "draggable", - "kinds", - "marks", - "as", - "children", - "class", - "classList", - ]) - const kind = () => visibleKind(local.node, local.kinds, local.marks) - const active = () => !!kind() && !local.node.ignored - const color = () => { - const value = kind() - if (!value) return - return kindTextColor(value) - } - - return ( - { - if (!local.draggable) return - event.dataTransfer?.setData("text/plain", `file:${local.node.path}`) - event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) - if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy" - withFileDragImage(event) - }} - {...rest} - > - {local.children} - - {local.node.name} - - {(() => { - const value = kind() - if (!value) return null - if (local.node.type === "file") { - return ( - - {kindLabel(value)} - - ) - } - return
- })()} - - ) -} - -export default function FileTree(props: { - path: string - class?: string - nodeClass?: string - active?: string - level?: number - allowed?: readonly string[] - modified?: readonly string[] - kinds?: ReadonlyMap - draggable?: boolean - onFileClick?: (file: FileNode) => void - onFileDoubleClick?: (file: FileNode) => void - - _filter?: Filter - _marks?: Set - _deeps?: Map - _kinds?: ReadonlyMap - _chain?: readonly string[] -}) { - const file = useFile() - const level = props.level ?? 0 - const draggable = () => props.draggable ?? true - - const key = (p: string) => - file - .normalize(p) - .replace(/[\\/]+$/, "") - .replaceAll("\\", "/") - const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)] - - const filter = createMemo(() => { - if (props._filter) return props._filter - - const allowed = props.allowed - if (!allowed) return - - const files = new Set(allowed) - const dirs = new Set() - - for (const item of allowed) { - const parts = item.split("/") - const parents = parts.slice(0, -1) - for (const [idx] of parents.entries()) { - const dir = parents.slice(0, idx + 1).join("/") - if (dir) dirs.add(dir) - } - } - - return { files, dirs } - }) - - const marks = createMemo(() => { - if (props._marks) return props._marks - - const out = new Set() - for (const item of props.modified ?? []) out.add(item) - for (const item of props.kinds?.keys() ?? []) out.add(item) - if (out.size === 0) return - return out - }) - - const kinds = createMemo(() => { - if (props._kinds) return props._kinds - return props.kinds - }) - - const deeps = createMemo(() => { - if (props._deeps) return props._deeps - - const out = new Map() - - const root = props.path - if (!(file.tree.state(root)?.expanded ?? false)) return out - - const seen = new Set() - const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = [] - - const push = (dir: string, lvl: number) => { - const id = key(dir) - if (seen.has(id)) return - seen.add(id) - - const kids = file.tree - .children(dir) - .filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false)) - .map((node) => node.path) - - stack.push({ dir, lvl, i: 0, kids, max: lvl }) - } - - push(root, level - 1) - - while (stack.length > 0) { - const top = stack[stack.length - 1]! - - if (top.i < top.kids.length) { - const next = top.kids[top.i]! - top.i++ - push(next, top.lvl + 1) - continue - } - - out.set(top.dir, top.max) - stack.pop() - - const parent = stack[stack.length - 1] - if (!parent) continue - parent.max = Math.max(parent.max, top.max) - } - - return out - }) - - createEffect(() => { - const current = filter() - const dirs = dirsToExpand({ - level, - filter: current, - expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false, - }) - for (const dir of dirs) file.tree.expand(dir) - }) - - createEffect( - on( - () => props.path, - (path) => { - const dir = untrack(() => file.tree.state(path)) - if (!shouldListRoot({ level, dir })) return - void file.tree.list(path) - }, - { defer: false }, - ), - ) - - const nodes = createMemo(() => { - const nodes = file.tree.children(props.path) - const current = filter() - if (!current) return nodes - - const parent = (path: string) => { - const idx = path.lastIndexOf("/") - if (idx === -1) return "" - return path.slice(0, idx) - } - - const leaf = (path: string) => { - const idx = path.lastIndexOf("/") - return idx === -1 ? path : path.slice(idx + 1) - } - - const out = nodes.filter((node) => { - if (node.type === "file") return current.files.has(node.path) - return current.dirs.has(node.path) - }) - - const seen = new Set(out.map((node) => node.path)) - - for (const dir of current.dirs) { - if (parent(dir) !== props.path) continue - if (seen.has(dir)) continue - out.push({ - name: leaf(dir), - path: dir, - absolute: dir, - type: "directory", - ignored: false, - }) - seen.add(dir) - } - - for (const item of current.files) { - if (parent(item) !== props.path) continue - if (seen.has(item)) continue - out.push({ - name: leaf(item), - path: item, - absolute: item, - type: "file", - ignored: false, - }) - seen.add(item) - } - - out.sort((a, b) => { - if (a.type !== b.type) { - return a.type === "directory" ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) - - return out - }) - - return ( -
- - {(node) => { - const expanded = () => file.tree.state(node.path)?.expanded ?? false - const deep = () => deeps().get(node.path) ?? -1 - const kind = () => visibleKind(node, kinds(), marks()) - const active = () => !!kind() && !node.ignored - - return ( - - - (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} - > - - -
- -
-
-
- -
- ...
} - > - - -
-
-
- - props.onFileClick?.(node)} - onDblClick={() => props.onFileDoubleClick?.(node)} - > -
- - - - - - - - - - - - - - - - - - ) - }} - -
- ) -} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/open-in-app-v2.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/open-in-app-v2.tsx deleted file mode 100644 index e26ff2e0..00000000 --- a/packages/app-bundle/overlay/packages/app/src/components/session/open-in-app-v2.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { For, Show } from "solid-js" -import { AppIcon } from "@opencode-ai/ui/app-icon" -import { Icon } from "@opencode-ai/ui/icon" -import { Spinner } from "@opencode-ai/ui/spinner" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" -import { SplitButtonV2, SplitButtonV2Action, SplitButtonV2MenuTrigger } from "@opencode-ai/ui/v2/split-button-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { useLanguage } from "@/context/language" -import { type OpenApp, useOpenInApp } from "@/components/session/open-in-app" - -export function OpenInAppV2(props: { directory: () => string }) { - const language = useLanguage() - const state = useOpenInApp(props) - - return ( - - event.stopPropagation()}> - - event.stopPropagation()} - onClick={(event) => { - event.stopPropagation() - if (state.opening()) return - state.openDir(state.current().id) - }} - disabled={state.opening()} - aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })} - > - }> - - - - - state.setMenu("open", open)} - > - event.stopPropagation()} - > - - - - - - {language.t("session.header.openIn")} - { - state.selectApp(value as OpenApp) - }} - > - - {(option) => ( - { - state.selectApp(option.id) - state.setMenu("open", false) - state.openDir(option.id) - }} - > - - {option.label} - - )} - - - - - { - state.setMenu("open", false) - state.copyPath() - }} - > - - {language.t("session.header.open.copyPath")} - - - - - - - ) -} diff --git a/packages/app-bundle/overlay/packages/app/src/components/settings-v2/developer-tools-controller.ts b/packages/app-bundle/overlay/packages/app/src/components/settings-v2/developer-tools-controller.ts index 179cb25a..1f605ca8 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/settings-v2/developer-tools-controller.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/settings-v2/developer-tools-controller.ts @@ -15,6 +15,16 @@ export interface DevToolsStatus { export type RebuildState = "idle" | "rebuilding" | "rebuilt" | "failed" +/** Structured rebuild error from the catalog (rebuild_errors.ts). */ +export interface RebuildErrorInfo { + /** One-line error message. */ + message: string + /** Numbered fix steps. */ + fix: string[] + /** Raw stderr / stack trace (collapsible). */ + detail?: string +} + /** Default repo paths autofilled when the toggle is turned ON with empty fields. */ const DEFAULT_OPENCODE_PATH = "~/harmoniqs/opencode" const DEFAULT_AMICODE_PATH = "~/harmoniqs/amicode" @@ -24,7 +34,7 @@ export function createDeveloperToolsController() { const [status, setStatus] = createSignal(undefined) const [pending, setPending] = createSignal(false) const [rebuildState, setRebuildState] = createSignal("idle") - const [rebuildError, setRebuildError] = createSignal(undefined) + const [rebuildError, setRebuildError] = createSignal(undefined) const [vsixBuildState, setVsixBuildState] = createSignal("idle") const [vsixBuildError, setVsixBuildError] = createSignal(undefined) const [vsixPath, setVsixPath] = createSignal(undefined) @@ -50,7 +60,14 @@ export function createDeveloperToolsController() { if (rebuildState() === "rebuilding") { applyRebuildFlagMutation(rebuildFlagMutation("failed")) setRebuildState("failed") - setRebuildError("Rebuild timed out") + setRebuildError({ + message: "Rebuild timed out", + fix: [ + "Close the Settings dialog and check the 'Amicode — opencode' output channel.", + "Try the rebuild again.", + "If it keeps timing out, report the issue.", + ], + }) } }, 300_000) } else if (didFinish) { @@ -101,7 +118,20 @@ export function createDeveloperToolsController() { } else if (d.state === "failed") { applyRebuildFlagMutation(rebuildFlagMutation("failed")) setRebuildState("failed") - setRebuildError(d.error ?? "Unknown error") + // Accept structured errors (new) or flat strings (legacy bridge compat). + if (d.error && typeof d.error === "object" && typeof d.error.message === "string") { + setRebuildError({ + message: d.error.message, + fix: Array.isArray(d.error.fix) ? d.error.fix : [], + detail: typeof d.error.detail === "string" ? d.error.detail : undefined, + }) + } else { + setRebuildError({ + message: typeof d.error === "string" ? d.error : "Unknown error", + fix: [], + detail: undefined, + }) + } } else if (d.state === "done") { // The extension host confirmed the build finished — set the // "rebuilt" flag now (not at rebuild-start) so a dialog reopened diff --git a/packages/app-bundle/overlay/packages/app/src/components/settings-v2/developer-tools.tsx b/packages/app-bundle/overlay/packages/app/src/components/settings-v2/developer-tools.tsx index df62b021..e9c67dcb 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/settings-v2/developer-tools.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/settings-v2/developer-tools.tsx @@ -1,4 +1,4 @@ -import { Component, Match, Show, Switch } from "solid-js" +import { Component, For, Match, Show, Switch, createSignal } from "solid-js" import { Switch as ToggleSwitch } from "@opencode-ai/ui/v2/switch-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" @@ -11,8 +11,8 @@ import { type DeveloperToolsController, } from "./developer-tools-controller" -/** Status indicator shown below the section title during/after rebuilds. */ -const RebuildStatusIndicator: Component<{ controller: DeveloperToolsController }> = (props) => { +/** Inline status dot shown next to the heading during/after rebuilds. */ +const RebuildStatusBadge: Component<{ controller: DeveloperToolsController }> = (props) => { const language = useLanguage() return ( @@ -31,13 +31,46 @@ const RebuildStatusIndicator: Component<{ controller: DeveloperToolsController }
- {props.controller.rebuildError() ?? "Build failed"} + Failed
) } +/** Block error panel shown below the rebuild buttons when a rebuild fails. */ +const RebuildErrorPanel: Component<{ controller: DeveloperToolsController }> = (props) => { + const [detailOpen, setDetailOpen] = createSignal(false) + const err = () => props.controller.rebuildError() + + return ( + +
+
+ + {err()!.message} +
+ 0}> +
    + {(step) =>
  1. {step}
  2. }
    +
+
+ + + +
{err()!.detail}
+
+
+
+
+ ) +} + const DeveloperToolsContent: Component<{ controller: DeveloperToolsController }> = (props) => { const language = useLanguage() const opencodeError = () => { @@ -76,6 +109,7 @@ const DeveloperToolsContent: Component<{ controller: DeveloperToolsController }> {language.t("settings.general.row.devTools.rebuildRemotely")}
+
@@ -235,7 +269,7 @@ export const DeveloperToolsSection: Component = () => {

{language.t("settings.general.section.developerTools")}

- +
diff --git a/packages/app-bundle/overlay/packages/app/src/constants/file-picker.ts b/packages/app-bundle/overlay/packages/app/src/constants/file-picker.ts deleted file mode 100644 index c661bc8f..00000000 --- a/packages/app-bundle/overlay/packages/app/src/constants/file-picker.ts +++ /dev/null @@ -1,89 +0,0 @@ -export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] - -export const ACCEPTED_FILE_TYPES = [ - ...ACCEPTED_IMAGE_TYPES, - "application/pdf", - "text/*", - "application/json", - "application/ld+json", - "application/toml", - "application/x-toml", - "application/x-yaml", - "application/xml", - "application/yaml", - ".c", - ".cc", - ".cjs", - ".conf", - ".cpp", - ".css", - ".csv", - ".cts", - ".env", - ".go", - ".gql", - ".graphql", - ".h", - ".hh", - ".hpp", - ".htm", - ".html", - ".ini", - ".java", - ".js", - ".json", - ".jsx", - ".log", - ".md", - ".mdx", - ".mjs", - ".mts", - ".py", - ".rb", - ".rs", - ".sass", - ".scss", - ".sh", - ".sql", - ".toml", - ".ts", - ".tsx", - ".txt", - ".xml", - ".yaml", - ".yml", - ".zsh", -] - -const MIME_EXT = new Map([ - ["image/png", "png"], - ["image/jpeg", "jpg"], - ["image/gif", "gif"], - ["image/webp", "webp"], - ["application/pdf", "pdf"], - ["application/json", "json"], - ["application/ld+json", "jsonld"], - ["application/toml", "toml"], - ["application/x-toml", "toml"], - ["application/x-yaml", "yaml"], - ["application/xml", "xml"], - ["application/yaml", "yaml"], -]) - -const TEXT_EXT = ["txt", "text", "md", "markdown", "log", "csv"] - -export const ACCEPTED_FILE_EXTENSIONS = Array.from( - new Set( - ACCEPTED_FILE_TYPES.flatMap((item) => { - if (item.startsWith(".")) return [item.slice(1)] - if (item === "text/*") return TEXT_EXT - const out = MIME_EXT.get(item) - return out ? [out] : [] - }), - ), -).sort() - -export function filePickerFilters(ext?: string[]) { - if (!ext || ext.length === 0) return undefined - return [{ name: "Files", extensions: ext }] -} diff --git a/packages/app-bundle/overlay/packages/app/src/context/global-sync/home-session-index.test.ts b/packages/app-bundle/overlay/packages/app/src/context/global-sync/home-session-index.test.ts deleted file mode 100644 index 4e40cc78..00000000 --- a/packages/app-bundle/overlay/packages/app/src/context/global-sync/home-session-index.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { SessionV2Info } from "@opencode-ai/sdk/v2/client" -import { - applyHomeSessionEvent, - appendHomeSessionEvent, - HOME_V2_SESSION_PAGE_LIMIT, - loadHomeSessionIndex, - homeSessionIndexSessions, - homeSessionIndexRefresh, - parseHomeSessionIndex, - retainHomeSessions, -} from "./home-session-index" - -const session = (input: { - id: string - directory?: string - parentID?: string - archived?: number - updated?: number -}) => ({ - id: input.id, - parentID: input.parentID, - projectID: "project", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: input.updated ?? 1, archived: input.archived }, - title: input.id, - location: { directory: input.directory ?? "/project" }, -}) - -describe("Home V2 session index", () => { - test("loads the Home index with one global V2 request", async () => { - const calls: unknown[] = [] - const result = await loadHomeSessionIndex(async (input) => { - calls.push(input) - return { data: { data: [session({ id: "root" })], cursor: {} } } - }) - - expect(result.sessions).toHaveLength(1) - expect(calls).toEqual([{ limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }]) - }) - - test("loads subsequent pages until the session index is complete", async () => { - const calls: unknown[] = [] - const controller = new AbortController() - const result = await loadHomeSessionIndex( - async (input, options) => { - calls.push({ input, signal: options.signal }) - if (!("cursor" in input)) { - return { - data: { - data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => - session({ id: `page-1-${index}` }), - ), - cursor: { next: "next-page" }, - }, - } - } - return { data: { data: [session({ id: "page-2" })], cursor: {} } } - }, - 0, - controller.signal, - ) - - expect(result.sessions).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1) - expect(calls).toEqual([ - { input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, signal: controller.signal }, - { - input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" }, - signal: controller.signal, - }, - ]) - }) - - test("maps visible roots to Home session summaries", () => { - const activeNull = { - ...session({ id: "active-null", updated: 20 }), - time: { created: 1, updated: 20, archived: null }, - } as unknown as SessionV2Info - const result = parseHomeSessionIndex([ - session({ id: "root", updated: 30 }), - activeNull, - session({ id: "child", parentID: "root", updated: 40 }), - session({ id: "archived", archived: 50, updated: 50 }), - ]) - - expect(result).toEqual([ - expect.objectContaining({ - id: "root", - slug: "root", - version: "", - directory: "/project", - projectID: "project", - title: "root", - time: { created: 1, updated: 30 }, - }), - expect.objectContaining({ - id: "active-null", - time: { created: 1, updated: 20, archived: null }, - }), - ]) - }) - - test("preserves the per-directory Home retention limit", () => { - const now = 10 * 60 * 60 * 1000 - const sessions = Array.from({ length: 80 }, (_, index) => ({ - ...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0], - directory: index % 2 === 0 ? "/one" : "/two", - })) - - const retained = retainHomeSessions(sessions, 10, now) - expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10) - expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10) - }) - - test("replays session events over the loaded index", () => { - const initial = parseHomeSessionIndex([session({ id: "old" })]) - const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } } - - const afterCreate = applyHomeSessionEvent(initial, { - type: "session.created", - properties: { sessionID: created.id, info: created }, - }) - expect( - applyHomeSessionEvent(afterCreate, { - type: "session.deleted", - properties: { sessionID: initial[0]!.id, info: initial[0]! }, - }), - ).toEqual([created]) - }) - - test("applies only events newer than the index baseline", () => { - const initial = parseHomeSessionIndex([session({ id: "old" })]) - const stale = { ...initial[0], title: "stale" } - const current = { ...initial[0], title: "current" } - const first = appendHomeSessionEvent(undefined, { - type: "session.updated", - properties: { sessionID: stale.id, info: stale }, - }) - const events = appendHomeSessionEvent(first, { - type: "session.updated", - properties: { sessionID: current.id, info: current }, - }) - - expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current") - }) - - test("refetches after reconnect, disposal, and session moves", () => { - expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false }) - expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true }) - expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true) - expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true) - }) -}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/global-sync/home-session-index.ts b/packages/app-bundle/overlay/packages/app/src/context/global-sync/home-session-index.ts deleted file mode 100644 index 03a085e3..00000000 --- a/packages/app-bundle/overlay/packages/app/src/context/global-sync/home-session-index.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client" -import type { QueryClient } from "@tanstack/solid-query" -import { trimSessions } from "./session-trim" -import { pathKey } from "@/utils/path-key" - -export const HOME_V2_SESSION_PAGE_LIMIT = 5_000 - -export type HomeSessionEvent = { - type: "session.created" | "session.updated" | "session.deleted" - properties: { sessionID: string; info: Session } -} -export type HomeSessionEvents = { - sequence: number - entries: Array<{ sequence: number; event: HomeSessionEvent }> -} -export type HomeSessionIndex = { - sessions: Session[] - eventSequence: number -} - -export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const -export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const - -type HomeSessionPage = { data?: V2SessionListResponse } - -export async function loadHomeSessionIndex( - list: ( - input: { limit: number; order: "desc"; cursor?: string }, - options: { signal?: AbortSignal }, - ) => Promise, - eventSequence = 0, - signal?: AbortSignal, -) { - const data: SessionV2Info[] = [] - let cursor: string | undefined - - for (;;) { - const response = await list( - { - limit: HOME_V2_SESSION_PAGE_LIMIT, - order: "desc", - ...(cursor ? { cursor } : {}), - }, - { signal }, - ) - const page = response.data! - data.push(...page.data) - if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next) - return { sessions: parseHomeSessionIndex(data), eventSequence } - cursor = page.cursor.next - } -} - -export function appendHomeSessionEvent(current: HomeSessionEvents | undefined, event: HomeSessionEvent) { - const sequence = (current?.sequence ?? 0) + 1 - return { - sequence, - entries: [...(current?.entries ?? []), { sequence, event }], - } -} - -export function trimHomeSessionEvents(current: HomeSessionEvents | undefined, sequence: number): HomeSessionEvents { - return { - sequence: current?.sequence ?? sequence, - entries: (current?.entries ?? []).filter((entry) => entry.sequence > sequence), - } -} - -export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) { - if (!index) return [] - return (events?.entries ?? []) - .filter((entry) => entry.sequence > index.eventSequence) - .reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions) -} - -export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) { - if (event === "server.connected") return { connected: true, refetch: connected } - return { - connected, - refetch: event === "global.disposed" || event === "session.next.moved", - } -} - -export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) { - const indexKey = homeSessionIndexKey(server) - const eventsKey = homeSessionEventsKey(server) - let connected = false - - return { - indexKey, - eventsKey, - eventSequence() { - return queryClient.getQueryData(eventsKey)?.sequence ?? 0 - }, - complete(sequence: number) { - // Keep events received after the fetch began so its response cannot overwrite them. - queryClient.setQueryData(eventsKey, (current) => trimHomeSessionEvents(current, sequence)) - }, - sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) { - return homeSessionIndexSessions(index, events) - }, - apply(event: HomeSessionEvent) { - if (!queryClient.getQueryState(indexKey)) return - const next = appendHomeSessionEvent(queryClient.getQueryData(eventsKey), event) - if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) { - queryClient.setQueryData(eventsKey, next) - return - } - - const index = queryClient.getQueryData(indexKey) - if (index) { - queryClient.setQueryData(indexKey, { - sessions: homeSessionIndexSessions(index, next), - eventSequence: next.sequence, - }) - } - queryClient.setQueryData(eventsKey, { sequence: next.sequence, entries: [] }) - }, - refresh(event: Event["type"]) { - const result = homeSessionIndexRefresh(event, connected) - connected = result.connected - if (!result.refetch) return - void queryClient.refetchQueries({ queryKey: indexKey, exact: true, type: "active" }) - }, - } -} - -// TODO(v2): This deliberately dumb full-table scan is necessary because the -// current V2 API orders by creation time and cannot filter roots, archives, or -// multiple directories. A bounded page could omit an old session updated today. -// Once released, use client.v2.project.list() and client.v2.session.list({ -// parentID: null, order: "desc" }), then remove this adapter and its V1 fields. -export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] { - return sessions.flatMap((item) => { - if (item.parentID || typeof item.time.archived === "number") return [] - return [toLegacySummary(item)] - }) -} - -export function retainHomeSessions(sessions: Session[], limit: number, now: number) { - const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory)) - return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now })) -} - -export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) { - const info = event.properties.info - const index = sessions.findIndex((session) => session.id === info.id) - if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") { - if (index === -1) return sessions - return sessions.toSpliced(index, 1) - } - if (event.type !== "session.created" && event.type !== "session.updated") return sessions - if (index === -1) return [...sessions, info] - return sessions.with(index, info) -} - -function toLegacySummary(session: SessionV2Info): Session { - return { - id: session.id, - slug: session.id, - projectID: session.projectID, - workspaceID: session.location.workspaceID, - directory: session.location.directory, - path: session.subpath, - parentID: session.parentID, - cost: session.cost, - tokens: session.tokens, - title: session.title, - agent: session.agent, - model: session.model, - version: "", - time: session.time, - } -} diff --git a/packages/app-bundle/overlay/packages/app/src/context/global-sync/utils.test.ts b/packages/app-bundle/overlay/packages/app/src/context/global-sync/utils.test.ts deleted file mode 100644 index 69ca4949..00000000 --- a/packages/app-bundle/overlay/packages/app/src/context/global-sync/utils.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { - AgentListOutput, - ModelDefaultOutput, - ModelListOutput, - ProviderListOutput, -} from "@opencode-ai/client/promise" -import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils" - -describe("normalizeAgentList", () => { - test("adapts current agents to the app agent shape", () => { - const result = normalizeAgentList([ - { - id: "build", - name: "Build", - mode: "primary", - hidden: false, - color: "primary", - model: { id: "gpt-5", providerID: "openai", variant: "high" }, - request: { settings: { temperature: 0.2, topP: 0.9 }, headers: {}, body: {} }, - system: "Build software", - permissions: [{ action: "read", resource: "*", effect: "allow" }], - }, - ] as AgentListOutput["data"]) - - expect(result).toEqual([ - { - name: "build", - description: undefined, - mode: "primary", - hidden: false, - temperature: 0.2, - topP: 0.9, - color: "primary", - permission: [{ permission: "read", pattern: "*", action: "allow" }], - model: { providerID: "openai", modelID: "gpt-5" }, - variant: "high", - prompt: "Build software", - options: { temperature: 0.2, topP: 0.9 }, - steps: undefined, - }, - ]) - }) -}) - -describe("normalizePermissionRequest", () => { - test("adapts the current permission request to app state", () => { - expect( - normalizePermissionRequest({ - id: "permission-1", - sessionID: "session-1", - action: "read", - resources: ["README.md"], - save: ["*.md"], - metadata: { path: "README.md" }, - source: { type: "tool", messageID: "message-1", callID: "call-1" }, - }), - ).toEqual({ - id: "permission-1", - sessionID: "session-1", - permission: "read", - patterns: ["README.md"], - always: ["*.md"], - metadata: { path: "README.md" }, - tool: { messageID: "message-1", callID: "call-1" }, - }) - }) -}) - -describe("normalizeProviderList", () => { - test("groups current models into the app provider catalog", () => { - const result = normalizeProviderList( - [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"], - [ - { - id: "gpt-5", - modelID: "gpt-5", - providerID: "openai", - name: "GPT-5", - capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, - variants: [{ id: "high" }], - time: { released: 1 }, - cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }], - status: "active", - enabled: true, - limit: { context: 128_000, output: 8_192 }, - }, - { - id: "gpt-old", - modelID: "gpt-old", - providerID: "openai", - name: "GPT Old", - capabilities: { tools: false, input: ["text"], output: ["text"] }, - variants: [], - time: { released: 0 }, - cost: [], - status: "deprecated", - enabled: true, - limit: { context: 1, output: 1 }, - }, - ] as ModelListOutput["data"], - { id: "gpt-5", providerID: "openai" } as ModelDefaultOutput["data"], - ) - - expect(result.connected).toEqual(["openai"]) - expect(result.default).toEqual({ openai: "gpt-5" }) - expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined() - expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({ - id: "gpt-5", - providerID: "openai", - capabilities: { toolcall: true, attachment: true }, - cost: { input: 1, output: 2 }, - variants: { high: {} }, - }) - }) -}) - -describe("directoryKey", () => { - test("normalizes slashes", () => { - expect(String(directoryKey("C:\\Repos\\sst\\opencode"))).toBe("C:/Repos/sst/opencode") - expect(String(directoryKey("C:/Repos/sst/opencode"))).toBe("C:/Repos/sst/opencode") - }) - - test("preserves backslashes in posix paths", () => { - expect(String(directoryKey("/tmp/foo\\bar"))).toBe("/tmp/foo\\bar") - }) - - test("trims trailing slashes without breaking roots", () => { - expect(String(directoryKey("C:/Repos/sst/opencode/"))).toBe("C:/Repos/sst/opencode") - expect(String(directoryKey("C:/"))).toBe("C:/") - expect(String(directoryKey("/"))).toBe("/") - }) -}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/global-sync/utils.ts b/packages/app-bundle/overlay/packages/app/src/context/global-sync/utils.ts deleted file mode 100644 index 59632e53..00000000 --- a/packages/app-bundle/overlay/packages/app/src/context/global-sync/utils.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { - AgentListOutput, - ModelDefaultOutput, - ModelListOutput, - PermissionV2Request, - ProviderListOutput, -} from "@opencode-ai/client/promise" -import type { Agent, PermissionRequest, Project, Provider, ProviderListResponse } from "@opencode-ai/sdk/v2/client" -import type { Project as CurrentProject } from "@opencode-ai/client/promise" -import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" -export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key" - -export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) - -export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] { - if (input.every((agent) => !("request" in agent))) return input as Agent[] - return (input as AgentListOutput["data"]).map((agent) => ({ - name: agent.id, - description: agent.description, - mode: agent.mode, - hidden: agent.hidden, - temperature: - typeof agent.request.settings.temperature === "number" ? agent.request.settings.temperature : undefined, - topP: typeof agent.request.settings.topP === "number" ? agent.request.settings.topP : undefined, - color: agent.color, - permission: agent.permissions.map((rule) => ({ - permission: rule.action, - pattern: rule.resource, - action: rule.effect, - })), - model: agent.model && { providerID: agent.model.providerID, modelID: agent.model.id }, - variant: agent.model?.variant, - prompt: agent.system, - options: agent.request.settings, - steps: agent.steps, - })) -} - -export function normalizePermissionRequest(input: PermissionV2Request | PermissionRequest): PermissionRequest { - if ("permission" in input) return input - return { - id: input.id, - sessionID: input.sessionID, - permission: input.action, - patterns: input.resources, - always: input.save ?? [], - metadata: input.metadata ?? {}, - tool: - input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined, - } -} - -export function normalizeProviderList( - providers: ProviderListOutput["data"] | ProviderListResponse, - models?: ModelListOutput["data"], - defaultModel?: ModelDefaultOutput["data"], -): NormalizedProviderListResponse { - if (!Array.isArray(providers)) { - return { - ...providers, - all: new Map( - providers.all.map((provider) => [ - provider.id, - { - ...provider, - models: Object.fromEntries( - Object.entries(provider.models).filter(([, model]) => model.status !== "deprecated"), - ), - }, - ]), - ), - } - } - const all = new Map() - - for (const provider of providers) { - all.set(provider.id, { - id: provider.id, - name: provider.name, - source: "custom", - env: [], - options: provider.settings ?? {}, - models: {}, - }) - } - - for (const model of models ?? []) { - const provider = all.get(model.providerID) - if (!provider || model.status === "deprecated") continue - const cost = model.cost.find((item) => item.tier === undefined) ?? model.cost[0] - provider.models[model.id] = { - id: model.id, - providerID: model.providerID, - api: { - id: model.modelID, - url: "", - npm: model.package ?? provider.id, - }, - name: model.name, - family: model.family, - capabilities: { - temperature: false, - reasoning: false, - attachment: model.capabilities.input.some((item) => item !== "text"), - toolcall: model.capabilities.tools, - input: { - text: model.capabilities.input.includes("text"), - audio: model.capabilities.input.includes("audio"), - image: model.capabilities.input.includes("image"), - video: model.capabilities.input.includes("video"), - pdf: model.capabilities.input.includes("pdf"), - }, - output: { - text: model.capabilities.output.includes("text"), - audio: model.capabilities.output.includes("audio"), - image: model.capabilities.output.includes("image"), - video: model.capabilities.output.includes("video"), - pdf: model.capabilities.output.includes("pdf"), - }, - interleaved: false, - }, - cost: { - input: cost?.input ?? 0, - output: cost?.output ?? 0, - cache: { - read: cost?.cache.read ?? 0, - write: cost?.cache.write ?? 0, - }, - }, - limit: model.limit, - status: model.status, - options: model.settings ?? {}, - headers: model.headers ?? {}, - release_date: new Date(model.time.released).toISOString().slice(0, 10), - variants: Object.fromEntries(model.variants.map((variant) => [variant.id, variant.settings ?? {}])), - } - } - - return { - all, - connected: providers.map((provider) => provider.id), - default: Object.fromEntries( - providers.flatMap((provider) => { - const model = - defaultModel?.providerID === provider.id - ? defaultModel - : models?.find((item) => item.providerID === provider.id && item.status !== "deprecated") - return model ? [[provider.id, model.id]] : [] - }), - ), - } -} - -export function sanitizeProject(project: Project) { - if (!project.icon?.url && !project.icon?.override) return project - return { - ...project, - icon: { - ...project.icon, - url: undefined, - override: undefined, - }, - } -} - -export function normalizeProjectInfo(project: Project | CurrentProject): Project { - return { - ...project, - vcs: project.vcs === "git" ? "git" : undefined, - } -} diff --git a/packages/app-bundle/overlay/packages/app/src/context/prompt-state.ts b/packages/app-bundle/overlay/packages/app/src/context/prompt-state.ts deleted file mode 100644 index ea639a5b..00000000 --- a/packages/app-bundle/overlay/packages/app/src/context/prompt-state.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { checksum } from "@opencode-ai/core/util/encode" -import type { FilePartSource } from "@opencode-ai/sdk/v2/client" -import { batch, createMemo, type Accessor } from "solid-js" -import { createStore, type SetStoreFunction } from "solid-js/store" -import type { FileSelection } from "@/context/file" -import { Persist, persisted } from "@/utils/persist" -import type { ServerScope } from "@/utils/server-scope" -import type { BlobReference } from "@/utils/draft-store" - -interface PartBase { - content: string - start: number - end: number -} - -export interface TextPart extends PartBase { - type: "text" -} - -export interface FileAttachmentPart extends PartBase { - type: "file" - path: string - selection?: FileSelection - mime?: string - filename?: string - url?: string - source?: FilePartSource -} - -export interface AgentPart extends PartBase { - type: "agent" - name: string -} - -export interface ImageAttachmentPart { - type: "image" - id: string - filename: string - sourcePath?: string - mime: string - blob: BlobReference -} - -export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart -export type Prompt = ContentPart[] - -export type PromptModel = { - providerID: string - modelID: string - variant?: string | null -} - -export type FileContextItem = { - type: "file" - path: string - selection?: FileSelection - comment?: string - commentID?: string - commentOrigin?: "review" | "file" - preview?: string -} - -export type ContextItem = FileContextItem -export type PromptScope = { draftID: string } | { dir: string; id?: string } - -export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] - -export type PromptStore = { - prompt: Prompt - cursor?: number - model?: PromptModel - context: { - items: (ContextItem & { key: string })[] - } -} - -type InitialPrompt = { - prompt?: string - model?: PromptModel -} - -function isSelectionEqual(a?: FileSelection, b?: FileSelection) { - if (!a && !b) return true - if (!a || !b) return false - return ( - a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar - ) -} - -function isPartEqual(partA: ContentPart, partB: ContentPart) { - switch (partA.type) { - case "text": - return partB.type === "text" && partA.content === partB.content - case "file": - return ( - partB.type === "file" && - partA.path === partB.path && - partA.mime === partB.mime && - partA.filename === partB.filename && - isSelectionEqual(partA.selection, partB.selection) - ) - case "agent": - return partB.type === "agent" && partA.name === partB.name - case "image": - return partB.type === "image" && partA.id === partB.id - } -} - -export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { - if (promptA.length !== promptB.length) return false - for (let i = 0; i < promptA.length; i++) { - if (!isPartEqual(promptA[i], promptB[i])) return false - } - return true -} - -function cloneSelection(selection?: FileSelection) { - if (!selection) return undefined - return { ...selection } -} - -function clonePart(part: ContentPart): ContentPart { - if (part.type === "text") return { ...part } - if (part.type === "image") return { ...part } - if (part.type === "agent") return { ...part } - return { - ...part, - selection: cloneSelection(part.selection), - } -} - -function clonePrompt(prompt: Prompt): Prompt { - return prompt.map(clonePart) -} - -function contextItemKey(item: ContextItem) { - if (item.type !== "file") return item.type - const start = item.selection?.startLine - const end = item.selection?.endLine - const key = `${item.type}:${item.path}:${start}:${end}` - - if (item.commentID) return `${key}:c=${item.commentID}` - const comment = item.comment?.trim() - if (!comment) return key - const digest = checksum(comment) ?? comment - return `${key}:c=${digest.slice(0, 8)}` -} - -export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { - return item.type === "file" && !!item.comment?.trim() -} - -function createPromptActions(setStore: SetStoreFunction) { - return { - set(prompt: Prompt, cursorPosition?: number) { - const next = clonePrompt(prompt) - batch(() => { - setStore("prompt", next) - if (cursorPosition !== undefined) setStore("cursor", cursorPosition) - }) - }, - reset() { - batch(() => { - setStore("prompt", clonePrompt(DEFAULT_PROMPT)) - setStore("cursor", 0) - }) - }, - } -} - -function promptTarget(serverScope: ServerScope, scope: PromptScope) { - if ("draftID" in scope) return Persist.prompt(Persist.draft(scope.draftID, "prompt")) - const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` - return Persist.prompt(Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])) -} - -function promptStore(initial?: InitialPrompt): PromptStore { - const text = initial?.prompt - return { - prompt: - text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }], - cursor: text === undefined ? undefined : text.length, - model: initial?.model ? { ...initial.model } : undefined, - context: { - items: [], - }, - } -} - -function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction) { - const actions = createPromptActions(setStore) - const value = { - store: [() => store, setStore] as [Accessor, SetStoreFunction], - current: () => store.prompt, - cursor: createMemo(() => store.cursor), - dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), - model: { - current: () => store.model, - set: (model: PromptModel | undefined) => setStore("model", model), - }, - context: { - items: createMemo(() => store.context.items), - add(item: ContextItem) { - const key = contextItemKey(item) - if (store.context.items.find((x) => x.key === key)) return - setStore("context", "items", (items) => [...items, { key, ...item }]) - }, - remove(key: string) { - setStore("context", "items", (items) => items.filter((x) => x.key !== key)) - }, - removeComment(path: string, commentID: string) { - setStore("context", "items", (items) => - items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)), - ) - }, - updateComment(path: string, commentID: string, next: Partial & { comment?: string }) { - setStore("context", "items", (items) => - items.map((item) => { - if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item - const value = { ...item, ...next } - return { ...value, key: contextItemKey(value) } - }), - ) - }, - replaceComments(items: FileContextItem[]) { - setStore("context", "items", (current) => [ - ...current.filter((item) => !isCommentItem(item)), - ...items.map((item) => ({ ...item, key: contextItemKey(item) })), - ]) - }, - }, - set: actions.set, - reset: actions.reset, - capture: () => value, - } - return value -} - -function createPersistedPrompt(target: ReturnType, initial?: InitialPrompt) { - const [store, setStore, _, ready] = persisted(target, createStore(promptStore(initial))) - return { ready, ...createPromptStateValue(store, setStore) } -} - -export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) { - return createPersistedPrompt(promptTarget(serverScope, scope), initial) -} - -export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) { - return createPersistedPrompt(Persist.prompt(Persist.draft(draftID, "prompt")), initial) -} - -export type PromptSession = ReturnType - -export function createPromptReady(session: Accessor) { - return Object.defineProperty(() => session().ready(), "promise", { - get: () => session().ready.promise, - }) as (() => boolean) & { readonly promise: Promise | undefined } -} - -export function createPromptState(initial?: InitialPrompt) { - const [store, setStore] = createStore(promptStore(initial)) - const ready = Object.assign(() => true, { promise: Promise.resolve(true) }) - return { - ready, - ...createPromptStateValue(store, setStore), - } -} diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-session.test.ts b/packages/app-bundle/overlay/packages/app/src/context/server-session.test.ts deleted file mode 100644 index e9595e13..00000000 --- a/packages/app-bundle/overlay/packages/app/src/context/server-session.test.ts +++ /dev/null @@ -1,1641 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { retry } from "@opencode-ai/core/util/retry" -import type { OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise" -import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client" -import { createServerSession } from "./server-session" -import type { ServerApi } from "@/utils/server" - -type MessageApi = ServerApi["message"] - -const session = (id: string, parentID?: string): Session => ({ - id, - slug: id, - projectID: "project", - directory: "/repo", - title: id, - version: "1", - parentID, - time: { created: 1, updated: 1 }, -}) - -type UserMessage = Extract -type AssistantMessage = Extract -type TextPart = Extract -type MessageResponse = { - data: { info: Message; parts: Part[] }[] - response: { headers: Headers } -} -type SingleMessageResponse = { data: MessageResponse["data"][number] } - -const userMessage = (id: string, input: Partial = {}): UserMessage => ({ - id, - sessionID: "child", - role: "user", - time: { created: 1 }, - agent: "build", - model: { providerID: "provider", modelID: "model" }, - ...input, -}) - -const assistantMessage = (id: string, parentID: string, input: Partial = {}): AssistantMessage => ({ - id, - sessionID: "child", - role: "assistant", - time: { created: Number(id.at(-1)), completed: Number(id.at(-1)) }, - parentID, - modelID: "model", - providerID: "provider", - mode: "build", - agent: "build", - path: { cwd: "/repo", root: "/repo" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - ...input, -}) - -const textPart = (messageID: string, input: Partial = {}): TextPart => ({ - id: "part", - sessionID: "child", - messageID, - type: "text", - text: "text", - ...input, -}) - -const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({ - data, - response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) }, -}) - -const singleResponse = (info: Message, parts: Part[] = []): SingleMessageResponse => ({ data: { info, parts } }) - -const deferredResponse = () => Promise.withResolvers() - -function messageClient(...responses: Array>) { - let index = 0 - const requests: unknown[] = [] - const waiting = new Map void>() - const client = { - session: { - get: async () => ({ data: session("child", "root") }), - messages: (input: unknown) => { - requests.push(input) - waiting.get(requests.length)?.() - waiting.delete(requests.length) - return responses[index++] - }, - }, - } as unknown as OpencodeClient - return Object.assign(client, { - requests, - requested(count: number) { - if (requests.length >= count) return Promise.resolve() - return new Promise((resolve) => waiting.set(count, resolve)) - }, - }) -} - -function rootMessageClient( - pages: Array>, - roots: Array>, -) { - let pageIndex = 0 - let rootIndex = 0 - const requests: unknown[] = [] - const rootRequests: unknown[] = [] - const rootWaiting = new Map void>() - const client = { - session: { - get: async () => ({ data: session("child", "root") }), - messages: (input: unknown) => { - requests.push(input) - return pages[pageIndex++] - }, - message: (input: unknown) => { - rootRequests.push(input) - rootWaiting.get(rootRequests.length)?.() - rootWaiting.delete(rootRequests.length) - return roots[rootIndex++] - }, - }, - } as unknown as OpencodeClient - return Object.assign(client, { - requests, - rootRequests, - rootRequested(count: number) { - if (rootRequests.length >= count) return Promise.resolve() - return new Promise((resolve) => rootWaiting.set(count, resolve)) - }, - }) -} - -const retryImmediately: typeof retry = async (task, options = {}) => { - const attempts = options.attempts ?? 3 - for (let attempt = 0; ; attempt++) { - try { - return await task() - } catch (error) { - if (attempt === attempts - 1) throw error - } - } -} - -function setup(sessions: Record) { - const get: unknown[] = [] - const messages: unknown[] = [] - const client = { - session: { - get: async (input: unknown) => { - get.push(input) - const id = (input as { sessionID: string }).sessionID - return { data: sessions[id] } - }, - messages: async (input: unknown) => { - messages.push(input) - return response() - }, - diff: async () => ({ data: [] }), - todo: async () => ({ data: [] }), - }, - } as unknown as OpencodeClient - return { get, messages, store: createServerSession(client) } -} - -describe("server session", () => { - test("projects V2 session events into current and legacy message state", () => { - const ctx = setup({ child: session("child") }) - ctx.store.remember(session("child")) - ctx.store.set("session_message", "child", [ - { - id: "msg_1_user", - type: "user", - text: "hello", - time: { created: 1 }, - }, - ]) - const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent) - - apply({ - id: "evt_step", - created: 2, - type: "session.step.started", - durable: { aggregateID: "child", seq: 1, version: 1 }, - location: { directory: "/repo" }, - data: { - sessionID: "child", - assistantMessageID: "msg_2_assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - }, - }) - apply({ - id: "evt_text_start", - created: 3, - type: "session.text.started", - durable: { aggregateID: "child", seq: 2, version: 1 }, - location: { directory: "/repo" }, - data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0 }, - }) - apply({ - id: "evt_text_delta", - created: 4, - type: "session.text.delta", - location: { directory: "/repo" }, - data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" }, - }) - - expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({ - id: "msg_2_assistant", - type: "assistant", - content: [{ type: "text", text: "world" }], - }) - expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"]) - expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }]) - }) - - test("resolves lineage by session ID without directory", async () => { - const ctx = setup({ child: session("child", "root"), root: session("root") }) - - const result = await ctx.store.lineage.resolve("child") - - expect(result.root.id).toBe("root") - expect(ctx.get).toEqual([{ sessionID: "child" }, { sessionID: "root" }]) - expect(ctx.store.lineage.peek("child")).toEqual(result) - }) - - test("loads session content through the server client", async () => { - const ctx = setup({ root: session("root") }) - - await ctx.store.sync("root") - - expect(ctx.get).toEqual([{ sessionID: "root" }]) - expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }]) - expect(ctx.store.data.message.root).toEqual([]) - }) - - test("loads current session content through the current message API", async () => { - const requests: unknown[] = [] - const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } } - const assistant = { - id: "msg_a_assistant", - type: "assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [{ type: "text", text: "hi" }], - time: { created: 2, completed: 3 }, - } - const client = { - session: { - messages: () => { - throw new Error("legacy message endpoint called") - }, - }, - } as unknown as OpencodeClient - const messageApi = { - list: async (input: unknown) => { - requests.push(input) - return { data: [assistant, user], cursor: { previous: null, next: null } } - }, - } as unknown as MessageApi - const store = createServerSession(client, {} as SessionApi, messageApi) - store.remember(session("root")) - - await store.sync("root") - - expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }]) - expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) - }) - - test("extends a current page to include the user for split assistant turns", async () => { - const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const - const assistant = (id: string, created: number) => ({ - id, - type: "assistant" as const, - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [{ type: "text" as const, text: id }], - time: { created, completed: created }, - }) - const assistants = [ - assistant("msg_2_assistant", 2), - assistant("msg_3_assistant", 3), - assistant("msg_4_assistant", 4), - ] - const pages = [ - { data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } }, - { data: [assistants[0], user], cursor: { previous: null, next: null } }, - ] - const requests: unknown[] = [] - const messageApi = { - list: async (input: unknown) => { - requests.push(input) - return pages.shift()! - }, - } as unknown as MessageApi - const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi) - store.remember(session("root")) - - await store.sync("root") - - expect(requests).toEqual([ - { sessionID: "root", limit: 20, order: "desc" }, - { sessionID: "root", limit: 20, cursor: "older" }, - ]) - expect(store.data.message.root.map((message) => message.id)).toEqual([ - user.id, - ...assistants.map((item) => item.id), - ]) - expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"]) - }) - - test("indexes V1 messages for the current timeline projection", async () => { - const user = userMessage("message-1", { sessionID: "root" }) - const assistant = assistantMessage("message-2", user.id, { sessionID: "root" }) - const client = messageClient( - response([ - { info: user, parts: [textPart(user.id, { sessionID: "root" })] }, - { info: assistant, parts: [textPart(assistant.id, { sessionID: "root" })] }, - ]), - ) - const messageApi = { - list: () => { - throw new Error("current message endpoint called") - }, - } as unknown as MessageApi - const store = createServerSession(client, {} as SessionApi, messageApi, { - protocol: Promise.resolve("v1"), - }) - store.remember(session("root")) - - await store.sync("root") - - expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) - expect(store.data.session_message.root).toMatchObject([ - { id: user.id, type: "user", text: "text" }, - { id: assistant.id, type: "assistant" }, - ]) - - const next = userMessage("message-3", { sessionID: "root" }) - store.apply({ type: "message.updated", properties: { info: next } }) - expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id, next.id]) - - store.apply({ type: "message.removed", properties: { sessionID: "root", messageID: next.id } }) - expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) - }) - - test("backfills an assistant-only initial page through its user root", async () => { - const user = userMessage("message-1") - const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] - const client = rootMessageClient( - [ - response( - assistants.map((info) => ({ info, parts: [] })), - "older", - ), - ], - [singleResponse(user)], - ) - const store = createServerSession(client) - - await store.sync("child") - - expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }]) - expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }]) - expect(store.data.message.child).toEqual([user, ...assistants]) - expect(store.history.more("child")).toBe(true) - }) - - test("keeps assistant history when its deleted parent cannot be backfilled", async () => { - const missing = Promise.withResolvers() - const assistant = assistantMessage("message-2", "message-missing") - const client = rootMessageClient([response([{ info: assistant, parts: [] }], "older")], [missing.promise]) - const store = createServerSession(client) - const loading = store.sync("child") - await client.rootRequested(1) - - missing.reject(new Error("Message not found: message-missing", { cause: { status: 404 } })) - await loading - - expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }]) - expect(store.data.message.child).toEqual([assistant]) - expect(store.history.more("child")).toBe(true) - }) - - test("drops a cached parent when a forced refresh confirms it was deleted", async () => { - const missing = Promise.withResolvers() - const parent = userMessage("message-1") - const part = textPart(parent.id) - const assistant = assistantMessage("message-2", parent.id) - const client = rootMessageClient( - [ - response([ - { info: parent, parts: [part] }, - { info: assistant, parts: [] }, - ]), - response([{ info: assistant, parts: [] }], "older"), - ], - [missing.promise], - ) - const store = createServerSession(client) - await store.sync("child") - const loading = store.sync("child", { force: true }) - await client.rootRequested(1) - - missing.reject(new Error(`Message not found: ${parent.id}`, { cause: { status: 404 } })) - await loading - - expect(store.data.message.child).toEqual([assistant]) - expect(store.data.part[parent.id]).toBeUndefined() - }) - - test("does not let an optimistic user suppress initial root backfill", async () => { - const user = userMessage("message-1") - const part = textPart(user.id) - const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] - const client = rootMessageClient( - [ - response( - assistants.map((info) => ({ info, parts: [] })), - "older", - ), - ], - [singleResponse(user)], - ) - const store = createServerSession(client) - store.optimistic.add({ sessionID: "child", message: user, parts: [part] }) - - await store.sync("child") - store.optimistic.remove({ sessionID: "child", messageID: user.id }) - - expect(client.requests).toHaveLength(1) - expect(client.rootRequests).toHaveLength(1) - expect(store.data.message.child).toEqual([user, ...assistants]) - }) - - test("backfills the parent of fetched assistants when another user is cached", async () => { - const unrelated = userMessage("message-0", { time: { created: 0 } }) - const user = userMessage("message-1") - const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] - const client = rootMessageClient( - [ - response([{ info: unrelated, parts: [] }]), - response( - assistants.map((info) => ({ info, parts: [] })), - "older", - ), - ], - [singleResponse(user)], - ) - const store = createServerSession(client) - await store.sync("child") - - await store.sync("child", { force: true }) - - expect(client.requests).toHaveLength(2) - expect(client.rootRequests).toHaveLength(1) - expect(store.data.message.child).toEqual([unrelated, user, ...assistants]) - }) - - test("preserves cached history between an injected parent and the page boundary", async () => { - const user = userMessage("message-1") - const cached = userMessage("message-3", { time: { created: 3 } }) - const assistant = assistantMessage("message-4", user.id) - const client = rootMessageClient( - [response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }], "older")], - [singleResponse(user)], - ) - const store = createServerSession(client) - await store.sync("child") - - await store.sync("child", { force: true }) - - expect(store.data.message.child).toEqual([user, cached, assistant]) - }) - - test("refreshes a cached parent omitted by an assistant-only replacement page", async () => { - const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } }) - const fresh = { ...stale, summary: { title: "fresh", diffs: [] } } - const stalePart = textPart(stale.id, { text: "stale" }) - const freshPart = { ...stalePart, text: "fresh" } - const assistant = assistantMessage("message-2", stale.id) - const client = rootMessageClient( - [response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }], "older")], - [singleResponse(fresh, [freshPart])], - ) - const store = createServerSession(client) - await store.sync("child") - - await store.sync("child", { force: true }) - - expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }]) - expect(store.data.message.child).toEqual([fresh, assistant]) - expect(store.data.part[stale.id]).toEqual([freshPart]) - }) - - test("refreshes a confirmed optimistic parent while preserving pending parts", async () => { - const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } }) - const fresh = { ...stale, summary: { title: "fresh", diffs: [] } } - const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" }) - const refreshed = { ...confirmed, text: "fresh" } - const pending = textPart(stale.id, { id: "pending", text: "pending" }) - const assistant = assistantMessage("message-2", stale.id) - const client = rootMessageClient( - [response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }], "older")], - [singleResponse(fresh, [refreshed])], - ) - const store = createServerSession(client) - store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] }) - await store.sync("child") - - await store.sync("child", { force: true }) - - expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }]) - expect(store.data.message.child).toEqual([fresh, assistant]) - expect(store.data.part[stale.id]).toEqual([refreshed, pending]) - }) - - test("uses a parent received by SSE during the replacement load", async () => { - const pending = deferredResponse() - const user = userMessage("message-1") - const assistant = assistantMessage("message-2", user.id) - const client = rootMessageClient([pending.promise], []) - const store = createServerSession(client) - const loading = store.sync("child") - - store.apply({ type: "message.updated", properties: { info: user } }) - pending.resolve(response([{ info: assistant, parts: [] }], "older")) - await loading - - expect(client.rootRequests).toEqual([]) - expect(store.data.message.child).toEqual([user, assistant]) - }) - - test("uses a successful retry over events received by a failed backfill attempt", async () => { - const failed = deferredResponse() - const user = userMessage("message-1") - const live = { ...user, agent: "stale" } - const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] - const client = rootMessageClient( - [ - response( - assistants.map((info) => ({ info, parts: [] })), - "older", - ), - ], - [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], - ) - const store = createServerSession(client, { retry: retryImmediately }) - const loading = store.sync("child") - await client.rootRequested(1) - - store.apply({ type: "message.updated", properties: { info: live } }) - failed.reject(new Error("retry")) - await loading - - expect(client.requests).toHaveLength(1) - expect(client.rootRequests).toHaveLength(2) - expect(store.data.message.child).toEqual([user, ...assistants]) - }) - - test("preserves newer-page events across a failed parent retry", async () => { - const failed = deferredResponse() - const user = userMessage("message-1") - const assistant = assistantMessage("message-2", user.id) - const live = { ...assistant, cost: 1 } - const client = rootMessageClient( - [response([{ info: assistant, parts: [] }], "older")], - [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], - ) - const store = createServerSession(client, { retry: retryImmediately }) - const loading = store.sync("child") - await client.rootRequested(1) - - store.apply({ type: "message.updated", properties: { info: live } }) - failed.reject(new Error("retry")) - await loading - - expect(store.data.message.child).toEqual([user, live]) - }) - - test("preserves unrelated message events across a failed parent retry", async () => { - const failed = deferredResponse() - const user = userMessage("message-1") - const assistant = assistantMessage("message-2", user.id) - const live = userMessage("message-4", { time: { created: 4 } }) - const client = rootMessageClient( - [response([{ info: assistant, parts: [] }], "older")], - [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], - ) - const store = createServerSession(client, { retry: retryImmediately }) - const loading = store.sync("child") - await client.rootRequested(1) - - store.apply({ type: "message.updated", properties: { info: live } }) - failed.reject(new Error("retry")) - await loading - - expect(store.data.message.child).toEqual([user, assistant, live]) - }) - - test("preserves newer-page part events across a failed parent retry", async () => { - const failed = deferredResponse() - const user = userMessage("message-1") - const assistant = assistantMessage("message-2", user.id) - const stale = textPart(assistant.id, { text: "stale" }) - const live = { ...stale, text: "live" } - const client = rootMessageClient( - [response([{ info: assistant, parts: [stale] }], "older")], - [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], - ) - const store = createServerSession(client, { retry: retryImmediately }) - const loading = store.sync("child") - await client.rootRequested(1) - - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } }) - failed.reject(new Error("retry")) - await loading - - expect(store.data.part[assistant.id]).toEqual([live]) - }) - - test("merges live events into the initial page", async () => { - const pending = deferredResponse() - const user = userMessage("message-1") - const live = userMessage("message-2", { time: { created: 2 } }) - const livePart = textPart(live.id, { text: "live" }) - const store = createServerSession(messageClient(pending.promise)) - const loading = store.sync("child") - - store.apply({ type: "message.updated", properties: { info: live } }) - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: livePart, time: 2 } }) - pending.resolve(response([{ info: user, parts: [] }])) - await loading - - expect(store.data.message.child).toEqual([user, live]) - expect(store.data.part[live.id]).toEqual([livePart]) - }) - - test("preserves same-ID live updates over the initial page", async () => { - const pending = deferredResponse() - const fetched = userMessage("message") - const fetchedPart = textPart(fetched.id, { text: "fetched" }) - const live = { ...fetched, time: { created: 2 } } - const livePart = { ...fetchedPart, text: "live" } - const store = createServerSession(messageClient(pending.promise)) - const loading = store.sync("child") - - store.apply({ type: "message.updated", properties: { info: live } }) - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: livePart, time: 2 } }) - pending.resolve(response([{ info: fetched, parts: [fetchedPart] }])) - await loading - - expect(store.data.message.child).toEqual([live]) - expect(store.data.part[live.id]).toEqual([livePart]) - }) - - test("preserves removals received during the initial load", async () => { - const pending = deferredResponse() - const removed = userMessage("message-1") - const kept = { ...removed, id: "message-2" } - const part = textPart(kept.id, { text: "removed" }) - const store = createServerSession(messageClient(pending.promise)) - const loading = store.sync("child") - - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: removed.id } }) - store.apply({ - type: "message.part.removed", - properties: { sessionID: "child", messageID: kept.id, partID: part.id }, - }) - pending.resolve( - response([ - { info: removed, parts: [] }, - { info: kept, parts: [part] }, - ]), - ) - await loading - - expect(store.data.message.child).toEqual([kept]) - expect(store.data.part[kept.id]).toBeUndefined() - }) - - test("keeps removal tracking isolated across load generations", async () => { - const firstResponse = deferredResponse() - const secondResponse = deferredResponse() - const message = userMessage("message") - const store = createServerSession(messageClient(firstResponse.promise, secondResponse.promise)) - const first = store.sync("child") - - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - store.apply({ - type: "session.deleted", - properties: { sessionID: "child", info: session("child", "root") }, - }) - const second = store.sync("child") - - firstResponse.resolve(response()) - await first - secondResponse.resolve(response([{ info: message, parts: [] }])) - await second - - expect(store.data.message.child).toEqual([message]) - }) - - test("tracks removals in a replacement load generation", async () => { - const firstResponse = deferredResponse() - const secondResponse = deferredResponse() - const message = userMessage("message") - const store = createServerSession(messageClient(firstResponse.promise, secondResponse.promise)) - const first = store.sync("child") - store.apply({ - type: "session.deleted", - properties: { sessionID: "child", info: session("child", "root") }, - }) - const second = store.sync("child") - - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - firstResponse.resolve(response()) - await first - secondResponse.resolve(response([{ info: message, parts: [] }])) - await second - - expect(store.data.message.child).toEqual([]) - }) - - test("preserves remove then re-add when a refresh omits the message", async () => { - const pending = deferredResponse() - const message = userMessage("message") - const store = createServerSession(messageClient(response([{ info: message, parts: [] }]), pending.promise)) - await store.sync("child") - const refreshing = store.sync("child", { force: true }) - - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - store.apply({ type: "message.updated", properties: { info: message } }) - pending.resolve(response()) - await refreshing - - expect(store.data.message.child).toEqual([message]) - }) - - test("preserves a re-added message without restoring removed parts", async () => { - const pending = deferredResponse() - const message = userMessage("message") - const part = textPart(message.id, { text: "stale" }) - const store = createServerSession(messageClient(response([{ info: message, parts: [] }]), pending.promise)) - await store.sync("child") - const refreshing = store.sync("child", { force: true }) - - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - store.apply({ type: "message.updated", properties: { info: message } }) - pending.resolve(response([{ info: message, parts: [part] }])) - await refreshing - - expect(store.data.message.child).toEqual([message]) - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("preserves optimistic parts re-added after removal during a refresh", async () => { - const pending = deferredResponse() - const message = userMessage("message") - const stale = textPart(message.id, { id: "stale", text: "stale" }) - const part = textPart(message.id, { id: "optimistic", text: "optimistic" }) - const store = createServerSession( - messageClient(response([{ info: message, parts: [] }]), pending.promise, response()), - ) - await store.sync("child") - const refreshing = store.sync("child", { force: true }) - - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - store.optimistic.add({ sessionID: "child", message, parts: [part] }) - pending.resolve(response([{ info: message, parts: [stale] }])) - await refreshing - - expect(store.data.message.child).toEqual([message]) - expect(store.data.part[message.id]).toEqual([part]) - - await store.sync("child", { force: true }) - expect(store.data.message.child).toEqual([message]) - expect(store.data.part[message.id]).toEqual([part]) - }) - - test("drops stale event content omitted by a complete initial page", async () => { - const stale = userMessage("stale") - const store = createServerSession(messageClient(response())) - store.apply({ type: "message.updated", properties: { info: stale } }) - - await store.sync("child") - - expect(store.data.message.child).toEqual([]) - }) - - test("preserves event content outside an incomplete initial page", async () => { - const live = userMessage("message-1") - const fetched = userMessage("message-2", { time: { created: 2 } }) - const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }], "older"))) - store.apply({ type: "message.updated", properties: { info: live } }) - - await store.sync("child") - - expect(store.data.message.child).toEqual([live, fetched]) - }) - - test("does not restore removed optimistic content on refresh", async () => { - const message = userMessage("message") - const part = textPart(message.id, { text: "removed" }) - const kept = { ...message, id: "kept" } - const keptPart = { ...part, id: "kept-part", messageID: kept.id } - const store = createServerSession(messageClient(response([{ info: kept, parts: [] }]))) - store.optimistic.add({ sessionID: "child", message, parts: [part] }) - store.optimistic.add({ sessionID: "child", message: kept, parts: [keptPart] }) - - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - store.apply({ - type: "message.part.removed", - properties: { sessionID: "child", messageID: kept.id, partID: keptPart.id }, - }) - await store.sync("child", { force: true }) - - expect(store.data.message.child).toEqual([kept]) - expect(store.data.part[message.id]).toBeUndefined() - expect(store.data.part[kept.id]).toBeUndefined() - }) - - test("replaces confirmed optimistic content with the initial page", async () => { - const optimistic = userMessage("message") - const fetched = { ...optimistic, time: { created: 2 } } - const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }]))) - store.optimistic.add({ sessionID: "child", message: optimistic, parts: [] }) - - await store.sync("child") - - expect(store.data.message.child).toEqual([fetched]) - }) - - test("replaces a confirmed optimistic part with fetched content", async () => { - const pending = deferredResponse() - const message = userMessage("message") - const optimistic = textPart(message.id, { text: "optimistic" }) - const fetched = { ...optimistic, text: "fetched" } - const store = createServerSession(messageClient(pending.promise)) - const loading = store.sync("child") - - store.optimistic.add({ sessionID: "child", message, parts: [optimistic] }) - pending.resolve(response([{ info: message, parts: [fetched] }])) - await loading - - expect(store.data.part[message.id]).toEqual([fetched]) - }) - - test("rolls back only unconfirmed optimistic parts", async () => { - const pending = deferredResponse() - const message = userMessage("message") - const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" }) - const pendingPart = textPart(message.id, { id: "pending", text: "pending" }) - const store = createServerSession(messageClient(pending.promise)) - const loading = store.sync("child") - store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] }) - - pending.resolve(response([{ info: message, parts: [confirmed] }])) - await loading - store.optimistic.remove({ sessionID: "child", messageID: message.id }) - - expect(store.data.message.child).toEqual([message]) - expect(store.data.part[message.id]).toEqual([confirmed]) - }) - - test("updates confirmed optimistic parts from later pages", async () => { - const message = userMessage("message") - const confirmed = textPart(message.id, { id: "confirmed", text: "first" }) - const updated = { ...confirmed, text: "updated" } - const pendingPart = textPart(message.id, { id: "pending", text: "pending" }) - const store = createServerSession( - messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [updated] }])), - ) - store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] }) - await store.sync("child") - - await store.sync("child", { force: true }) - store.optimistic.remove({ sessionID: "child", messageID: message.id }) - - expect(store.data.part[message.id]).toEqual([updated]) - }) - - test("does not restore a confirmed optimistic part after its removal event", async () => { - const message = userMessage("message") - const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" }) - const pendingPart = textPart(message.id, { id: "pending", text: "pending" }) - const store = createServerSession( - messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [] }])), - ) - store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] }) - await store.sync("child") - store.apply({ - type: "message.part.removed", - properties: { sessionID: "child", messageID: message.id, partID: confirmed.id }, - }) - - await store.sync("child", { force: true }) - - expect(store.data.part[message.id]).toEqual([pendingPart]) - }) - - test("clears delta buffers when removing optimistic content", () => { - const message = userMessage("message") - const part = textPart(message.id, { text: "optimistic" }) - const store = setup({ child: session("child") }).store - store.optimistic.add({ sessionID: "child", message, parts: [part] }) - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, - }) - - store.optimistic.remove({ sessionID: "child", messageID: message.id }) - - expect(store.data.part[message.id]).toBeUndefined() - expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() - }) - - test("does not remove content confirmed by a message event", () => { - const message = userMessage("message") - const part = textPart(message.id) - const store = setup({ child: session("child") }).store - store.optimistic.add({ sessionID: "child", message, parts: [part] }) - store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } }) - - store.optimistic.remove({ sessionID: "child", messageID: message.id }) - - expect(store.data.message.child).toEqual([message]) - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("does not remove parts confirmed by part events", () => { - const message = userMessage("message") - const part = textPart(message.id) - const store = setup({ child: session("child") }).store - store.optimistic.add({ sessionID: "child", message, parts: [part] }) - store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } }) - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) - - store.optimistic.remove({ sessionID: "child", messageID: message.id }) - - expect(store.data.message.child).toEqual([message]) - expect(store.data.part[message.id]).toEqual([part]) - }) - - test("treats a part event as confirmation when it precedes the message event", () => { - const message = userMessage("message") - const part = textPart(message.id) - const store = setup({ child: session("child") }).store - store.optimistic.add({ sessionID: "child", message, parts: [part] }) - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) - - store.optimistic.remove({ sessionID: "child", messageID: message.id }) - - expect(store.data.message.child).toEqual([message]) - expect(store.data.part[message.id]).toEqual([part]) - }) - - test("clears stale parts when the initial page has none", async () => { - const pending = deferredResponse() - const message = userMessage("message") - const part = textPart(message.id, { text: "stale" }) - const store = createServerSession(messageClient(pending.promise)) - store.apply({ type: "message.updated", properties: { info: message } }) - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 1 } }) - const loading = store.sync("child") - - pending.resolve(response([{ info: message, parts: [] }])) - await loading - - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("clears delta buffers for parts omitted by the initial page", async () => { - const pending = deferredResponse() - const message = userMessage("message") - const kept = textPart(message.id, { id: "part-1", text: "kept" }) - const removed: Part = { ...kept, id: "part-2", text: "removed" } - const store = createServerSession(messageClient(pending.promise)) - store.apply({ type: "message.updated", properties: { info: message } }) - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: kept, time: 1 } }) - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: removed, time: 1 } }) - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: removed.id, field: "text", delta: " delta" }, - }) - const loading = store.sync("child") - - pending.resolve(response([{ info: message, parts: [kept] }])) - await loading - - expect(store.data.part[message.id]).toEqual([kept]) - expect(store.data.part_text_accum_delta[removed.id]).toBeUndefined() - }) - - test("clears a stale delta buffer when a refresh replaces its part", async () => { - const message = userMessage("message") - const stale = textPart(message.id, { text: "stale" }) - const fetched = { ...stale, text: "fetched" } - const store = createServerSession( - messageClient(response([{ info: message, parts: [stale] }]), response([{ info: message, parts: [fetched] }])), - ) - await store.sync("child") - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" }, - }) - - await store.sync("child", { force: true }) - - expect(store.data.part[message.id]).toEqual([fetched]) - expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined() - }) - - test("preserves a non-durable delta received before refresh", async () => { - const message = userMessage("message") - const part = textPart(message.id, { text: "stale" }) - const store = createServerSession( - messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [{ ...part }] }])), - ) - await store.sync("child") - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, - }) - - await store.sync("child", { force: true }) - - expect(store.data.part[message.id]).toEqual([{ ...part, text: "stale delta" }]) - expect(store.data.part_text_accum_delta[part.id]).toBe("stale delta") - }) - - test("accepts fetched text that intentionally replaces an accumulated prefix", async () => { - const message = userMessage("message") - const part = textPart(message.id, { text: "abc" }) - const fetched = { ...part, text: "ab" } - const store = createServerSession( - messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])), - ) - await store.sync("child") - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "def" }, - }) - - await store.sync("child", { force: true }) - - expect(store.data.part[message.id]).toEqual([fetched]) - expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() - }) - - test("preserves an unpersisted delta suffix after partial server catch-up", async () => { - const message = userMessage("message") - const part = textPart(message.id, { text: "a" }) - const fetched = { ...part, text: "ab" } - const store = createServerSession( - messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])), - ) - await store.sync("child") - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "bc" }, - }) - - await store.sync("child", { force: true }) - - expect(store.data.part[message.id]).toEqual([{ ...part, text: "abc" }]) - expect(store.data.part_text_accum_delta[part.id]).toBe("abc") - }) - - test("clears delta state after exact server catch-up", async () => { - const message = userMessage("message") - const part = textPart(message.id, { text: "a" }) - const fetched = { ...part, text: "ab" } - const store = createServerSession( - messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])), - ) - await store.sync("child") - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "b" }, - }) - - await store.sync("child", { force: true }) - - expect(store.data.part[message.id]).toEqual([fetched]) - expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() - }) - - test("uses the successful retry response over events from a failed attempt", async () => { - const failed = Promise.withResolvers() - const retried = Promise.withResolvers() - const message = userMessage("message") - const stale = textPart(message.id, { text: "stale" }) - const intermediate = { ...stale, text: "intermediate" } - const fetched = { ...stale, text: "fetched" } - const client = messageClient(failed.promise, retried.promise) - const store = createServerSession(client, { retry: retryImmediately }) - store.apply({ type: "message.updated", properties: { info: message } }) - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: stale, time: 1 } }) - const loading = store.sync("child") - - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: intermediate, time: 2 } }) - failed.reject(new Error("failed to fetch")) - await client.requested(2) - retried.resolve(response([{ info: message, parts: [fetched] }])) - await loading - - expect(store.data.part[message.id]).toEqual([fetched]) - }) - - test("preserves non-durable deltas across message retries", async () => { - const failed = Promise.withResolvers() - const retried = Promise.withResolvers() - const message = userMessage("message") - const part = textPart(message.id, { text: "stale" }) - const client = messageClient(failed.promise, retried.promise) - const store = createServerSession(client, { retry: retryImmediately }) - store.apply({ type: "message.updated", properties: { info: message } }) - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 1 } }) - const loading = store.sync("child") - - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, - }) - failed.reject(new Error("failed to fetch")) - await client.requested(2) - retried.resolve(response([{ info: message, parts: [part] }])) - await loading - - expect(store.data.part[message.id]).toEqual([{ ...part, text: "stale delta" }]) - }) - - test("preserves part removals across message retries", async () => { - const failed = Promise.withResolvers() - const retried = Promise.withResolvers() - const message = userMessage("message") - const part = textPart(message.id) - const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise) - const store = createServerSession(client, { retry: retryImmediately }) - await store.sync("child") - const loading = store.sync("child", { force: true }) - - store.apply({ - type: "message.part.removed", - properties: { sessionID: "child", messageID: message.id, partID: part.id }, - }) - failed.reject(new Error("failed to fetch")) - await client.requested(3) - retried.resolve(response([{ info: message, parts: [part] }])) - await loading - - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("preserves message removals across message retries", async () => { - const failed = Promise.withResolvers() - const retried = Promise.withResolvers() - const message = userMessage("message") - const part = textPart(message.id) - const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise) - const store = createServerSession(client, { retry: retryImmediately }) - await store.sync("child") - const loading = store.sync("child", { force: true }) - - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - failed.reject(new Error("failed to fetch")) - await client.requested(3) - retried.resolve(response([{ info: message, parts: [part] }])) - await loading - - expect(store.data.message.child).toEqual([]) - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("preserves optimistic re-adds across message retries", async () => { - const failed = Promise.withResolvers() - const retried = Promise.withResolvers() - const message = userMessage("message") - const stale = textPart(message.id, { id: "stale", text: "stale" }) - const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" }) - const client = messageClient(response([{ info: message, parts: [stale] }]), failed.promise, retried.promise) - const store = createServerSession(client, { retry: retryImmediately }) - await store.sync("child") - const loading = store.sync("child", { force: true }) - - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - store.optimistic.add({ sessionID: "child", message, parts: [optimistic] }) - failed.reject(new Error("failed to fetch")) - await client.requested(3) - retried.resolve(response([{ info: message, parts: [stale] }])) - await loading - - expect(store.data.message.child).toEqual([message]) - expect(store.data.part[message.id]).toEqual([optimistic]) - }) - - test("accepts part omission from a successful retry after an earlier delta", async () => { - const failed = Promise.withResolvers() - const retried = Promise.withResolvers() - const message = userMessage("message") - const part = textPart(message.id) - const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise) - const store = createServerSession(client, { retry: retryImmediately }) - await store.sync("child") - const loading = store.sync("child", { force: true }) - - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, - }) - failed.reject(new Error("failed to fetch")) - await client.requested(3) - retried.resolve(response([{ info: message, parts: [] }])) - await loading - - expect(store.data.part[message.id]).toBeUndefined() - expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() - }) - - test("clears load-owned orphan parts when all retries fail", async () => { - const first = Promise.withResolvers() - const second = Promise.withResolvers() - const third = Promise.withResolvers() - const message = userMessage("message") - const part = textPart(message.id) - const client = messageClient(first.promise, second.promise, third.promise) - const store = createServerSession(client, { retry: retryImmediately }) - const loading = store.sync("child").catch((error) => error) - - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) - first.reject(new Error("failed to fetch")) - await client.requested(2) - second.reject(new Error("failed to fetch")) - await client.requested(3) - third.reject(new Error("failed to fetch")) - await loading - - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("preserves live updates during a forced refresh", async () => { - const pending = deferredResponse() - const stale = userMessage("message") - const stalePart = textPart(stale.id, { text: "stale" }) - const store = createServerSession(messageClient(response([{ info: stale, parts: [stalePart] }]), pending.promise)) - await store.sync("child") - const refreshing = store.sync("child", { force: true }) - const live = { ...stale, time: { created: 2 } } - - store.apply({ type: "message.updated", properties: { info: live } }) - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: stale.id, partID: stalePart.id, field: "text", delta: " live" }, - }) - pending.resolve(response([{ info: stale, parts: [stalePart] }])) - await refreshing - - expect(store.data.message.child).toEqual([live]) - expect(store.data.part[stale.id]).toEqual([{ ...stalePart, text: "stale live" }]) - }) - - test("keeps fetched message metadata when only a part changes", async () => { - const pending = deferredResponse() - const stale = userMessage("message") - const fetched = { ...stale, time: { created: 2 } } - const part = textPart(stale.id, { text: "stale" }) - const store = createServerSession(messageClient(response([{ info: stale, parts: [part] }]), pending.promise)) - await store.sync("child") - const refreshing = store.sync("child", { force: true }) - - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: stale.id, partID: part.id, field: "text", delta: " live" }, - }) - pending.resolve(response([{ info: fetched, parts: [part] }])) - await refreshing - - expect(store.data.message.child).toEqual([fetched]) - expect(store.data.part[stale.id]).toEqual([{ ...part, text: "stale live" }]) - }) - - test("preserves a part update when a forced refresh omits its message", async () => { - const pending = deferredResponse() - const message = userMessage("message") - const stale = textPart(message.id, { text: "stale" }) - const live = { ...stale, text: "live" } - const store = createServerSession(messageClient(response([{ info: message, parts: [stale] }]), pending.promise)) - await store.sync("child") - const refreshing = store.sync("child", { force: true }) - - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } }) - pending.resolve(response()) - await refreshing - - expect(store.data.message.child).toEqual([message]) - expect(store.data.part[message.id]).toEqual([live]) - }) - - test("ignores a late part update after its message is removed", async () => { - const pending = deferredResponse() - const message = userMessage("message") - const part = textPart(message.id) - const store = createServerSession(messageClient(pending.promise)) - const loading = store.sync("child") - - store.apply({ type: "message.updated", properties: { info: message } }) - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) - pending.resolve(response([{ info: message, parts: [part] }])) - await loading - - expect(store.data.message.child).toEqual([]) - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("ignores a late part update after a completed message removal", () => { - const message = userMessage("message") - const part = textPart(message.id) - const store = setup({ child: session("child") }).store - store.apply({ type: "message.updated", properties: { info: message } }) - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) - - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("does not restore a completed message removal from a stale refresh", async () => { - const message = userMessage("message") - const part = textPart(message.id) - const store = createServerSession( - messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [part] }])), - ) - await store.sync("child") - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - - await store.sync("child", { force: true }) - - expect(store.data.message.child).toEqual([]) - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("does not restore a completed part removal from a stale refresh", async () => { - const message = userMessage("message") - const part = textPart(message.id) - const store = createServerSession( - messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [part] }])), - ) - await store.sync("child") - store.apply({ - type: "message.part.removed", - properties: { sessionID: "child", messageID: message.id, partID: part.id }, - }) - - await store.sync("child", { force: true }) - - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("does not cache skipped optimistic parts", () => { - const message = userMessage("message") - const part = { id: "part", sessionID: "child", messageID: message.id, type: "step-start" as const } - const store = setup({ child: session("child") }).store - - store.optimistic.add({ sessionID: "child", message, parts: [part] }) - - expect(store.data.part[message.id]).toEqual([]) - }) - - test("clears stale delta buffers when replacing optimistic parts", () => { - const message = userMessage("message") - const stale = textPart(message.id, { id: "stale", text: "stale" }) - const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" }) - const store = setup({ child: session("child") }).store - store.optimistic.add({ sessionID: "child", message, parts: [stale] }) - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" }, - }) - - store.optimistic.add({ sessionID: "child", message, parts: [optimistic] }) - - expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined() - expect(store.data.part_text_accum_delta[optimistic.id]).toBeUndefined() - }) - - test("preserves removals during history prepend", async () => { - const pending = deferredResponse() - const latest = userMessage("message-2", { time: { created: 2 } }) - const older = { ...latest, id: "message-1", time: { created: 1 } } - const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise)) - await store.sync("child") - const loading = store.history.loadMore("child") - - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: older.id } }) - pending.resolve(response([{ info: older, parts: [] }])) - await loading - - expect(store.data.message.child).toEqual([latest]) - }) - - test("does not scan cached messages for user roots during history prepend", async () => { - const guard = { active: false } - const latest = new Proxy(userMessage("message-2", { time: { created: 2 } }), { - get(target, property, receiver) { - if (guard.active && property === "role") throw new Error("cached role accessed") - return Reflect.get(target, property, receiver) - }, - }) - const older = userMessage("message-1") - const store = createServerSession( - messageClient(response([{ info: latest, parts: [] }], "older"), response([{ info: older, parts: [] }])), - ) - await store.sync("child") - guard.active = true - - await store.history.loadMore("child") - - expect(store.data.message.child).toEqual([older, latest]) - }) - - test("preserves loaded history during an incomplete refresh", async () => { - const older = userMessage("message-1") - const latest = userMessage("message-2", { time: { created: 2 } }) - const fresh = userMessage("message-3", { time: { created: 3 } }) - const store = createServerSession( - messageClient( - response( - [ - { info: older, parts: [] }, - { info: latest, parts: [] }, - ], - "older", - ), - response( - [ - { info: latest, parts: [] }, - { info: fresh, parts: [] }, - ], - "older", - ), - ), - ) - await store.sync("child") - - await store.sync("child", { force: true }) - - expect(store.data.message.child).toEqual([older, latest, fresh]) - }) - - test("drops stale recent messages omitted by an incomplete refresh", async () => { - const third = userMessage("message-3", { time: { created: 3 } }) - const fourth = userMessage("message-4", { time: { created: 4 } }) - const stale = userMessage("message-5", { time: { created: 5 } }) - const store = createServerSession( - messageClient( - response( - [ - { info: fourth, parts: [] }, - { info: stale, parts: [] }, - ], - "older", - ), - response( - [ - { info: third, parts: [] }, - { info: fourth, parts: [] }, - ], - "older", - ), - ), - ) - await store.sync("child") - - await store.sync("child", { force: true }) - - expect(store.data.message.child).toEqual([third, fourth]) - }) - - test("uses message creation time for incomplete refresh boundaries", async () => { - const older = userMessage("msg_z", { time: { created: 1 } }) - const boundary = userMessage("msg_m", { time: { created: 2 } }) - const stale = userMessage("msg_a", { time: { created: 3 } }) - const store = createServerSession( - messageClient( - response( - [ - { info: older, parts: [] }, - { info: stale, parts: [] }, - ], - "older", - ), - response([{ info: boundary, parts: [] }], "older"), - ), - ) - await store.sync("child") - - await store.sync("child", { force: true }) - - expect(store.data.message.child).toEqual([boundary, older]) - }) - - test("preserves a part update for a message being loaded from history", async () => { - const pending = deferredResponse() - const latest = userMessage("message-2", { time: { created: 2 } }) - const older = userMessage("message-1") - const stale = textPart(older.id, { text: "stale" }) - const live = { ...stale, text: "live" } - const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise)) - await store.sync("child") - const loading = store.history.loadMore("child") - - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } }) - pending.resolve(response([{ info: older, parts: [stale] }])) - await loading - - expect(store.data.part[older.id]).toEqual([live]) - }) - - test("does not clear newer orphan parts after terminal history prepend", async () => { - const pending = deferredResponse() - const latest = userMessage("message-2", { time: { created: 2 } }) - const older = userMessage("message-1") - const newer = userMessage("message-3", { time: { created: 3 } }) - const part = textPart(newer.id, { text: "live" }) - const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise)) - await store.sync("child") - const loading = store.history.loadMore("child") - - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 3 } }) - pending.resolve(response([{ info: older, parts: [] }])) - await loading - store.apply({ type: "message.updated", properties: { sessionID: "child", info: newer } }) - - expect(store.data.part[newer.id]).toEqual([part]) - }) - - test("accepts an authoritative history part after an earlier unknown-parent update", async () => { - const pending = deferredResponse() - const history = deferredResponse() - const latest = userMessage("message-2", { time: { created: 2 } }) - const older = userMessage("message-1") - const part = textPart(older.id, { text: "live" }) - const store = createServerSession(messageClient(pending.promise, history.promise)) - const loading = store.sync("child") - - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) - pending.resolve(response([{ info: latest, parts: [] }], "older")) - await loading - - expect(store.data.part[older.id]).toEqual([part]) - - const loadingHistory = store.history.loadMore("child") - history.resolve(response([{ info: older, parts: [{ ...part, text: "stale" }] }])) - await loadingHistory - - expect(store.data.part[older.id]).toEqual([{ ...part, text: "stale" }]) - }) - - test("preserves an unknown-parent part removal across pages", async () => { - const initial = deferredResponse() - const history = deferredResponse() - const latest = userMessage("message-2", { time: { created: 2 } }) - const older = userMessage("message-1") - const part = textPart(older.id) - const store = createServerSession(messageClient(initial.promise, history.promise)) - const loading = store.sync("child") - - store.apply({ - type: "message.part.removed", - properties: { sessionID: "child", messageID: older.id, partID: part.id }, - }) - initial.resolve(response([{ info: latest, parts: [] }], "older")) - await loading - const loadingHistory = store.history.loadMore("child") - history.resolve(response([{ info: older, parts: [part] }])) - await loadingHistory - - expect(store.data.part[older.id]).toBeUndefined() - }) - - test("clears orphaned parts when a refresh drops a message", async () => { - const message = userMessage("message") - const part = textPart(message.id, { text: "stale" }) - const store = createServerSession(messageClient(response([{ info: message, parts: [part] }]), response())) - await store.sync("child") - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, - }) - await store.sync("child", { force: true }) - - expect(store.data.message.child).toEqual([]) - expect(store.data.part[message.id]).toBeUndefined() - expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() - }) - - test("applies events without a directory store", () => { - const ctx = setup({}) - ctx.store.apply({ type: "session.created", properties: { sessionID: "root", info: session("root") } }) - ctx.store.apply({ type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } }) - - expect(ctx.store.get("root")?.directory).toBe("/repo") - expect(ctx.store.data.session_working("root")).toBe(true) - expect(ctx.get).toEqual([]) - }) - - test("preserves pinned session content under server-wide cache pressure", () => { - const ctx = setup({}) - ctx.store.pin("active") - ctx.store.optimistic.add({ - sessionID: "active", - message: { - id: "message", - sessionID: "active", - role: "assistant", - time: { created: 1 }, - parentID: "parent", - modelID: "model", - providerID: "provider", - mode: "build", - agent: "agent", - path: { cwd: "/repo", root: "/repo" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - }, - parts: [], - }) - - for (let index = 0; index < 50; index++) { - ctx.store.remember(session(`session-${index}`)) - ctx.store.apply({ - type: "session.status", - properties: { sessionID: `session-${index}`, status: { type: "idle" } }, - }) - } - - expect(ctx.store.data.message.active?.map((message) => message.id)).toEqual(["message"]) - expect(ctx.store.data.session_status["session-0"]).toBeUndefined() - }) -}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/sync-optimistic.test.ts b/packages/app-bundle/overlay/packages/app/src/context/sync-optimistic.test.ts deleted file mode 100644 index 94324f8a..00000000 --- a/packages/app-bundle/overlay/packages/app/src/context/sync-optimistic.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { Message, Part } from "@opencode-ai/sdk/v2/client" -import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync" - -type Text = Extract - -const userMessage = (id: string, sessionID: string): Message => ({ - id, - sessionID, - role: "user", - time: { created: 1 }, - agent: "assistant", - model: { providerID: "openai", modelID: "gpt" }, -}) - -const textPart = (id: string, sessionID: string, messageID: string): Text => ({ - id, - sessionID, - messageID, - type: "text", - text: id, -}) - -describe("sync optimistic reducers", () => { - test("applyOptimisticAdd inserts message in sorted order and stores parts", () => { - const sessionID = "ses_1" - const draft = { - message: { [sessionID]: [userMessage("msg_2", sessionID)] }, - part: {} as Record, - } - - applyOptimisticAdd(draft, { - sessionID, - message: userMessage("msg_1", sessionID), - parts: [textPart("prt_2", sessionID, "msg_1"), textPart("prt_1", sessionID, "msg_1")], - }) - - expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2"]) - expect(draft.part.msg_1?.map((x) => x.id)).toEqual(["prt_1", "prt_2"]) - }) - - test("applyOptimisticRemove removes message and part entries", () => { - const sessionID = "ses_1" - const draft = { - message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_2", sessionID)] }, - part: { - msg_1: [textPart("prt_1", sessionID, "msg_1")], - msg_2: [textPart("prt_2", sessionID, "msg_2")], - } as Record, - } - - applyOptimisticRemove(draft, { sessionID, messageID: "msg_1" }) - - expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_2"]) - expect(draft.part.msg_1).toBeUndefined() - expect(draft.part.msg_2).toHaveLength(1) - }) - - test("mergeOptimisticPage keeps pending messages in fetched timelines", () => { - const sessionID = "ses_1" - const page = mergeOptimisticPage( - { - session: [userMessage("msg_1", sessionID)], - part: [{ id: "msg_1", part: [textPart("prt_1", sessionID, "msg_1")] }], - complete: true, - }, - [{ message: userMessage("msg_2", sessionID), parts: [textPart("prt_2", sessionID, "msg_2")] }], - ) - - expect(page.session.map((x) => x.id)).toEqual(["msg_1", "msg_2"]) - expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_2"]) - expect(page.confirmed).toEqual([]) - expect(page.complete).toBe(true) - }) - - test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => { - const sessionID = "ses_1" - const page = mergeOptimisticPage( - { - session: [userMessage("msg_2", sessionID)], - part: [{ id: "msg_2", part: [textPart("prt_2", sessionID, "msg_2")] }], - complete: true, - }, - [ - { - message: userMessage("msg_2", sessionID), - parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")], - }, - ], - ) - - expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_1", "prt_2"]) - expect(page.confirmed).toEqual([]) - }) - - test("mergeOptimisticPage confirms echoed messages once all parts arrive", () => { - const sessionID = "ses_1" - const page = mergeOptimisticPage( - { - session: [userMessage("msg_2", sessionID)], - part: [ - { - id: "msg_2", - part: [{ ...textPart("prt_1", sessionID, "msg_2"), text: "server" }, textPart("prt_2", sessionID, "msg_2")], - }, - ], - complete: true, - }, - [ - { - message: userMessage("msg_2", sessionID), - parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")], - }, - ], - ) - - expect(page.confirmed).toEqual(["msg_2"]) - expect(page.part.find((x) => x.id === "msg_2")?.part).toMatchObject([ - { id: "prt_1", type: "text", text: "server" }, - { id: "prt_2", type: "text", text: "prt_2" }, - ]) - }) -}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/sync.tsx b/packages/app-bundle/overlay/packages/app/src/context/sync.tsx deleted file mode 100644 index b90f49ff..00000000 --- a/packages/app-bundle/overlay/packages/app/src/context/sync.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { Binary } from "@opencode-ai/core/util/binary" -import { createMemo } from "solid-js" -import { useServerSync } from "./server-sync" -import { useSDK } from "./sdk" -import type { Message, Part } from "@opencode-ai/sdk/v2/client" - -const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) - -function sortParts(parts: Part[]) { - return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) -} - -const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) - -type OptimisticStore = { - message: Record - part: Record -} - -type OptimisticAddInput = { - sessionID: string - message: Message - parts: Part[] -} - -type OptimisticRemoveInput = { - sessionID: string - messageID: string -} - -type OptimisticItem = { - message: Message - parts: Part[] -} - -type MessagePage = { - session: Message[] - part: { id: string; part: Part[] }[] - cursor?: string - complete: boolean -} - -const hasParts = (parts: Part[] | undefined, want: Part[]) => { - if (!parts) return want.length === 0 - return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found) -} - -const mergeParts = (parts: Part[] | undefined, want: Part[]) => { - if (!parts) return sortParts(want) - const next = [...parts] - let changed = false - for (const part of want) { - const result = Binary.search(next, part.id, (item) => item.id) - if (result.found) continue - next.splice(result.index, 0, part) - changed = true - } - if (!changed) return parts - return next -} - -export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) { - if (items.length === 0) return { ...page, confirmed: [] as string[] } - - const session = [...page.session] - const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)])) - const confirmed: string[] = [] - - for (const item of items) { - const result = Binary.search(session, item.message.id, (message) => message.id) - const found = result.found - if (!found) session.splice(result.index, 0, item.message) - - const current = part.get(item.message.id) - if (found && hasParts(current, item.parts)) { - confirmed.push(item.message.id) - continue - } - - part.set(item.message.id, mergeParts(current, item.parts)) - } - - return { - cursor: page.cursor, - complete: page.complete, - session, - part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })), - confirmed, - } -} - -export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) { - const messages = draft.message[input.sessionID] - if (messages) { - const result = Binary.search(messages, input.message.id, (m) => m.id) - messages.splice(result.index, 0, input.message) - } else { - draft.message[input.sessionID] = [input.message] - } - draft.part[input.message.id] = sortParts(input.parts) -} - -export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) { - const messages = draft.message[input.sessionID] - if (messages) { - const result = Binary.search(messages, input.messageID, (m) => m.id) - if (result.found) messages.splice(result.index, 1) - } - delete draft.part[input.messageID] -} - -export const useSync = () => { - const serverSync = useServerSync() - const sdk = useSDK() - - return createMemo(() => serverSync().ensureDirSyncContext(sdk().directory)) -} - -export type DirectorySync = ReturnType> diff --git a/packages/app-bundle/overlay/packages/app/src/desktop-menu.test.ts b/packages/app-bundle/overlay/packages/app/src/desktop-menu.test.ts deleted file mode 100644 index c7e36a7c..00000000 --- a/packages/app-bundle/overlay/packages/app/src/desktop-menu.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { DESKTOP_MENU } from "./desktop-menu" - -describe("desktop menu", () => { - test("exports logs through the desktop command registry", () => { - const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter( - (item) => item.type === "item" && item.label === "Export Logs...", - ) - - expect(items).toHaveLength(2) - expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true) - }) -}) diff --git a/packages/app-bundle/overlay/packages/app/src/hooks/provider-catalog.test.ts b/packages/app-bundle/overlay/packages/app/src/hooks/provider-catalog.test.ts deleted file mode 100644 index 04fbe5ab..00000000 --- a/packages/app-bundle/overlay/packages/app/src/hooks/provider-catalog.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { expect, test } from "bun:test" -import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" -import { selectProviderCatalog } from "./provider-catalog" - -const catalog = (id: string): NormalizedProviderListResponse => ({ - all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]), - connected: [id], - default: { [id]: `${id}-model` }, -}) - -test("selects the ready catalog for an explicit directory", () => { - const directory = catalog("directory") - - expect( - selectProviderCatalog({ - explicit: true, - directory: "/repo", - catalog: { ready: true, providers: directory }, - }), - ).toBe(directory) -}) - -test("returns an empty catalog while an explicit directory is unresolved", () => { - expect(selectProviderCatalog({ explicit: true })).toEqual({ all: new Map(), connected: [], default: {} }) - expect( - selectProviderCatalog({ - explicit: true, - directory: "/repo", - catalog: { ready: false, providers: catalog("directory") }, - }), - ).toEqual({ all: new Map(), connected: [], default: {} }) -}) - -test("uses the route catalog when it is ready", () => { - const directory = catalog("directory") - - expect( - selectProviderCatalog({ - explicit: false, - directory: "/repo", - catalog: { ready: true, providers: directory }, - global: catalog("global"), - }), - ).toBe(directory) -}) - -test("falls back to the global catalog for route consumers", () => { - const global = catalog("global") - - expect(selectProviderCatalog({ explicit: false, global })).toBe(global) - expect( - selectProviderCatalog({ - explicit: false, - directory: "/repo", - catalog: { ready: false, providers: catalog("directory") }, - global, - }), - ).toBe(global) -}) diff --git a/packages/app-bundle/overlay/packages/app/src/hooks/provider-catalog.ts b/packages/app-bundle/overlay/packages/app/src/hooks/provider-catalog.ts deleted file mode 100644 index 01b966ed..00000000 --- a/packages/app-bundle/overlay/packages/app/src/hooks/provider-catalog.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" - -const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} } - -type DirectoryCatalog = { - ready: boolean - providers: NormalizedProviderListResponse -} - -type ProviderCatalogInput = - | { - explicit: true - directory?: string - catalog?: DirectoryCatalog - } - | { - explicit: false - directory?: string - catalog?: DirectoryCatalog - global: NormalizedProviderListResponse - } - -export function selectProviderCatalog(input: ProviderCatalogInput) { - if (input.directory && input.catalog?.ready) return input.catalog.providers - if (input.explicit) return emptyProviderCatalog - return input.global -} diff --git a/packages/app-bundle/overlay/packages/app/src/hooks/use-providers.ts b/packages/app-bundle/overlay/packages/app/src/hooks/use-providers.ts deleted file mode 100644 index 60ad01c8..00000000 --- a/packages/app-bundle/overlay/packages/app/src/hooks/use-providers.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { useServerSync } from "@/context/server-sync" -import { decode64 } from "@/utils/base64" -import { useParams } from "@solidjs/router" -import { Iterable, pipe } from "effect" -import { createEffect, createMemo, type Accessor } from "solid-js" -import { selectProviderCatalog } from "./provider-catalog" - -export const popularProviders = [ - "opencode", - "opencode-go", - "anthropic", - "github-copilot", - "openai", - "google", - "openrouter", - "vercel", -] -const popularProviderSet = new Set(popularProviders) - -export function useProviders(directory: Accessor) { - const serverSync = useServerSync() - const params = useParams() - const dir = () => (directory ? directory() : decode64(params.dir)) - const providers = () => { - const value = dir() - const projectStore = value ? serverSync().child(value)[0] : undefined - if (value) - return selectProviderCatalog({ - explicit: true, - directory: value, - catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider }, - }) - return selectProviderCatalog({ - explicit: false, - directory: value, - catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider }, - global: serverSync().data.provider, - }) - } - - return { - all: () => providers().all, - default: () => providers().default, - popular: () => - pipe( - providers().all, - Iterable.map(([, p]) => p), - Iterable.filter((p) => popularProviderSet.has(p.id)), - (v) => Array.from(v), - ), - connected: () => { - const connected = new Set(providers().connected) - return pipe( - providers().all, - Iterable.map(([, p]) => p), - Iterable.filter((p) => connected.has(p.id)), - (v) => Array.from(v), - ) - }, - paid: () => { - const connected = new Set(providers().connected) - const paid = [ - ...Iterable.filter( - providers().all, - ([id]) => - connected.has(id) && - (id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)), - ), - ] - return paid - }, - } -} diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/parity.test.ts b/packages/app-bundle/overlay/packages/app/src/i18n/parity.test.ts deleted file mode 100644 index 566be37e..00000000 --- a/packages/app-bundle/overlay/packages/app/src/i18n/parity.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, test } from "bun:test" - -const appLocales = [ - "ar", - "br", - "bs", - "da", - "de", - "es", - "fr", - "ja", - "ko", - "no", - "pl", - "ru", - "uk", - "th", - "tr", - "zh", - "zht", -] as const -const desktopLocales = appLocales.filter((locale) => locale !== "th" && locale !== "tr") - -const domains = [ - { - name: "app", - source: "./en.ts", - target: (locale: string) => `./${locale}.ts`, - locales: appLocales, - }, - { - name: "ui", - source: "../../../ui/src/i18n/en.ts", - target: (locale: string) => `../../../ui/src/i18n/${locale}.ts`, - locales: appLocales, - }, - { - name: "desktop", - source: "../../../desktop/src/renderer/i18n/en.ts", - target: (locale: string) => `../../../desktop/src/renderer/i18n/${locale}.ts`, - locales: desktopLocales, - }, -] as const - -describe.skipIf(!!process.env.CI)("i18n parity", () => { - test("non-English locales have every English key", async () => { - for (const domain of domains) { - const source = await dictionary(domain.source) - for (const locale of domain.locales) { - const target = await dictionary(domain.target(locale)) - const missing = Object.keys(source).filter((key) => !Object.hasOwn(target, key)) - const extra = Object.keys(target).filter((key) => !Object.hasOwn(source, key)) - expect({ domain: domain.name, locale, missing, extra }).toEqual({ - domain: domain.name, - locale, - missing: [], - extra: [], - }) - } - } - }) - - test("non-English locales preserve English placeholders", async () => { - for (const domain of domains) { - const source = await dictionary(domain.source) - for (const locale of domain.locales) { - const target = await dictionary(domain.target(locale)) - const mismatched = Object.keys(source).filter( - (key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(), - ) - expect({ domain: domain.name, locale, mismatched }).toEqual({ domain: domain.name, locale, mismatched: [] }) - } - } - }) - - test("non-English locales translate targeted unseen session keys", async () => { - const source = await dictionary("./en.ts") - for (const locale of appLocales) { - const target = await dictionary(`./${locale}.ts`) - for (const key of ["command.session.previous.unseen", "command.session.next.unseen"]) { - expect(target[key]).toBeDefined() - expect(target[key]).not.toBe(source[key]) - } - } - }) - - test("changed-file summary keys preserve rendered English copy and localize complete phrases", async () => { - const source = await dictionary("../../../ui/src/i18n/en.ts") - expect(source["ui.sessionTurn.diffs.changed.one"].replace("{{count}}", "1")).toBe("1 Changed file") - expect(source["ui.sessionTurn.diffs.changed.other"].replace("{{count}}", "2")).toBe("2 Changed files") - expect(source["ui.sessionTurn.diffs.changed"]).toBeUndefined() - - for (const locale of appLocales) { - const target = await dictionary(`../../../ui/src/i18n/${locale}.ts`) - for (const key of ["ui.sessionTurn.diffs.changed.one", "ui.sessionTurn.diffs.changed.other"]) { - expect(target[key].trim()).not.toBe("") - expect(placeholders(target[key])).toEqual(["count"]) - } - } - }) -}) - -async function dictionary(file: string) { - const module: unknown = await import(file) - if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) { - throw new Error(`Invalid translation dictionary: ${file}`) - } - return module.dict -} - -function isDictionary(value: unknown): value is Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) return false - return Object.values(value).every((item) => typeof item === "string") -} - -function placeholders(value: string) { - return Array.from(value.matchAll(/{{\s*([^}]+?)\s*}}/g), (match) => match[1]).sort() -} diff --git a/packages/app-bundle/overlay/packages/app/src/index.ts b/packages/app-bundle/overlay/packages/app/src/index.ts deleted file mode 100644 index 8d36086d..00000000 --- a/packages/app-bundle/overlay/packages/app/src/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -export { AppBaseProviders, AppInterface } from "./app" -export { useLayout } from "./context/layout" -export { useServerSDK } from "./context/server-sdk" -export { useServerSync } from "./context/server-sync" -export { useServer } from "./context/server" -export { useSettings } from "./context/settings" -export { useTabs } from "./context/tabs" -export { useProviders } from "./hooks/use-providers" -export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" -export { useCommand } from "./context/command" -export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" -export { useWslServers } from "./wsl/context" -export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform" -export { type UpdaterPlatform, type UpdaterState } from "./updater" -export { - type WslDistroProbe, - type WslInstalledDistro, - type WslJob, - type WslOnlineDistro, - type WslOpencodeCheck, - type WslRuntimeCheck, - type WslServerConfig, - type WslServerItem, - type WslServerRuntime, - type WslServersEvent, - type WslServersPlatform, - type WslServersState, -} from "./wsl/types" -export { ServerConnection } from "./context/server" -export { createDraftStore, type DraftStore } from "./utils/draft-store" diff --git a/packages/app-bundle/overlay/packages/app/src/pages/home/home-controller.ts b/packages/app-bundle/overlay/packages/app/src/pages/home/home-controller.ts deleted file mode 100644 index 5e7e50cf..00000000 --- a/packages/app-bundle/overlay/packages/app/src/pages/home/home-controller.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { useGlobal } from "@/context/global" -import { type HomeProjectSelection, useLayout } from "@/context/layout" -import { ServerConnection, useServer } from "@/context/server" -import { useServerSync } from "@/context/server-sync" -import { useTabs } from "@/context/tabs" -import { toggleHomeProjectSelection } from "@/pages/layout/helpers" -import { createEffect, createMemo } from "solid-js" - -export function createHomeController() { - const sync = useServerSync() - const layout = useLayout() - const server = useServer() - const global = useGlobal() - const tabs = useTabs() - const selection = layout.home.selection - const focusedServer = createMemo( - () => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current, - ) - const focusedServerCtx = createMemo(() => { - const conn = focusedServer() - if (!conn) return undefined - return global.ensureServerCtx(conn) - }) - const focusedSync = () => focusedServerCtx()?.sync ?? sync() - const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list()) - const recentlyClosed = createMemo( - () => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(), - ) - const homedir = createMemo(() => focusedSync().data.path.home ?? "") - const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory)) - const newSessionProject = createMemo( - () => - selectedProject() ?? - projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ?? - projects()[0], - ) - - createEffect(() => { - const list = global.servers.list() - if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return - const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0] - if (conn) setSelection({ server: ServerConnection.key(conn) }) - }) - - function setSelection(next: HomeProjectSelection) { - layout.home.setSelection(next) - } - - function openProjectNewSession(conn: ServerConnection.Any, directory: string) { - const ctx = global.ensureServerCtx(conn) - ctx.projects.open(directory) - ctx.projects.touch(directory) - void tabs.newDraft({ server: ServerConnection.key(conn), directory }) - } - - return { - selection: { - value: selection, - set: setSelection, - focusServer: (conn: ServerConnection.Any) => setSelection({ server: ServerConnection.key(conn) }), - }, - server: { - list: global.servers.list, - health: (conn: ServerConnection.Any) => global.servers.health[ServerConnection.key(conn)], - context: (conn: ServerConnection.Any) => global.ensureServerCtx(conn), - focused: focusedServer, - focusedContext: focusedServerCtx, - focusedSync, - }, - project: { - list: projects, - recentlyClosed, - homedir, - selected: selectedProject, - newSession: newSessionProject, - forServer: (conn: ServerConnection.Any) => global.ensureServerCtx(conn).projects.list(), - select: (conn: ServerConnection.Any, directory: string) => { - const key = ServerConnection.key(conn) - if (global.servers.health[key]?.healthy === false) return - if ( - !global - .ensureServerCtx(conn) - .projects.list() - .some((project) => project.worktree === directory) - ) - return - setSelection(toggleHomeProjectSelection(selection(), key, directory)) - }, - add: (conn: ServerConnection.Any, directories: string[]) => { - const directory = directories[0] - if (!directory) return - const ctx = global.ensureServerCtx(conn) - directories.forEach((item) => ctx.projects.open(item)) - ctx.projects.touch(directory) - setSelection({ server: ServerConnection.key(conn), directory }) - }, - openNewSession: () => { - const conn = focusedServer() - const project = newSessionProject() - if (!conn || !project) return - openProjectNewSession(conn, project.worktree) - }, - openProjectNewSession, - }, - } -} - -export type HomeController = ReturnType diff --git a/packages/app-bundle/overlay/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app-bundle/overlay/packages/app/src/pages/home/home-sessions-controller.tsx deleted file mode 100644 index a12e9800..00000000 --- a/packages/app-bundle/overlay/packages/app/src/pages/home/home-sessions-controller.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import type { Session } from "@opencode-ai/sdk/v2/client" -import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useMarked } from "@opencode-ai/ui/context/marked" -import { useQuery } from "@tanstack/solid-query" -import { DateTime } from "luxon" -import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js" -import { produce } from "solid-js/store" -import { useCommand } from "@/context/command" -import { - loadHomeSessionIndex, - retainHomeSessions, - type HomeSessionEvents, -} from "@/context/global-sync/home-session-index" -import type { LocalProject } from "@/context/layout" -import { useLanguage } from "@/context/language" -import { ServerConnection } from "@/context/server" -import { sessionHasOpenTab, useTabs } from "@/context/tabs" -import { displayName, errorMessage, projectForSession } from "@/pages/layout/helpers" -import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" -import { pathKey } from "@/utils/path-key" -import { showToast } from "@/utils/toast" -import { Binary } from "@opencode-ai/core/util/binary" -import { archiveHomeSession } from "../home-session-archive" -import type { HomeController } from "./home-controller" - -const HOME_SESSION_LIMIT = 64 -export type HomeSessionRecord = { - session: Session - project: LocalProject - projectName: string -} - -export type HomeSessionGroup = { - id: "today" | "yesterday" | "older" - title: string - sessions: HomeSessionRecord[] -} - -export type OpenSessionOptions = { background?: boolean } - -export function createHomeSessionsController(home: HomeController) { - const tabs = useTabs() - const command = useCommand() - const dialog = useDialog() - const language = useLanguage() - const marked = useMarked() - const projectDirectories = createMemo(() => { - const project = home.project.selected() - if (!project) return home.project.list().flatMap(directories) - return directories(project) - }) - const projectByID = createMemo( - () => new Map(home.project.list().flatMap((project) => (project.id ? [[project.id, project] as const] : []))), - ) - const homeSessions = () => home.server.focusedSync().homeSessions - const sessionEventLoad = useQuery(() => ({ - queryKey: homeSessions().eventsKey, - queryFn: async (): Promise => ({ sequence: 0, entries: [] }), - initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents, - enabled: false, - })) - const sessionLoad = useQuery(() => ({ - queryKey: homeSessions().indexKey, - enabled: !!home.server.focusedContext(), - queryFn: async ({ signal }) => { - const ctx = home.server.focusedContext() - if (!ctx) return { sessions: [], eventSequence: 0 } - const cache = homeSessions() - const eventSequence = cache.eventSequence() - const index = await loadHomeSessionIndex( - (input, options) => ctx.sdk.client.v2.session.list(input, options), - eventSequence, - signal, - ) - cache.complete(eventSequence) - return index - }, - retry: false, - staleTime: 30_000, - refetchOnMount: true, - refetchOnReconnect: true, - })) - const indexedSessions = createMemo(() => - retainHomeSessions( - homeSessions().sessions(sessionLoad.data, sessionEventLoad.data), - HOME_SESSION_LIMIT, - Date.now(), - ), - ) - const allRecords = createMemo(() => - buildHomeSessionRecords({ - sessions: indexedSessions, - projectDirectories, - projects: home.project.list, - projectByID, - }), - ) - const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT)) - const groups = createMemo(() => groupSessions(records(), language)) - const prefetched = new Set() - - createEffect(() => { - const ctx = home.server.focusedContext() - const conn = home.server.focused() - if (!ctx || !conn) return - records() - .slice(0, 2) - .forEach((record) => { - const key = `${ServerConnection.key(conn)}\0${record.session.id}` - if (prefetched.has(key)) return - prefetched.add(key) - createRoot((dispose) => { - try { - void ctx.sync.session - .sync(record.session.id) - .then(() => - Promise.all( - (ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) => - (ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => { - if (part.type !== "text" || !part.text) return [] - return preloadMarkdown(part.text, part.id, marked) - }), - ), - ), - ) - .catch(() => {}) - .finally(dispose) - } catch { - dispose() - } - }) - }) - }) - - command.register("home.palette", () => [ - { - id: "command.palette", - title: language.t("command.palette"), - hidden: true, - onSelect: async () => { - const conn = home.server.focused() - if (!conn) return - const ctx = home.server.focusedContext() - if (!ctx) return - const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2") - void dialog.show(() => ( - { - if (!entry.sessionID || !entry.directory || !entry.server) return - const sessionID = entry.sessionID - const server = entry.server - const directory = entry.project?.worktree ?? entry.directory - ctx.projects.open(directory) - ctx.projects.touch(directory) - void startTransition(() => { - const tab = tabs.addSessionTab({ server, sessionId: sessionID }) - tabs.select(tab) - }) - }} - /> - )) - }, - }, - ]) - - return { - copy: { - language, - }, - data: { - records, - groups, - loading: () => sessionLoad.isLoading, - searchRecords: allRecords, - }, - session: { - showProjectName: () => !home.project.selected(), - server: () => home.selection.value().server, - canCreate: () => !!home.project.newSession(), - create: home.project.openNewSession, - open: (session: Session, options?: OpenSessionOptions) => { - const directoryKey = pathKey(session.directory) - const project = - home.project - .list() - .find( - (item) => - pathKey(item.worktree) === directoryKey || - item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey), - ) ?? projectForSession(session, home.project.list(), projectByID()) - const conn = home.server.focused() - if (!conn) return - const directory = project?.worktree ?? session.directory - const ctx = home.server.focusedContext() - if (!ctx) return - ctx.projects.open(directory) - if (options?.background) { - tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) - return - } - ctx.projects.touch(directory) - void startTransition(() => { - const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) - tabs.select(tab) - }) - }, - archive: async (session: Session) => { - const conn = home.server.focused() - const ctx = home.server.focusedContext() - if (!conn || !ctx) return - const [, setStore] = ctx.sync.child(session.directory) - if ((await ctx.sdk.protocol) !== "v1") return - await archiveHomeSession({ - server: ServerConnection.key(conn), - session, - archive: (sessionID) => - ctx.sdk.client.session.update({ - sessionID, - directory: session.directory, - time: { archived: Date.now() }, - }), - remove: () => - setStore( - produce((draft) => { - const match = Binary.search(draft.session, session.id, (item) => item.id) - if (match.found) draft.session.splice(match.index, 1) - }), - ), - onError: (cause) => - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(cause, language.t("common.requestFailed")), - }), - }) - }, - }, - tab: { - isOpen: (record: HomeSessionRecord) => - sessionHasOpenTab(tabs.store, home.selection.value().server, record.session), - }, - } -} - -function directories(project: LocalProject) { - return [project.worktree, ...(project.sandboxes ?? [])] -} - -function buildHomeSessionRecords(input: { - sessions: () => Session[] - projectDirectories: () => string[] - projects: () => LocalProject[] - projectByID: () => Map -}) { - const directories = new Set(input.projectDirectories().map(pathKey)) - const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory))) - return [...new Map(sessions.map((session) => [session.id, session] as const)).values()] - .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) - .flatMap((session) => { - const directory = pathKey(session.directory) - const project = - input - .projects() - .find( - (item) => - pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory), - ) ?? projectForSession(session, input.projects(), input.projectByID()) - if (!project) return [] - return { session, project, projectName: displayName(project) } - }) -} - -export function homeSessionSearchKey(record: HomeSessionRecord) { - return `${pathKey(record.session.directory)}:${record.session.id}` -} - -function groupSessions(records: HomeSessionRecord[], language: ReturnType): HomeSessionGroup[] { - const now = DateTime.local() - const yesterday = now.minus({ days: 1 }) - const todaySessions = records.filter((record) => - DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"), - ) - const yesterdaySessions = records.filter((record) => - DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"), - ) - const olderSessions = records.filter((record) => { - const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created) - return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day") - }) - const olderTitle = - todaySessions.length === 0 && yesterdaySessions.length === 0 - ? language.t("sidebar.project.recentSessions") - : language.t("home.sessions.group.older") - return [ - { id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions }, - { id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions }, - { id: "older" as const, title: olderTitle, sessions: olderSessions }, - ].filter((group) => group.sessions.length > 0) -} - -export type HomeSessionsController = ReturnType - -export function HomeSessionStatusController(props: { - server: Accessor - record: HomeSessionRecord - isOpenTab: (record: HomeSessionRecord) => boolean - render: (state: { unread: Accessor; loading: Accessor; open: Accessor }) => JSX.Element -}) { - const avatar = useSessionTabAvatarState( - props.server, - () => props.record.session.directory, - () => props.record.session.id, - ) - return props.render({ - unread: avatar.unread, - loading: avatar.loading, - open: () => props.isOpenTab(props.record), - }) -} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/composer/prompt-model-selection.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/composer/prompt-model-selection.ts deleted file mode 100644 index 3f5bb0f4..00000000 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/composer/prompt-model-selection.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { batch, createMemo, startTransition } from "solid-js" -import { useModels } from "@/context/models" -import type { ModelKey, ModelSelection } from "@/context/local" -import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant" -import { usePrompt } from "@/context/prompt" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" -import { useProviders } from "@/hooks/use-providers" - -export function createPromptModelSelection(input: { agent: () => { model?: ModelKey; variant?: string } | undefined }) { - const sdk = useSDK() - const sync = useSync() - const models = useModels() - const prompt = usePrompt() - const providers = useProviders(() => sdk().directory) - const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) - - const valid = (model: ModelKey) => { - const provider = providers.all().get(model.providerID) - return !!provider?.models[model.modelID] && connected().has(model.providerID) - } - - const configured = () => { - const value = sync().data.config.model - if (!value) return - const [providerID, modelID] = value.split("/") - const model = { providerID, modelID } - if (valid(model)) return model - } - - const recent = () => models.recent.list().find(valid) - const fallback = () => { - const defaults = providers.default() - return providers.connected().flatMap((provider) => { - const modelID = defaults[provider.id] ?? Object.values(provider.models)[0]?.id - return modelID ? [{ providerID: provider.id, modelID }] : [] - })[0] - } - - const current = () => { - const key = [prompt.model.current(), input.agent()?.model, configured(), recent(), fallback()].find( - (item): item is ModelKey => !!item && valid(item), - ) - if (!key) return - return models.find(key) - } - const recentModels = createMemo(() => - models.recent - .list() - .map(models.find) - .filter((item): item is NonNullable => !!item), - ) - - const selection = { - ready: models.ready, - current, - recent: recentModels, - list: models.list, - cycle(direction: 1 | -1) { - const items = recentModels() - const item = current() - if (!item) return - const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id) - if (index === -1) return - const next = items[(index + direction + items.length) % items.length] - if (next) selection.set({ providerID: next.provider.id, modelID: next.id }) - }, - set(item: ModelKey | undefined, options?: { recent?: boolean }) { - startTransition(() => - batch(() => { - prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined) - if (!item) return - models.setVisibility(item, true) - if (options?.recent) models.recent.push(item) - }), - ) - }, - visible: models.visible, - setVisibility: models.setVisibility, - variant: { - configured() { - const item = input.agent() - const model = current() - if (!item || !model) return - return getConfiguredAgentVariant({ - agent: { model: item.model, variant: item.variant }, - model: { providerID: model.provider.id, modelID: model.id, variants: model.variants }, - }) - }, - selected() { - return prompt.model.current()?.variant - }, - current() { - const resolved = resolveModelVariant({ - variants: this.list(), - selected: this.selected(), - configured: this.configured(), - }) - if (resolved) return resolved - const model = current() - if (!model) return - const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id }) - if (saved && this.list().includes(saved)) return saved - }, - list() { - return Object.keys(current()?.variants ?? {}) - }, - set(value: string | undefined) { - startTransition(() => - batch(() => { - const model = current() - if (!model) return - prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null }) - models.variant.set({ providerID: model.provider.id, modelID: model.id }, value) - }), - ) - }, - cycle() { - const variants = this.list() - if (variants.length === 0) return - this.set( - cycleModelVariant({ - variants, - selected: this.selected(), - configured: this.configured(), - }), - ) - }, - }, - } satisfies ModelSelection - - return selection -} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/composer/session-followup-dock.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session/composer/session-followup-dock.tsx deleted file mode 100644 index 7d744f4e..00000000 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/composer/session-followup-dock.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { For, Show, createMemo } from "solid-js" -import { createStore } from "solid-js/store" -import { Button } from "@opencode-ai/ui/button" -import { DockTray } from "@opencode-ai/ui/dock-surface" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { useLanguage } from "@/context/language" - -export function SessionFollowupDock(props: { - items: { id: string; text: string }[] - sending?: string - onSend: (id: string) => void - onEdit: (id: string) => void -}) { - const language = useLanguage() - const [store, setStore] = createStore({ - collapsed: false, - }) - - const toggle = () => setStore("collapsed", (value) => !value) - const total = createMemo(() => props.items.length) - const label = createMemo(() => - language.t(total() === 1 ? "session.followupDock.summary.one" : "session.followupDock.summary.other", { - count: total(), - }), - ) - const preview = createMemo(() => props.items[0]?.text ?? "") - - return ( - -
{ - if (event.key !== "Enter" && event.key !== " ") return - event.preventDefault() - toggle() - }} - > - {label()} - - {preview()} - -
- { - event.preventDefault() - event.stopPropagation() - }} - onClick={(event) => { - event.stopPropagation() - toggle() - }} - aria-label={ - store.collapsed ? language.t("session.followupDock.expand") : language.t("session.followupDock.collapse") - } - /> -
-
- - - - } - > - 0} - fallback={ -
- {language.t("palette.empty")} -
- } - > - { - setExplicitHighlight(path) - props.onSelect(path) - }} - onFileDoubleClick={props.onSelectPermanent} - /> -
-
-
- - } - > - -
- -
{language.t("command.file.open")}
-
{language.t("session.files.selectToOpen")}
-
- - } - > -
- - {(tab) => } - -
-
- - ) -} diff --git a/packages/app-bundle/overlay/packages/app/src/utils/persist.ts b/packages/app-bundle/overlay/packages/app/src/utils/persist.ts deleted file mode 100644 index a7742c39..00000000 --- a/packages/app-bundle/overlay/packages/app/src/utils/persist.ts +++ /dev/null @@ -1,703 +0,0 @@ -import { Platform, usePlatform } from "@/context/platform" -import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage" -import { checksum } from "@opencode-ai/core/util/encode" -import { createResource, type Accessor } from "solid-js" -import type { SetStoreFunction, Store } from "solid-js/store" -import { pathKey } from "@/utils/path-key" -import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope" - -type InitType = Promise | string | null -type PersistedWithReady = [ - Store, - SetStoreFunction, - InitType, - Accessor & { promise: undefined | Promise }, -] - -type PersistTarget = { - draft?: boolean - storage?: string - scope?: "window" - legacyStorageNames?: string[] - key: string - legacy?: string[] - migrate?: (value: unknown) => unknown -} - -const LEGACY_STORAGE = "default.dat" -const GLOBAL_STORAGE = "opencode.global.dat" -const WINDOW_STORAGE = "opencode.window" -const LOCAL_PREFIX = "opencode." -const fallback = new Map() - -const CACHE_MAX_ENTRIES = 500 -const CACHE_MAX_BYTES = 8 * 1024 * 1024 - -type CacheEntry = { value: string; bytes: number } -const cache = new Map() -const cacheTotal = { bytes: 0 } - -function cacheDelete(key: string) { - const entry = cache.get(key) - if (!entry) return - cacheTotal.bytes -= entry.bytes - cache.delete(key) -} - -function cachePrune() { - for (;;) { - if (cache.size <= CACHE_MAX_ENTRIES && cacheTotal.bytes <= CACHE_MAX_BYTES) return - const oldest = cache.keys().next().value as string | undefined - if (!oldest) return - cacheDelete(oldest) - } -} - -function cacheSet(key: string, value: string) { - const bytes = value.length * 2 - if (bytes > CACHE_MAX_BYTES) { - cacheDelete(key) - return - } - - const entry = cache.get(key) - if (entry) cacheTotal.bytes -= entry.bytes - cache.delete(key) - cache.set(key, { value, bytes }) - cacheTotal.bytes += bytes - cachePrune() -} - -function cacheGet(key: string) { - const entry = cache.get(key) - if (!entry) return - cache.delete(key) - cache.set(key, entry) - return entry.value -} - -function fallbackDisabled(scope: string) { - return fallback.get(scope) === true -} - -function fallbackSet(scope: string) { - fallback.set(scope, true) -} - -function quota(error: unknown) { - if (error instanceof DOMException) { - if (error.name === "QuotaExceededError") return true - if (error.name === "NS_ERROR_DOM_QUOTA_REACHED") return true - if (error.name === "QUOTA_EXCEEDED_ERR") return true - if (error.code === 22 || error.code === 1014) return true - return false - } - - if (!error || typeof error !== "object") return false - const name = (error as { name?: string }).name - if (name === "QuotaExceededError" || name === "NS_ERROR_DOM_QUOTA_REACHED") return true - if (name && /quota/i.test(name)) return true - - const code = (error as { code?: number }).code - if (code === 22 || code === 1014) return true - - const message = (error as { message?: string }).message - if (typeof message !== "string") return false - if (/quota/i.test(message)) return true - return false -} - -type Evict = { key: string; size: number } - -function evict(storage: Storage, keep: string, value: string) { - const total = storage.length - const indexes = Array.from({ length: total }, (_, index) => index) - const items: Evict[] = [] - - for (const index of indexes) { - const name = storage.key(index) - if (!name) continue - if (!name.startsWith(LOCAL_PREFIX)) continue - if (name === keep) continue - const stored = storage.getItem(name) - items.push({ key: name, size: stored?.length ?? 0 }) - } - - items.sort((a, b) => b.size - a.size) - - for (const item of items) { - storage.removeItem(item.key) - cacheDelete(item.key) - - try { - storage.setItem(keep, value) - cacheSet(keep, value) - return true - } catch (error) { - if (!quota(error)) throw error - } - } - - return false -} - -function write(storage: Storage, key: string, value: string) { - try { - storage.setItem(key, value) - cacheSet(key, value) - return true - } catch (error) { - if (!quota(error)) throw error - } - - try { - storage.removeItem(key) - cacheDelete(key) - storage.setItem(key, value) - cacheSet(key, value) - return true - } catch (error) { - if (!quota(error)) throw error - } - - const ok = evict(storage, key, value) - return ok -} - -function snapshot(value: unknown) { - return JSON.parse(JSON.stringify(value)) as unknown -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - -function merge(defaults: unknown, value: unknown): unknown { - if (value === undefined) return defaults - if (value === null) return value - - if (Array.isArray(defaults)) { - if (Array.isArray(value)) return value - return defaults - } - - if (isRecord(defaults)) { - if (!isRecord(value)) return defaults - - const result: Record = { ...defaults } - for (const key of Object.keys(value)) { - if (key in defaults) { - result[key] = merge((defaults as Record)[key], (value as Record)[key]) - } else { - result[key] = (value as Record)[key] - } - } - return result - } - - return value -} - -function parse(value: string) { - try { - return JSON.parse(value) as unknown - } catch { - return undefined - } -} - -function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => unknown) { - const parsed = parse(raw) - if (parsed === undefined) return - const migrated = migrate ? migrate(parsed) : parsed - const merged = merge(defaults, migrated) - return JSON.stringify(merged) -} - -function readCurrent(input: { - storage: SyncStorage - key: string - defaults: unknown - migrate?: (value: unknown) => unknown -}) { - const raw = input.storage.getItem(input.key) - if (raw === null) return - const next = normalize(input.defaults, raw, input.migrate) - if (next === undefined) { - input.storage.removeItem(input.key) - return null - } - if (raw !== next) input.storage.setItem(input.key, next) - return next -} - -function migrateLegacy(input: { - current: SyncStorage - legacyStore?: SyncStorage - stores: SyncStorage[] - keys: string[] - key: string - defaults: unknown - migrate?: (value: unknown) => unknown -}) { - for (const store of input.stores) { - const raw = store.getItem(input.key) - if (raw === null) continue - - const next = normalize(input.defaults, raw, input.migrate) - if (next === undefined) { - store.removeItem(input.key) - continue - } - input.current.setItem(input.key, next) - store.removeItem(input.key) - return next - } - - if (!input.legacyStore) return null - - for (const key of input.keys) { - const raw = input.legacyStore.getItem(key) - if (raw === null) continue - - const next = normalize(input.defaults, raw, input.migrate) - if (next === undefined) { - input.legacyStore.removeItem(key) - continue - } - input.current.setItem(input.key, next) - input.legacyStore.removeItem(key) - return next - } - - return null -} - -async function readCurrentAsync(input: { - storage: AsyncStorage - key: string - defaults: unknown - migrate?: (value: unknown) => unknown -}) { - const raw = await input.storage.getItem(input.key) - if (raw === null) return - const next = normalize(input.defaults, raw, input.migrate) - if (next === undefined) { - await input.storage.removeItem(input.key).catch(() => undefined) - return null - } - if (raw !== next) await input.storage.setItem(input.key, next) - return next -} - -async function removeAsync(storage: AsyncStorage, key: string) { - try { - await storage.removeItem(key) - } catch {} -} - -function toAsyncStorage(storage: SyncStorage | AsyncStorage): AsyncStorage { - return { - getItem: async (key) => storage.getItem(key), - setItem: async (key, value) => storage.setItem(key, value), - removeItem: async (key) => storage.removeItem(key), - } -} - -async function migrateLegacyAsync(input: { - current: AsyncStorage - legacyStore?: AsyncStorage - stores: AsyncStorage[] - keys: string[] - key: string - defaults: unknown - migrate?: (value: unknown) => unknown -}) { - for (const store of input.stores) { - const raw = await store.getItem(input.key) - if (raw === null) continue - - const next = normalize(input.defaults, raw, input.migrate) - if (next === undefined) { - await removeAsync(store, input.key) - continue - } - await input.current.setItem(input.key, next) - await store.removeItem(input.key) - return next - } - - if (!input.legacyStore) return null - - for (const key of input.keys) { - const raw = await input.legacyStore.getItem(key) - if (raw === null) continue - - const next = normalize(input.defaults, raw, input.migrate) - if (next === undefined) { - await removeAsync(input.legacyStore, key) - continue - } - await input.current.setItem(input.key, next) - await input.legacyStore.removeItem(key) - return next - } - - return null -} - -function workspaceStorage(dir: string) { - const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-") - const sum = checksum(dir) ?? "0" - return `opencode.workspace.${head}.${sum}.dat` -} - -function draftStorage(draftID: string) { - const head = (draftID.slice(0, 12) || "draft").replace(/[^a-zA-Z0-9._-]/g, "-") - const sum = checksum(draftID) ?? "0" - return `opencode.draft.${head}.${sum}.dat` -} - -function windowStorage(windowID: string) { - const safe = (windowID || "browser").replace(/[^a-zA-Z0-9._-]/g, "-") - return `${WINDOW_STORAGE}.${safe}.dat` -} - -function legacyWorkspaceStorage(dir: string) { - const storage = workspaceStorage(pathKey(dir)) - const result = new Set() - const raw = workspaceStorage(dir) - if (raw !== storage) result.add(raw) - - const key = pathKey(dir) - const drive = key.length >= 3 && key[1] === ":" && key[2] === "/" - if (drive) { - const backslash = workspaceStorage(key.replaceAll("/", "\\")) - if (backslash !== storage) result.add(backslash) - } - - if (result.size === 0) return - return [...result] -} - -function serverWorkspaceTarget(scope: ServerScopeValue, dir: string, key: string, legacy?: string[]): PersistTarget { - if (scope !== ServerScope.local) return { storage: workspaceStorage(ScopedKey.from(scope, pathKey(dir))), key } - return { storage: workspaceStorage(pathKey(dir)), legacyStorageNames: legacyWorkspaceStorage(dir), key, legacy } -} - -function localStorageWithPrefix(prefix: string): SyncStorage { - const base = `${prefix}:` - const scope = `prefix:${prefix}` - const item = (key: string) => base + key - return { - getItem: (key) => { - const name = item(key) - const cached = cacheGet(name) - if (fallbackDisabled(scope)) return cached ?? null - - const stored = (() => { - try { - return localStorage.getItem(name) - } catch { - fallbackSet(scope) - return null - } - })() - if (stored === null) return cached ?? null - cacheSet(name, stored) - return stored - }, - setItem: (key, value) => { - const name = item(key) - if (fallbackDisabled(scope)) return - try { - if (write(localStorage, name, value)) return - } catch { - fallbackSet(scope) - return - } - fallbackSet(scope) - }, - removeItem: (key) => { - const name = item(key) - cacheDelete(name) - if (fallbackDisabled(scope)) return - try { - localStorage.removeItem(name) - } catch { - fallbackSet(scope) - } - }, - } -} - -function localStorageDirect(): SyncStorage { - const scope = "direct" - return { - getItem: (key) => { - const cached = cacheGet(key) - if (fallbackDisabled(scope)) return cached ?? null - - const stored = (() => { - try { - return localStorage.getItem(key) - } catch { - fallbackSet(scope) - return null - } - })() - if (stored === null) return cached ?? null - cacheSet(key, stored) - return stored - }, - setItem: (key, value) => { - if (fallbackDisabled(scope)) return - try { - if (write(localStorage, key, value)) return - } catch { - fallbackSet(scope) - return - } - fallbackSet(scope) - }, - removeItem: (key) => { - cacheDelete(key) - if (fallbackDisabled(scope)) return - try { - localStorage.removeItem(key) - } catch { - fallbackSet(scope) - } - }, - } -} - -const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "file-view", "layout"] - -export function draftPersistedKeys() { - return DRAFT_PERSISTED_KEYS -} - -export const PersistTesting = { - localStorageDirect, - localStorageWithPrefix, - migrateLegacy, - normalize, - resolveTarget, - windowStorage, - workspaceStorage, -} - -export const Persist = { - global(key: string, legacy?: string[]): PersistTarget { - return { storage: GLOBAL_STORAGE, key, legacy } - }, - window(key: string, legacy?: string[]): PersistTarget { - return { scope: "window", key, legacy } - }, - draft(draftID: string, key: string, legacy?: string[]): PersistTarget { - return { storage: draftStorage(draftID), key: `draft:${key}`, legacy } - }, - serverGlobal(scope: ServerScopeValue, key: string, legacy?: string[]): PersistTarget { - if (scope === ServerScope.local) return Persist.global(key, legacy) - return { storage: GLOBAL_STORAGE, key: ScopedKey.from(scope, key) } - }, - workspace(dir: string, key: string, legacy?: string[]): PersistTarget { - return serverWorkspaceTarget(ServerScope.local, dir, `workspace:${key}`, legacy) - }, - serverWorkspace(scope: ServerScopeValue, dir: string, key: string, legacy?: string[]): PersistTarget { - return serverWorkspaceTarget(scope, dir, `workspace:${key}`, legacy) - }, - session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget { - return serverWorkspaceTarget(ServerScope.local, dir, `session:${session}:${key}`, legacy) - }, - serverSession(scope: ServerScopeValue, dir: string, session: string, key: string, legacy?: string[]): PersistTarget { - return serverWorkspaceTarget(scope, dir, `session:${session}:${key}`, legacy) - }, - scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget { - if (session) return Persist.session(dir, session, key, legacy) - return Persist.workspace(dir, key, legacy) - }, - serverScoped(scope: ServerScopeValue, dir: string, session: string | undefined, key: string, legacy?: string[]) { - if (session) return Persist.serverSession(scope, dir, session, key, legacy) - return Persist.serverWorkspace(scope, dir, key, legacy) - }, - prompt(target: PersistTarget): PersistTarget { - return { ...target, draft: true } - }, -} - -function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget { - if (target.scope !== "window") return target - if (platform.platform === "desktop" && !platform.windowID) return { ...target, storage: GLOBAL_STORAGE } - const windowID = platform.platform === "desktop" ? (platform.windowID ?? "browser") : "browser" - return { - ...target, - storage: windowStorage(windowID), - } -} - -export function removePersisted( - target: { draft?: boolean; storage?: string; legacyStorageNames?: string[]; key: string }, - platform?: Platform, -) { - if (target.draft && platform?.draftStore) { - void platform.draftStore.removeItem(`${target.storage ?? "default"}:${target.key}`) - } - const isDesktop = platform?.platform === "desktop" && !!platform.storage - - if (isDesktop) { - void platform.storage?.(target.storage)?.removeItem(target.key) - for (const storage of target.legacyStorageNames ?? []) { - void platform.storage?.(storage)?.removeItem(target.key) - } - return - } - - if (!target.storage) { - localStorageDirect().removeItem(target.key) - return - } - - localStorageWithPrefix(target.storage).removeItem(target.key) - for (const storage of target.legacyStorageNames ?? []) { - localStorageWithPrefix(storage).removeItem(target.key) - } -} - -export function persisted( - target: string | PersistTarget, - store: [Store, SetStoreFunction], -): PersistedWithReady { - const platform = usePlatform() - const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform) - - const defaults = snapshot(store[0]) - const legacy = config.legacy ?? [] - - const isDesktop = platform.platform === "desktop" && !!platform.storage - const draft = config.draft ? platform.draftStore : undefined - - const currentStorage = (() => { - if (draft) { - const prefix = `${config.storage ?? "default"}:` - return { - getItem: (key: string) => draft.getItem(prefix + key), - setItem: (key: string, value: string) => draft.setItem(prefix + key, value), - removeItem: (key: string) => draft.removeItem(prefix + key), - } satisfies AsyncStorage - } - if (isDesktop) return platform.storage?.(config.storage) - if (!config.storage) return localStorageDirect() - return localStorageWithPrefix(config.storage) - })() - - const legacyStorage = (() => { - if (!isDesktop) return localStorageDirect() - if (!config.storage) return platform.storage?.() - return platform.storage?.(LEGACY_STORAGE) - })() - - const legacyStorageNames = config.legacyStorageNames ?? [] - - const storage = (() => { - if (!isDesktop && !draft) { - const current = currentStorage as SyncStorage - const legacyStore = legacyStorage as SyncStorage - const legacyStores = legacyStorageNames.map(localStorageWithPrefix) - - const api: SyncStorage = { - getItem: (key) => { - const value = readCurrent({ storage: current, key, defaults, migrate: config.migrate }) - if (value !== undefined) return value - return migrateLegacy({ - current, - legacyStore, - stores: legacyStores, - keys: legacy, - key, - defaults, - migrate: config.migrate, - }) - }, - setItem: (key, value) => { - current.setItem(key, value) - }, - removeItem: (key) => { - current.removeItem(key) - }, - } - - return api - } - - const current = currentStorage as AsyncStorage - const legacyStore = legacyStorage as AsyncStorage | undefined - const oldCurrent = draft - ? isDesktop - ? platform.storage?.(config.storage) - : config.storage - ? localStorageWithPrefix(config.storage) - : localStorageDirect() - : undefined - const legacyStores = [ - oldCurrent, - ...legacyStorageNames.map((name) => (isDesktop ? platform.storage?.(name) : localStorageWithPrefix(name))), - ] - .filter((x) => !!x) - .map(toAsyncStorage) - let draftLatest: string | undefined - - const api: AsyncStorage = { - getItem: async (key) => { - const value = await readCurrentAsync({ storage: current, key, defaults, migrate: config.migrate }) - if (value !== undefined) return value - const migrated = await migrateLegacyAsync({ - current, - legacyStore, - stores: legacyStores, - keys: legacy, - key, - defaults, - migrate: config.migrate, - }) - if (draftLatest === undefined) { - if (draft && migrated !== null) return (await current.getItem(key)) ?? migrated - return migrated - } - await current.setItem(key, draftLatest) - return draftLatest - }, - setItem: async (key, value) => { - if (draft) draftLatest = value - await current.setItem(key, value) - }, - removeItem: async (key) => { - await current.removeItem(key) - }, - } - - return api - })() - - const [state, setState, init] = makePersisted(store, { name: config.key, storage }) - - const isAsync = init instanceof Promise - const [ready] = createResource( - () => init, - async (initValue) => { - if (initValue instanceof Promise) await initValue - return true - }, - { initialValue: !isAsync }, - ) - - return [ - state, - setState, - init, - Object.assign(() => (ready.loading ? false : ready.latest === true), { - promise: init instanceof Promise ? init : undefined, - }), - ] -} diff --git a/packages/app-bundle/overlay/packages/app/src/utils/session-message.ts b/packages/app-bundle/overlay/packages/app/src/utils/session-message.ts deleted file mode 100644 index 93d86a66..00000000 --- a/packages/app-bundle/overlay/packages/app/src/utils/session-message.ts +++ /dev/null @@ -1,358 +0,0 @@ -import type { - SessionMessageAssistant, - SessionMessageAssistantTool, - SessionMessageInfo, - SessionMessageShell, - SessionMessageUser, -} from "@opencode-ai/client/promise" -import type { AssistantMessage, FilePart, Message, Part, ToolPart, UserMessage } from "@opencode-ai/sdk/v2" -import { Option, Schema } from "effect" - -const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } -const emptyModel: { id: string; providerID: string; variant?: string } = { id: "", providerID: "" } -const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) - -function record(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value) -} - -function normalizeToolInput(name: string, input: Record) { - if (!["edit", "write"].includes(name) || typeof input.path !== "string" || typeof input.filePath === "string") - return input - return { ...input, filePath: input.path } -} - -function normalizeToolMetadata(name: string, metadata: Record) { - if (name !== "edit" || !Array.isArray(metadata.files)) return metadata - const file = metadata.files.find(record) - if (!file || typeof file.file !== "string") return metadata - return { - ...metadata, - filediff: { - file: file.file, - patch: typeof file.patch === "string" ? file.patch : undefined, - additions: typeof file.additions === "number" ? file.additions : 0, - deletions: typeof file.deletions === "number" ? file.deletions : 0, - }, - } -} - -export function normalizeSessionMessages(sessionID: string, source: readonly SessionMessageInfo[]) { - const messages: Message[] = [] - const parts = new Map() - let agent = "" - let model = emptyModel - let parentID: string | undefined - - source.forEach((message) => { - if (message.type === "agent-switched") { - agent = message.agent - return - } - if (message.type === "model-switched") { - model = message.model - return - } - if (message.type === "user") { - parentID = message.id - messages.push(userMessage(sessionID, message, agent, model)) - parts.set(message.id, userParts(sessionID, message)) - return - } - if (message.type === "synthetic" && message.description?.trim()) { - parentID = message.id - messages.push({ - id: message.id, - sessionID, - role: "user", - time: message.time, - agent, - model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, - }) - parts.set(message.id, [textPart(sessionID, message.id, 0, message.description, true)]) - return - } - if (message.type === "shell") { - messages.push(...shellMessages(sessionID, message, agent, model)) - parts.set(message.id, [textPart(sessionID, message.id, 0, message.command)]) - parts.set(`${message.id}:assistant`, [shellPart(sessionID, message)]) - parentID = undefined - return - } - if (message.type === "assistant") { - agent = message.agent - model = message.model - if (!parentID) return - const parent = messages.findLast((item) => item.id === parentID) - if (parent?.role === "user") { - parent.agent = message.agent - parent.model = { - providerID: message.model.providerID, - modelID: message.model.id, - variant: message.model.variant, - } - } - messages.push(assistantMessage(sessionID, parentID, message)) - parts.set(message.id, assistantParts(sessionID, message)) - return - } - if (message.type !== "compaction" || !parentID) return - parts.set(parentID, [ - ...(parts.get(parentID) ?? []), - { - id: `${message.id}:compaction`, - sessionID, - messageID: parentID, - type: "compaction", - auto: message.reason === "auto", - }, - ]) - }) - - return { messages, parts } -} - -function shellMessages( - sessionID: string, - message: SessionMessageShell, - agent: string, - model: { id: string; providerID: string; variant?: string }, -): [UserMessage, AssistantMessage] { - return [ - { - id: message.id, - sessionID, - role: "user", - time: { created: message.time.created }, - agent, - model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, - }, - { - id: `${message.id}:assistant`, - sessionID, - role: "assistant", - time: message.time, - parentID: message.id, - modelID: model.id, - providerID: model.providerID, - variant: model.variant, - mode: agent, - agent, - path: { cwd: "", root: "" }, - cost: 0, - tokens: emptyTokens, - }, - ] -} - -function shellPart(sessionID: string, message: SessionMessageShell): ToolPart { - const input = { command: message.command } - const start = message.time.created - const state: ToolPart["state"] = - message.status === "running" - ? { status: "running", input, time: { start } } - : { - status: "completed", - input, - output: message.output?.output ?? "", - title: "Shell", - metadata: { - status: message.status, - exit: message.exit, - truncated: message.output?.truncated, - }, - time: { start, end: message.time.completed ?? start }, - } - return { - id: `${message.id}:tool`, - sessionID, - messageID: `${message.id}:assistant`, - type: "tool", - callID: message.shellID, - tool: "bash", - state, - } -} - -export function sessionMessagePartID(messageID: string, type: "text" | "reasoning", ordinal: number) { - return `${messageID}:${type}:${ordinal}` -} - -function userMessage( - sessionID: string, - message: SessionMessageUser, - agent: string, - model: { id: string; providerID: string; variant?: string }, -): UserMessage { - return { - id: message.id, - sessionID, - role: "user", - time: message.time, - agent, - model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, - } -} - -function userParts(sessionID: string, message: SessionMessageUser): Part[] { - return [ - textPart(sessionID, message.id, 0, message.text), - ...(message.files ?? []).map( - (file, index): FilePart => ({ - id: `${message.id}:file:${index}`, - sessionID, - messageID: message.id, - type: "file", - mime: file.mime, - filename: file.name, - url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`, - source: file.mention - ? { - type: "file", - text: { value: file.mention.text, start: file.mention.start, end: file.mention.end }, - path: file.mention.text.startsWith("@") ? file.mention.text.slice(1) : (file.name ?? file.mention.text), - } - : undefined, - }), - ), - ...(message.agents ?? []).map( - (item, index): Part => ({ - id: `${message.id}:agent:${index}`, - sessionID, - messageID: message.id, - type: "agent", - name: item.name, - source: item.mention - ? { value: item.mention.text, start: item.mention.start, end: item.mention.end } - : undefined, - }), - ), - ] -} - -function assistantMessage(sessionID: string, parentID: string, message: SessionMessageAssistant): AssistantMessage { - const error = message.error - ? message.error.type.toLowerCase().includes("abort") || message.error.type.toLowerCase().includes("interrupt") - ? { name: "MessageAbortedError" as const, data: { message: message.error.message } } - : { name: "UnknownError" as const, data: { message: message.error.message } } - : undefined - return { - id: message.id, - sessionID, - role: "assistant", - time: message.time, - error, - parentID, - modelID: message.model.id, - providerID: message.model.providerID, - variant: message.model.variant, - mode: message.agent, - agent: message.agent, - path: { cwd: "", root: "" }, - cost: message.cost ?? 0, - tokens: message.tokens ?? emptyTokens, - finish: message.finish, - } -} - -function assistantParts(sessionID: string, message: SessionMessageAssistant): Part[] { - const ordinals = { text: 0, reasoning: 0 } - return message.content.flatMap((content): Part[] => { - if (content.type === "text") { - const part = textPart(sessionID, message.id, ordinals.text++, content.text) - return content.text.trim() ? [part] : [] - } - if (content.type === "reasoning") { - const part: Part = { - id: sessionMessagePartID(message.id, "reasoning", ordinals.reasoning++), - sessionID, - messageID: message.id, - type: "reasoning", - text: content.text, - metadata: content.state, - time: { - start: content.time?.created ?? message.time.created, - end: content.time?.completed, - }, - } - return content.text.trim() ? [part] : [] - } - return [toolPart(sessionID, message.id, content)] - }) -} - -function textPart(sessionID: string, messageID: string, ordinal: number, text: string, synthetic?: boolean): Part { - return { - id: sessionMessagePartID(messageID, "text", ordinal), - sessionID, - messageID, - type: "text", - text, - synthetic, - } -} - -function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssistantTool): ToolPart { - const start = tool.time.ran ?? tool.time.created - const state = (() => { - if (tool.state.status === "streaming") { - const value = Option.getOrUndefined(decodeToolInput(tool.state.input)) - const input = normalizeToolInput(tool.name, record(value) ? value : {}) - return { status: "pending" as const, input, raw: tool.state.input } - } - if (tool.state.status === "running") { - return { - status: "running" as const, - input: normalizeToolInput(tool.name, tool.state.input), - // metadata: normalizeToolMetadata(tool.name, tool.state.structured), - metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}), - time: { start }, - } - } - if (tool.state.status === "error") { - return { - status: "error" as const, - input: normalizeToolInput(tool.name, tool.state.input), - error: tool.state.error.message, - // metadata: normalizeToolMetadata(tool.name, tool.state.structured), - metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}), - time: { start, end: tool.time.completed ?? start }, - } - } - const attachments = tool.state.content.flatMap((item, index): FilePart[] => - item.type === "file" - ? [ - { - id: `${tool.id}:file:${index}`, - sessionID, - messageID, - type: "file", - mime: item.mime, - filename: item.name, - url: item.uri, - }, - ] - : [], - ) - return { - status: "completed" as const, - input: normalizeToolInput(tool.name, tool.state.input), - output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"), - title: tool.name, - // metadata: normalizeToolMetadata(tool.name, tool.state.structured), - metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}), - time: { start, end: tool.time.completed ?? start }, - attachments: attachments.length ? attachments : undefined, - } - })() - return { - id: tool.id, - sessionID, - messageID, - type: "tool", - callID: tool.id, - tool: tool.name, - state, - metadata: { providerState: tool.providerState, providerResultState: tool.providerResultState }, - } -} diff --git a/packages/app-bundle/overlay/packages/app/src/utils/session-title.ts b/packages/app-bundle/overlay/packages/app/src/utils/session-title.ts deleted file mode 100644 index ca04c010..00000000 --- a/packages/app-bundle/overlay/packages/app/src/utils/session-title.ts +++ /dev/null @@ -1,7 +0,0 @@ -const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ - -export function sessionTitle(title?: string) { - if (!title) return title - const match = title.match(pattern) - return match?.[1] ?? title -} diff --git a/packages/app-bundle/overlay/packages/app/src/utils/session.test.ts b/packages/app-bundle/overlay/packages/app/src/utils/session.test.ts deleted file mode 100644 index b15c23b6..00000000 --- a/packages/app-bundle/overlay/packages/app/src/utils/session.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise" -import { listAllSessions, normalizeSessionInfo } from "./session" - -describe("normalizeSessionInfo", () => { - test("adapts a current session to the app session shape", () => { - const result = normalizeSessionInfo({ - id: "session-1", - projectID: "project-1", - agent: "build", - model: { id: "gpt-5", providerID: "openai", variant: "high" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: 1 }, - title: "New session", - location: { directory: "/repo/worktree", workspaceID: "workspace-1" }, - subpath: "worktree", - revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot", files: [] }, - } as SessionInfo) - - expect(result).toEqual({ - id: "session-1", - slug: "session-1", - projectID: "project-1", - workspaceID: "workspace-1", - directory: "/repo/worktree", - path: "worktree", - parentID: undefined, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - title: "New session", - agent: "build", - model: { id: "gpt-5", providerID: "openai", variant: "high" }, - version: "", - time: { created: 1, updated: 1 }, - revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot" }, - }) - }) -}) - -describe("listAllSessions", () => { - test("loads every page in server order and retains the query", async () => { - const calls: SessionListInput[] = [] - const pages = new Map([ - [undefined, { data: [sessionInfo("session-3"), sessionInfo("session-2")], cursor: { next: "next" } }], - ["next", { data: [sessionInfo("session-1", true)], cursor: {} }], - ]) - const api = { - list: async (query = {}) => { - calls.push(query) - return pages.get(query.cursor) ?? { data: [], cursor: {} } - }, - } satisfies Pick - - const result = await listAllSessions(api, { directory: "/repo", order: "desc" }) - - expect(result.map((session) => session.id)).toEqual(["session-3", "session-2", "session-1"]) - expect(result[2]?.time.archived).toBe(2) - expect(calls).toEqual([ - { directory: "/repo", order: "desc", limit: 100, cursor: undefined }, - { directory: "/repo", order: "desc", limit: 100, cursor: "next" }, - ]) - }) - - test("requests the terminal empty page when the server returns a next cursor", async () => { - const cursors: Array = [] - const api = { - list: async (query = {}) => { - cursors.push(query.cursor) - if (query.cursor) return { data: [], cursor: { next: "unused" } } - return { data: [sessionInfo("session-1")], cursor: { next: "terminal" } } - }, - } satisfies Pick - - const result = await listAllSessions(api, { directory: "/repo", limit: 25 }) - - expect(result.map((session) => session.id)).toEqual(["session-1"]) - expect(cursors).toEqual([undefined, "terminal"]) - }) -}) - -function sessionInfo(id: string, archived = false) { - return { - id, - projectID: "project-1", - agent: "build", - model: { id: "model-1", providerID: "provider-1" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: 1, archived: archived ? 2 : undefined }, - title: id, - location: { directory: "/repo" }, - } as SessionInfo -} diff --git a/packages/app-bundle/overlay/packages/app/src/utils/session.ts b/packages/app-bundle/overlay/packages/app/src/utils/session.ts deleted file mode 100644 index faf84796..00000000 --- a/packages/app-bundle/overlay/packages/app/src/utils/session.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise" -import type { Session } from "@opencode-ai/sdk/v2/client" - -export function normalizeSessionInfo(input: SessionInfo | Session): Session { - if (!("location" in input)) return input - return { - id: input.id, - slug: input.id, - projectID: input.projectID, - workspaceID: input.location.workspaceID, - directory: input.location.directory, - path: input.subpath, - parentID: input.parentID, - cost: input.cost, - tokens: input.tokens, - title: input.title, - agent: input.agent, - model: input.model, - version: "", - time: input.time, - revert: input.revert && { - messageID: input.revert.messageID, - partID: input.revert.partID, - snapshot: input.revert.snapshot, - }, - } -} - -export async function listAllSessions(api: Pick, input: Omit) { - const load = async (cursor?: string): Promise => { - const result = await api.list({ ...input, limit: input.limit ?? 100, cursor }) - const sessions = result.data.map(normalizeSessionInfo) - if (result.data.length === 0 || !result.cursor.next) return sessions - return [...sessions, ...(await load(result.cursor.next))] - } - return load() -} diff --git a/packages/app-bundle/overlay/packages/app/test-browser/prompt-persistence.test.ts b/packages/app-bundle/overlay/packages/app/test-browser/prompt-persistence.test.ts deleted file mode 100644 index f3d8f8ae..00000000 --- a/packages/app-bundle/overlay/packages/app/test-browser/prompt-persistence.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { beforeAll, describe, expect, mock, test } from "bun:test" -import type { AsyncStorage } from "@solid-primitives/storage" -import { createEffect, createRoot } from "solid-js" -import { ServerScope } from "@/utils/server-scope" -import { createDraftStore } from "@/utils/draft-store" - -let Prompt: typeof import("@/context/prompt") -let read: ((value: string | null) => void) | undefined - -const storage: AsyncStorage = { - getItem: () => new Promise((resolve) => (read = resolve)), - setItem: async () => undefined, - removeItem: async () => undefined, - clear: async () => undefined, - key: async () => null, - getLength: async () => 0, - length: Promise.resolve(0), -} - -beforeAll(async () => { - mock.module("@solidjs/router", () => ({ - useParams: () => ({}), - useSearchParams: () => [{}], - useLocation: () => ({ pathname: "", query: {} }), - useNavigate: () => () => undefined, - })) - mock.module("@opencode-ai/ui/context", () => ({ - createSimpleContext: () => ({ - use: () => undefined, - provider: () => undefined, - }), - })) - mock.module("@/context/platform", () => ({ - usePlatform: () => ({ platform: "desktop", storage: () => storage, draftStore: storage }), - })) - - Prompt = await import("@/context/prompt") -}) - -describe("prompt persistence", () => { - test("waits for an async draft to hydrate before reporting ready", async () => { - await new Promise((resolve, reject) => { - createRoot((dispose) => { - const session = Prompt.createPromptSession(ServerScope.local, { draftID: "draft-async" }) - const ready = Prompt.createPromptReady(() => session) - - expect(ready()).toBe(false) - expect(session.current()[0]).toMatchObject({ type: "text", content: "" }) - - read?.( - JSON.stringify({ - prompt: [{ type: "text", content: "persisted draft", start: 0, end: 15 }], - cursor: 15, - context: { items: [] }, - }), - ) - - createEffect(() => { - if (!ready()) return - try { - expect(session.current()[0]).toMatchObject({ type: "text", content: "persisted draft" }) - dispose() - resolve() - } catch (error) { - dispose() - reject(error) - } - }) - }) - }) - }) -}) - -test("moves legacy image data URLs into blobs and hydrates object URLs", async () => { - const documents = new Map() - const blobs = new Map() - const store = createDraftStore({ - get: async (key) => documents.get(key) ?? null, - set: async (key, value) => void documents.set(key, value), - remove: async (key) => void documents.delete(key), - putBlob: async (blob) => { - const id = String(blob.size) - blobs.set(id, blob) - return id - }, - getBlob: async (id) => blobs.get(id) ?? null, - }) - - await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] })) - expect(documents.get("prompt")).not.toContain("dataUrl") - const value = JSON.parse((await store.getItem("prompt"))!) - expect(value.prompt[0].blob.id).toBe("1") - expect(value.prompt[0].blob.url).toStartWith("blob:") -}) - -test("does not let delayed blob migration overwrite a newer draft", async () => { - const documents = new Map() - const migration = Promise.withResolvers() - const store = createDraftStore({ - get: async () => null, - set: async (key, value) => void documents.set(key, value), - remove: async () => undefined, - putBlob: async () => { - await migration.promise - return "blob" - }, - getBlob: async () => null, - }) - const older = store.setItem( - "prompt", - JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] }), - ) - await Bun.sleep(0) - await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "text", content: "latest" }] })) - migration.resolve() - await older - - expect(documents.get("prompt")).toContain("latest") -}) diff --git a/packages/app-bundle/overlay/packages/core/package.json b/packages/app-bundle/overlay/packages/core/package.json deleted file mode 100644 index 3dffa1fc..00000000 --- a/packages/app-bundle/overlay/packages/core/package.json +++ /dev/null @@ -1,132 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.12", - "name": "@opencode-ai/core", - "type": "module", - "license": "MIT", - "private": true, - "scripts": { - "db": "bun drizzle-kit", - "migration": "bun run script/migration.ts", - "fix-node-pty": "bun run script/fix-node-pty.ts", - "test": "bun test --only-failures", - "typecheck": "tsgo --noEmit" - }, - "bin": { - "opencode": "./bin/opencode" - }, - "exports": { - "./effect/layer-node": "./src/effect/layer-node.ts", - "./effect/app-node": "./src/effect/app-node.ts", - "./session/runner": "./src/session/runner/index.ts", - "./system-context": "./src/system-context/index.ts", - "./*": "./src/*.ts" - }, - "imports": { - "#sqlite": { - "bun": "./src/database/sqlite.bun.ts", - "node": "./src/database/sqlite.node.ts", - "default": "./src/database/sqlite.bun.ts" - }, - "#pty": { - "bun": "./src/pty/pty.bun.ts", - "node": "./src/pty/pty.node.ts", - "default": "./src/pty/pty.bun.ts" - }, - "#fff": { - "bun": "./src/filesystem/fff.bun.ts", - "node": "./src/filesystem/fff.node.ts", - "default": "./src/filesystem/fff.bun.ts" - } - }, - "devDependencies": { - "@tsconfig/bun": "catalog:", - "@types/bun": "catalog:", - "@types/cross-spawn": "catalog:", - "@types/node": "catalog:", - "@types/npm-package-arg": "6.1.4", - "@types/npmcli__arborist": "6.3.3", - "@types/semver": "catalog:", - "@types/turndown": "5.0.5", - "@types/which": "3.0.4", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1", - "@opencode-ai/http-recorder": "workspace:*", - "drizzle-kit": "catalog:" - }, - "dependencies": { - "@ai-sdk/alibaba": "1.0.17", - "@ai-sdk/amazon-bedrock": "4.0.112", - "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.88", - "@ai-sdk/cerebras": "2.0.41", - "@ai-sdk/cohere": "3.0.27", - "@ai-sdk/deepinfra": "2.0.41", - "@ai-sdk/gateway": "3.0.104", - "@ai-sdk/google": "3.0.73", - "@ai-sdk/google-vertex": "4.0.128", - "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.51", - "@ai-sdk/openai": "3.0.84", - "@ai-sdk/openai-compatible": "2.0.41", - "@ai-sdk/perplexity": "3.0.26", - "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.23", - "@ai-sdk/togetherai": "2.0.41", - "@ai-sdk/vercel": "2.0.39", - "@ai-sdk/xai": "3.0.102", - "@aws-sdk/credential-providers": "3.1057.0", - "@effect/opentelemetry": "catalog:", - "@effect/platform-node": "catalog:", - "@effect/sql-sqlite-bun": "catalog:", - "@lydell/node-pty": "catalog:", - "@ff-labs/fff-bun": "0.9.4", - "@npmcli/arborist": "9.4.0", - "@npmcli/config": "10.8.1", - "@opencode-ai/effect-drizzle-sqlite": "workspace:*", - "@opencode-ai/effect-sqlite-node": "workspace:*", - "@opencode-ai/llm": "workspace:*", - "@opencode-ai/schema": "workspace:*", - "@opencode-ai/plugin": "workspace:*", - "@opentelemetry/api": "1.9.0", - "@opentelemetry/context-async-hooks": "2.6.1", - "@opentelemetry/exporter-trace-otlp-http": "0.214.0", - "@opentelemetry/sdk-trace-base": "2.6.1", - "@parcel/watcher": "2.5.1", - "@silvia-odwyer/photon-node": "0.3.4", - "@openrouter/ai-sdk-provider": "2.9.0", - "ai-gateway-provider": "3.1.2", - "bun-pty": "0.4.8", - "cross-spawn": "catalog:", - "diff": "catalog:", - "drizzle-orm": "catalog:", - "effect": "catalog:", - "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.12.1", - "glob": "13.0.5", - "google-auth-library": "10.5.0", - "gray-matter": "4.0.3", - "htmlparser2": "8.0.2", - "immer": "11.1.4", - "ignore": "7.0.5", - "jsonc-parser": "3.3.1", - "mime-types": "3.0.2", - "minimatch": "10.2.5", - "npm-package-arg": "13.0.2", - "semver": "^7.6.3", - "turndown": "7.2.0", - "venice-ai-sdk-provider": "2.1.1", - "which": "6.0.1", - "xdg-basedir": "5.1.0", - "zod": "catalog:" - }, - "overrides": { - "drizzle-orm": "catalog:" - } -} diff --git a/packages/app-bundle/overlay/packages/core/src/aisdk.ts b/packages/app-bundle/overlay/packages/core/src/aisdk.ts deleted file mode 100644 index b604dac6..00000000 --- a/packages/app-bundle/overlay/packages/core/src/aisdk.ts +++ /dev/null @@ -1,235 +0,0 @@ -export * as AISDK from "./aisdk" - -import { makeLocationNode } from "./effect/app-node" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Cause, Context, Effect, Layer, Schema, Scope } from "effect" -import { ModelV2 } from "./model" -import { ProviderV2 } from "./provider" -import { State } from "./state" - -type SDK = any - -export interface SDKEvent { - readonly model: ModelV2.Info - readonly package: string - readonly options: Record - sdk?: SDK -} - -export interface LanguageEvent { - readonly model: ModelV2.Info - readonly sdk: SDK - readonly options: Record - language?: LanguageModelV3 -} - -function wrapSSE(res: Response, ms: number, ctl: AbortController) { - if (typeof ms !== "number" || ms <= 0) return res - if (!res.body) return res - if (!res.headers.get("content-type")?.includes("text/event-stream")) return res - - const reader = res.body.getReader() - const body = new ReadableStream({ - async pull(ctrl) { - const part = await new Promise>>((resolve, reject) => { - const id = setTimeout(() => { - const err = new Error("SSE read timed out") - ctl.abort(err) - void reader.cancel(err) - reject(err) - }, ms) - - reader.read().then( - (part) => { - clearTimeout(id) - resolve(part) - }, - (err) => { - clearTimeout(id) - reject(err) - }, - ) - }) - - if (part.done) { - ctrl.close() - return - } - - ctrl.enqueue(part.value) - }, - async cancel(reason) { - ctl.abort(reason) - await reader.cancel(reason) - }, - }) - - return new Response(body, { - headers: new Headers(res.headers), - status: res.status, - statusText: res.statusText, - }) -} - -function prepareOptions(model: ModelV2.Info, pkg: string) { - const options: Record = { - name: model.providerID, - ...(model.api.type === "aisdk" ? (model.api.settings ?? {}) : {}), - ...model.request.body, - } - if (model.api.type === "aisdk" && model.api.url) options.baseURL = model.api.url - - const customFetch = options.fetch - const chunkTimeout = options.chunkTimeout - delete options.chunkTimeout - options.fetch = async (input: Parameters[0], init?: RequestInit) => { - const opts = { ...(init ?? {}) } - const signals = [ - opts.signal, - typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined, - options.timeout !== undefined && options.timeout !== null && options.timeout !== false - ? AbortSignal.timeout(options.timeout) - : undefined, - ].filter((item): item is AbortSignal | AbortController => Boolean(item)) - const chunkAbortCtl = signals.find((item): item is AbortController => item instanceof AbortController) - const abortSignals = signals.map((item) => (item instanceof AbortController ? item.signal : item)) - if (abortSignals.length === 1) opts.signal = abortSignals[0] - if (abortSignals.length > 1) opts.signal = AbortSignal.any(abortSignals) - - if ( - (pkg === "@ai-sdk/openai" || pkg === "@ai-sdk/azure" || pkg === "@ai-sdk/amazon-bedrock/mantle") && - opts.body && - opts.method === "POST" - ) { - const body = JSON.parse(opts.body as string) - if (body.store !== true && Array.isArray(body.input)) { - for (const item of body.input) { - if ("id" in item) delete item.id - } - opts.body = JSON.stringify(body) - } - } - - const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, { - ...opts, - timeout: false, - }) - if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res - return wrapSSE(res, chunkTimeout, chunkAbortCtl) - } - - return options -} - -export class InitError extends Schema.TaggedErrorClass()("AISDK.InitError", { - providerID: ProviderV2.ID, - cause: Schema.Defect(), -}) {} - -function initError(providerID: ProviderV2.ID) { - return Effect.catchCause((cause) => Effect.fail(new InitError({ providerID, cause: Cause.squash(cause) }))) -} - -export interface Interface { - readonly hook: { - readonly sdk: ( - callback: (event: SDKEvent) => Effect.Effect | void, - ) => Effect.Effect - readonly language: ( - callback: (event: LanguageEvent) => Effect.Effect | void, - ) => Effect.Effect - } - readonly runSDK: (event: SDKEvent) => Effect.Effect - readonly runLanguage: (event: LanguageEvent) => Effect.Effect - readonly language: (model: ModelV2.Info) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/AISDK") {} - -export const locationLayer = Layer.effect( - Service, - Effect.gen(function* () { - let sdkHooks: ((event: SDKEvent) => Effect.Effect | void)[] = [] - let languageHooks: ((event: LanguageEvent) => Effect.Effect | void)[] = [] - const languages = new Map() - const sdks = new Map() - - const register = ( - hooks: () => ((event: Event) => Effect.Effect | void)[], - update: (hooks: ((event: Event) => Effect.Effect | void)[]) => void, - ) => - Effect.fn("AISDK.hook")(function* (callback: (event: Event) => Effect.Effect | void) { - const scope = yield* Scope.Scope - let active = true - update([...hooks(), callback]) - const dispose = Effect.sync(() => { - if (!active) return - active = false - update(hooks().filter((item) => item !== callback)) - }) - yield* Scope.addFinalizer(scope, dispose) - return { dispose } - }) - - const run = Effect.fnUntraced(function* ( - hooks: readonly ((event: Event) => Effect.Effect | void)[], - event: Event, - ) { - for (const hook of hooks) { - const result = hook(event) - if (Effect.isEffect(result)) yield* result - } - return event - }) - - const service = Service.of({ - hook: { - sdk: register( - () => sdkHooks, - (next) => (sdkHooks = next), - ), - language: register( - () => languageHooks, - (next) => (languageHooks = next), - ), - }, - runSDK: (event) => run(sdkHooks, event), - runLanguage: (event) => run(languageHooks, event), - language: Effect.fn("AISDK.language")(function* (model) { - const key = `${model.providerID}/${model.id}/${model.request.variant ?? "default"}` - const existing = languages.get(key) - if (existing) return existing - if (model.api.type !== "aisdk") - return yield* new InitError({ - providerID: model.providerID, - cause: new Error(`Unsupported api ${model.api.type}`), - }) - - const options = prepareOptions(model, model.api.package) - const sdkKey = JSON.stringify({ - providerID: model.providerID, - api: model.api, - options, - }) - const sdk = - sdks.get(sdkKey) ?? - (yield* service.runSDK({ model, package: model.api.package, options }).pipe(initError(model.providerID))).sdk - if (!sdk) - return yield* new InitError({ - providerID: model.providerID, - cause: new Error("No AISDK provider plugin returned an SDK"), - }) - sdks.set(sdkKey, sdk) - const result = yield* service.runLanguage({ model, sdk, options }).pipe(initError(model.providerID)) - const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.api.id)).pipe( - initError(model.providerID), - ) - languages.set(key, language) - return language - }), - }) - return service - }), -) - -export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] }) diff --git a/packages/app-bundle/overlay/packages/core/src/database/migration.ts b/packages/app-bundle/overlay/packages/core/src/database/migration.ts deleted file mode 100644 index 90dee8ac..00000000 --- a/packages/app-bundle/overlay/packages/core/src/database/migration.ts +++ /dev/null @@ -1,81 +0,0 @@ -export * as DatabaseMigration from "./migration" - -import { sql } from "drizzle-orm" -import { Effect, Semaphore } from "effect" -import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" -import { migrations } from "./migration.gen" -import schema from "./schema.gen" - -type Database = EffectDrizzleSqlite.EffectSQLiteDatabase -type Transaction = Parameters[0]>[0] -const lock = Semaphore.makeUnsafe(1) - -export type Migration = { - id: string - up: (tx: Transaction) => Effect.Effect -} - -export function apply(db: Database) { - return lock.withPermit( - Effect.gen(function* () { - const tables = yield* db.all<{ name: string }>( - sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`, - ) - if (tables.some((table) => table.name === "session")) return yield* applyOnly(db, migrations) - if (tables.length > 0) return yield* Effect.die("Database is not empty and has no session table") - yield* db.transaction((tx) => - Effect.gen(function* () { - yield* schema.up(tx) - yield* tx.run( - sql`CREATE TABLE ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`, - ) - yield* Effect.forEach(migrations, (migration) => - tx.run( - sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`, - ), - ) - }), - ) - }), - ) -} - -export function applyOnly(db: Database, input: Migration[]) { - return Effect.gen(function* () { - yield* db.run( - sql`CREATE TABLE IF NOT EXISTS ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`, - ) - let completed = new Set( - (yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id), - ) - if (completed.size === 0) { - // Existing installs used Drizzle's migration journal. Seed the new - // journal once so TypeScript migrations don't replay old SQL. - if ( - yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${"__drizzle_migrations"}`) - ) { - yield* db.run(sql` - INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed) - SELECT name, ${Date.now()} - FROM ${sql.identifier("__drizzle_migrations")} - WHERE name IS NOT NULL - `) - completed = new Set( - (yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id), - ) - } - } - - for (const migration of input) { - if (completed.has(migration.id)) continue - yield* db.transaction((tx) => - Effect.gen(function* () { - yield* migration.up(tx) - yield* tx.run( - sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`, - ) - }), - ) - } - }) -} diff --git a/packages/app-bundle/overlay/packages/core/src/database/migration/20260410174513_workspace-name.ts b/packages/app-bundle/overlay/packages/core/src/database/migration/20260410174513_workspace-name.ts deleted file mode 100644 index 18483e1c..00000000 --- a/packages/app-bundle/overlay/packages/core/src/database/migration/20260410174513_workspace-name.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Effect } from "effect" -import type { DatabaseMigration } from "../migration" - -export default { - id: "20260410174513_workspace-name", - up(tx) { - return Effect.gen(function* () { - yield* tx.run(`PRAGMA foreign_keys=OFF;`) - yield* tx.run(` - CREATE TABLE \`__new_workspace\` ( - \`id\` text PRIMARY KEY, - \`type\` text NOT NULL, - \`name\` text DEFAULT '' NOT NULL, - \`branch\` text, - \`directory\` text, - \`extra\` text, - \`project_id\` text NOT NULL, - CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE - ); - `) - yield* tx.run( - `INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`, - ) - yield* tx.run(`DROP TABLE \`workspace\`;`) - yield* tx.run(`ALTER TABLE \`__new_workspace\` RENAME TO \`workspace\`;`) - yield* tx.run(`PRAGMA foreign_keys=ON;`) - }) - }, -} satisfies DatabaseMigration.Migration diff --git a/packages/app-bundle/overlay/packages/core/src/plugin/agent.ts b/packages/app-bundle/overlay/packages/core/src/plugin/agent.ts deleted file mode 100644 index 9a763c7e..00000000 --- a/packages/app-bundle/overlay/packages/core/src/plugin/agent.ts +++ /dev/null @@ -1,206 +0,0 @@ -export * as AgentPlugin from "./agent" - -import path from "path" -import { define } from "./internal" -import { Effect } from "effect" -import { AgentV2 } from "../agent" -import { Global } from "../global" -import { Location } from "../location" -import { PermissionV2 } from "../permission" - -const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*") -const BUILD_SYSTEM = - "You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions." - -const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases. - -Your strengths: -- Rapidly finding files using glob patterns -- Searching code and text with powerful regex patterns -- Reading and analyzing file contents - -Guidelines: -- Use Glob for broad file pattern matching -- Use Grep for searching file contents with regex -- Use Read when you know the specific file path you need to read -- Adapt your search approach based on the thoroughness level specified by the caller -- Return file paths as absolute paths in your final response -- For clear communication, avoid using emojis -- Do not create any files, or run bash commands that modify the user's system state in any way - -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 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. - - -Generate a brief title that would help the user find this conversation later. - -Follow all rules in -Use the so you know what a good title looks like. -Your output must be: -- A single line -- <=50 characters -- No explanations - - - -- you MUST use the same language as the user message you are summarizing -- Title must be grammatically correct and read naturally - no word salad -- Never include tool names in the title (e.g. "read tool", "bash tool", "edit tool") -- Focus on the main topic or question the user needs to retrieve -- Vary your phrasing - avoid repetitive patterns like always starting with "Analyzing" -- When a file is mentioned, focus on WHAT the user wants to do WITH the file, not just that they shared it -- Keep exact: technical terms, numbers, filenames, HTTP codes -- Remove: the, this, my, a, an -- Never assume tech stack -- Never use tools -- NEVER respond to questions, just generate a title for the conversation -- The title should NEVER include "summarizing" or "generating" when generating a title -- DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT -- Always output something meaningful, even if the input is minimal. -- If the user message is short or conversational (e.g. "hello", "lol", "what's up", "hey"): - -> create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.) - - - -"debug 500 errors in production" -> Debugging production 500 errors -"refactor user service" -> Refactoring user service -"why is app.js failing" -> app.js failure investigation -"implement rate limiting" -> Rate limiting implementation -"how do I connect postgres to my API" -> Postgres API connection -"best practices for React hooks" -> React hooks best practices -"@src/credential.ts can you add refresh token support" -> Credential refresh token support -"@utils/parser.ts this is broken" -> Parser bug fix -"look at @config.json" -> Config review -"@App.tsx add dark mode toggle" -> Dark mode toggle in App -` - -const PROMPT_SUMMARY = `Summarize what was done in this conversation. Write like a pull request description. - -Rules: -- 2-3 sentences max -- Describe the changes made, not the process -- Do not mention running tests, builds, or other validation steps -- Do not explain what the user asked for -- Write in first person (I added..., I fixed...) -- Never ask questions or add new questions -- If the conversation ends with an unanswered question to the user, preserve that exact question -- If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary` - -export const Plugin = define({ - id: "agent", - effect: Effect.fn(function* (ctx) { - const location = yield* Location.Service - const worktree = location.directory - const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")] - const readonlyExternalDirectory: PermissionV2.Ruleset = [ - { action: "external_directory", resource: "*", effect: "ask" }, - ...whitelistedDirs.map( - (resource): PermissionV2.Rule => ({ action: "external_directory", resource, effect: "allow" }), - ), - ] - const defaults: PermissionV2.Ruleset = [ - { action: "*", resource: "*", effect: "allow" }, - ...readonlyExternalDirectory, - { action: "question", resource: "*", effect: "deny" }, - { action: "plan_enter", resource: "*", effect: "deny" }, - { action: "plan_exit", resource: "*", effect: "deny" }, - { action: "read", resource: "*", effect: "allow" }, - { action: "read", resource: "*.env", effect: "ask" }, - { action: "read", resource: "*.env.*", effect: "ask" }, - { action: "read", resource: "*.env.example", effect: "allow" }, - ] - - yield* ctx.agent.transform((draft) => { - draft.update(AgentV2.defaultID, (item) => { - item.description = "The default agent. Executes tools based on configured permissions." - item.system ??= BUILD_SYSTEM - item.mode = "primary" - item.permissions.push( - ...PermissionV2.merge(defaults, [ - { action: "question", resource: "*", effect: "allow" }, - { action: "plan_enter", resource: "*", effect: "allow" }, - ]), - ) - }) - - draft.update(AgentV2.ID.make("plan"), (item) => { - item.description = "Plan mode. Disallows all edit tools." - item.mode = "primary" - item.permissions.push( - ...PermissionV2.merge(defaults, [ - { action: "question", resource: "*", effect: "allow" }, - { action: "plan_exit", resource: "*", effect: "allow" }, - { action: "external_directory", resource: path.join(Global.Path.data, "plans", "*"), effect: "allow" }, - { action: "edit", resource: "*", effect: "deny" }, - { action: "edit", resource: path.join(".opencode", "plans", "*.md"), effect: "allow" }, - { - action: "edit", - resource: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")), - effect: "allow", - }, - ]), - ) - }) - - draft.update(AgentV2.ID.make("general"), (item) => { - item.description = - "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." - item.mode = "subagent" - item.permissions.push(...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }])) - }) - - draft.update(AgentV2.ID.make("explore"), (item) => { - item.description = - 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.' - item.system = PROMPT_EXPLORE - item.mode = "subagent" - item.permissions.push( - ...PermissionV2.merge( - defaults, - [ - { action: "*", resource: "*", effect: "deny" }, - { action: "grep", resource: "*", effect: "allow" }, - { action: "glob", resource: "*", effect: "allow" }, - { action: "webfetch", resource: "*", effect: "allow" }, - { action: "websearch", resource: "*", effect: "allow" }, - { action: "read", resource: "*", effect: "allow" }, - ], - readonlyExternalDirectory, - ), - ) - }) - - draft.update(AgentV2.ID.make("compaction"), (item) => { - item.mode = "primary" - item.hidden = true - item.system = PROMPT_COMPACTION - item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) - }) - - draft.update(AgentV2.ID.make("title"), (item) => { - item.mode = "primary" - item.hidden = true - item.system = PROMPT_TITLE - item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) - }) - - draft.update(AgentV2.ID.make("summary"), (item) => { - item.mode = "primary" - item.hidden = true - item.system = PROMPT_SUMMARY - item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) - }) - }) - }), -}) diff --git a/packages/app-bundle/overlay/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/app-bundle/overlay/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts deleted file mode 100644 index d416f6f1..00000000 --- a/packages/app-bundle/overlay/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ /dev/null @@ -1,81 +0,0 @@ -import os from "os" -import { InstallationVersion } from "../../installation/version" -import { Effect, Option, Schema } from "effect" -import { define } from "../internal" - -export const CloudflareAIGatewayPlugin = define({ - id: "cloudflare-ai-gateway", - effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( - Effect.fn(function* (evt) { - if (evt.package !== "ai-gateway-provider") return - if (evt.options.baseURL) return - - const config = gatewayConfig(evt.options) - if (!config) return - const metadata = gatewayMetadata(evt.options) - const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider")).pipe(Effect.orDie) - const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified")).pipe( - Effect.orDie, - ) - const gateway = createAiGateway({ - accountId: config.accountId, - gateway: config.gatewayId, - apiKey: config.apiKey, - options: gatewayOptions(evt.options, metadata), - } as any) - const unified = createUnified({ apiKey: config.apiKey }) - evt.sdk = { - languageModel(modelID: string) { - return gateway(unified(modelID)) - }, - } - }), - ) - }), -}) - -type GatewayConfig = { - accountId: string - gatewayId: string - apiKey: string -} - -const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) - -function gatewayConfig(options: Record): GatewayConfig | undefined { - const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId") - // Credential projection copies key metadata into options. The prompt stores the - // gateway as gatewayId, while older config examples may use gateway. - const gatewayId = - process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway") - const apiKey = process.env.CLOUDFLARE_API_TOKEN ?? process.env.CF_AIG_TOKEN ?? stringOption(options, "apiKey") - if (!accountId || !gatewayId || !apiKey) return undefined - - return { accountId, gatewayId, apiKey } -} - -function gatewayMetadata(options: Record) { - // Preserve the legacy cf-aig-metadata header escape hatch for gateway logging - // metadata, but prefer the typed metadata option when present. - if (options.metadata !== undefined) return options.metadata - const raw = (options.headers as Record | undefined)?.["cf-aig-metadata"] - return raw ? Option.getOrUndefined(decodeJson(raw)) : undefined -} - -function gatewayOptions(options: Record, metadata: unknown) { - return { - metadata, - cacheTtl: options.cacheTtl, - cacheKey: options.cacheKey, - skipCache: options.skipCache, - collectLog: options.collectLog, - headers: { - "User-Agent": `opencode/${InstallationVersion} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`, - }, - } -} - -function stringOption(options: Record, key: string) { - return typeof options[key] === "string" ? options[key] : undefined -} diff --git a/packages/app-bundle/overlay/packages/core/src/plugin/provider/opencode.ts b/packages/app-bundle/overlay/packages/core/src/plugin/provider/opencode.ts deleted file mode 100644 index f07d72e5..00000000 --- a/packages/app-bundle/overlay/packages/core/src/plugin/provider/opencode.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { Duration, Effect, Schema, Semaphore, Stream } from "effect" -import type { Scope } from "effect" -import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" -import { define } from "@opencode-ai/plugin/v2/effect/plugin" -import type { CredentialValue } from "@opencode-ai/sdk/v2/types" -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import { EventV2 } from "../../event" -import { Credential } from "../../credential" -import { Integration } from "../../integration" -import { ModelV2 } from "../../model" -import { ProviderV2 } from "../../provider" -import { ConfigProviderV1 } from "../../v1/config/provider" -import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options" -import { ConfigV1 } from "../../v1/config/config" - -const defaultServer = "https://console.opencode.ai" -const clientID = "opencode-cli" -const methodID = Integration.MethodID.make("device") -const RemoteResponse = Schema.Struct({ config: ConfigV1.Info }) -const Device = Schema.Struct({ - device_code: Schema.String, - user_code: Schema.String, - verification_uri_complete: Schema.String, - expires_in: Schema.Number, - interval: Schema.Number, -}) -const Token = Schema.Struct({ - access_token: Schema.String, - refresh_token: Schema.String, - expires_in: Schema.Number, -}) -const TokenPending = Schema.Struct({ error: Schema.String }) -const DeviceToken = Schema.Union([Token, TokenPending]) -const User = Schema.Struct({ id: Schema.String, email: Schema.String }) -const Org = Schema.Struct({ id: Schema.String, name: Schema.String }) - -function oauth(http: HttpClient.HttpClient) { - return { - integrationID: Integration.ID.make("opencode"), - method: { - id: methodID, - type: "oauth", - label: "OpenCode Console account", - }, - authorize: () => - Effect.gen(function* () { - const device = yield* post(http, `${defaultServer}/auth/device/code`, { client_id: clientID }, Device) - return { - mode: "auto" as const, - url: `${defaultServer}${device.verification_uri_complete}`, - instructions: `Enter code: ${device.user_code}`, - callback: poll(http, defaultServer, device.device_code, Duration.seconds(device.interval)), - } - }), - refresh: (credential) => - Effect.gen(function* () { - const server = typeof credential.metadata?.server === "string" ? credential.metadata.server : defaultServer - const token = yield* post( - http, - `${server}/auth/device/token`, - { grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID }, - Token, - ) - return { - ...credential, - access: token.access_token, - refresh: token.refresh_token, - expires: Date.now() + token.expires_in * 1000, - } - }), - label: (credential) => { - return typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined - }, - } satisfies IntegrationOAuthMethodRegistration -} - -export const OpencodePlugin = define({ - id: "opencode", - effect: Effect.fn(function* (ctx) { - const events = yield* EventV2.Service - const http = yield* HttpClient.HttpClient - const loading = Semaphore.makeUnsafe(1) - let connected = false - let providers: typeof ConfigV1.Info.Type.provider | undefined - - const load = Effect.fn("OpencodePlugin.load")(function* () { - const connection = yield* ctx.integration.connection.active("opencode") - const credential = connection - ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined))) - : undefined - connected = connection !== undefined - providers = credential - ? yield* fetchProviders(http, credential).pipe( - Effect.catch((cause) => - Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)), - ), - ) - : undefined - }) - - yield* ctx.integration.transform((draft) => { - draft.update("opencode", (integration) => { - integration.name = "OpenCode" - }) - draft.method.update(oauth(http)) - draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } }) - }) - - connected = (yield* ctx.integration.connection.active("opencode")) !== undefined - yield* ctx.catalog.transform((catalog) => { - for (const [providerID, item] of Object.entries(providers ?? {})) { - catalog.provider.update(providerID, (provider) => { - provider.integrationID = Integration.ID.make("opencode") - if (item.name !== undefined) provider.name = item.name - provider.api = item.npm - ? { type: "aisdk", package: item.npm, url: item.api } - : { type: "native", url: item.api, settings: {} } - Object.assign(provider.request.headers, item.options?.headers) - Object.assign(provider.request.body, withoutCredentials(item.options)) - }) - - for (const [modelID, config] of Object.entries(item.models ?? {})) { - catalog.model.update(providerID, modelID, (model) => { - if (config.family !== undefined) model.family = config.family - if (config.name !== undefined) model.name = config.name - if (config.id !== undefined) model.api.id = config.id - if (config.provider !== undefined) { - model.api = config.provider.npm - ? { - id: model.api.id, - type: "aisdk", - package: config.provider.npm, - url: config.provider.api, - } - : { id: model.api.id, type: "native", url: config.provider.api, settings: {} } - } - if (config.tool_call !== undefined) model.capabilities.tools = config.tool_call - if (config.modalities?.input !== undefined) model.capabilities.input = [...config.modalities.input] - if (config.modalities?.output !== undefined) model.capabilities.output = [...config.modalities.output] - const packageName = config.provider?.npm ?? item.npm - const lowerer = ConfigProviderOptionsV1.get(packageName) - Object.assign(model.request.headers, config.headers) - Object.assign(model.request.body, lowerer.request(withoutCredentials(config.options))) - if (config.variants !== undefined) { - model.variants = Object.entries(config.variants).map(([id, options]) => ({ - id: ModelV2.VariantID.make(id), - headers: { ...(options.headers ?? {}) }, - body: lowerer.request(withoutCredentials(options)), - })) - } - if (config.release_date !== undefined) { - const released = Date.parse(config.release_date) - model.time.released = Number.isFinite(released) ? released : 0 - } - if (config.cost !== undefined) { - model.cost = remoteCost(config.cost) - } - model.status = config.status ?? "active" - model.enabled = config.status !== "deprecated" - if (config.limit !== undefined) model.limit = { ...config.limit } - }) - } - } - - const item = catalog.provider.get(ProviderV2.ID.opencode) - if (!item) return - const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.request.body.apiKey) - catalog.provider.update(item.provider.id, (provider) => { - if (!hasKey) provider.request.body.apiKey = "public" - }) - if (hasKey) return - for (const model of item.models.values()) { - if (!model.cost.some((cost) => cost.input > 0)) continue - catalog.model.update(item.provider.id, model.id, (draft) => { - draft.enabled = false - }) - } - }) - - const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) - yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe( - Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")), - Stream.runForEach(refresh), - Effect.forkScoped({ startImmediately: true }), - ) - yield* refresh().pipe(Effect.forkScoped) - }), -}) - -function fetchProviders(http: HttpClient.HttpClient, value: CredentialValue) { - const metadata = value.metadata - const server = typeof metadata?.server === "string" ? metadata.server : defaultServer - const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined - const token = value.type === "oauth" ? value.access : value.key - return http - .execute( - HttpClientRequest.get(`${server}/api/config`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(token), - HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}), - ), - ) - .pipe( - Effect.flatMap((response) => { - if (response.status === 404) return Effect.succeed(undefined) - return HttpClientResponse.filterStatusOk(response).pipe( - Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)), - Effect.map((remote) => remote.config.provider), - ) - }), - ) -} - -function withoutCredentials(body: Readonly> | undefined) { - return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers")) -} - -function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) { - const base = { - input: input.input, - output: input.output, - cache: { read: input.cache_read ?? 0, write: input.cache_write ?? 0 }, - } - if (!input.context_over_200k) return [base] - return [ - base, - { - tier: { type: "context" as const, size: 200_000 }, - input: input.context_over_200k.input, - output: input.context_over_200k.output, - cache: { - read: input.context_over_200k.cache_read ?? 0, - write: input.context_over_200k.cache_write ?? 0, - }, - }, - ] -} - -function poll(http: HttpClient.HttpClient, server: string, deviceCode: string, interval: Duration.Duration) { - const loop = (wait: Duration.Duration): Effect.Effect => - Effect.gen(function* () { - yield* Effect.sleep(wait) - const result = yield* post( - http, - `${server}/auth/device/token`, - { - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - device_code: deviceCode, - client_id: clientID, - }, - DeviceToken, - false, - ) - if ("access_token" in result) return yield* credential(http, server, result) - if (result.error === "authorization_pending") return yield* loop(wait) - if (result.error === "slow_down") { - return yield* loop(Duration.sum(wait, Duration.seconds(5))) - } - return yield* Effect.fail(new Error(`Device authorization failed: ${result.error}`)) - }) - return loop(interval) -} - -function credential(http: HttpClient.HttpClient, server: string, token: typeof Token.Type) { - return Effect.gen(function* () { - const [user, orgs] = yield* Effect.all( - [ - get(http, `${server}/api/user`, token.access_token, User), - get(http, `${server}/api/orgs`, token.access_token, Schema.Array(Org)), - ], - { concurrency: 2 }, - ) - const org = orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0] - return Credential.OAuth.make({ - type: "oauth" as const, - methodID, - access: token.access_token, - refresh: token.refresh_token, - expires: Date.now() + token.expires_in * 1000, - metadata: { - server, - accountID: user.id, - email: user.email, - orgID: org?.id, - orgName: org?.name, - }, - }) - }) -} - -function get(http: HttpClient.HttpClient, url: string, token: string, schema: S) { - return HttpClient.filterStatusOk(http) - .execute(HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(token))) - .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(schema))) -} - -function post( - http: HttpClient.HttpClient, - url: string, - body: Record, - schema: S, - statusOk = true, -) { - return HttpClientRequest.post(url).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.String))(body), - Effect.flatMap((request) => http.execute(request)), - Effect.flatMap((response) => (statusOk ? HttpClientResponse.filterStatusOk(response) : Effect.succeed(response))), - Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), - ) -} diff --git a/packages/app-bundle/overlay/packages/core/src/plugin/skill/customize-opencode.md b/packages/app-bundle/overlay/packages/core/src/plugin/skill/customize-opencode.md deleted file mode 100644 index 549f15e2..00000000 --- a/packages/app-bundle/overlay/packages/core/src/plugin/skill/customize-opencode.md +++ /dev/null @@ -1,453 +0,0 @@ - - -# Customizing opencode - -opencode validates its own config strictly and refuses to start when a field -is wrong. The shapes below cover the common surface area, but they are a -**summary, not the source of truth**. - -## Full schema reference - -The authoritative list of every config option — with field types, enums, -defaults, and descriptions — lives in the published JSON Schema: - -**** - -If a field is not documented in this skill, or you need to confirm an exact -shape before writing config, **fetch that URL and read the schema directly** -rather than guessing. opencode hard-fails on invalid config, so the cost of a -wrong shape is a broken startup. - -Independently, every `opencode.json` should declare -`"$schema": "https://opencode.ai/config.json"` so the user's editor catches -mistakes as they type. - -## Applying changes - -Config is loaded once when opencode starts and is not hot-reloaded. After -saving changes to `opencode.json`, an agent file, a skill, a plugin, or any -other config-time file, **tell the user to quit and restart opencode** for -the changes to take effect. The running session will keep using the -already-loaded config until then. - -## Where files live - -| Scope | Path | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) | -| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) | -| Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` | -| Global agents | `~/.config/opencode/agent(s)/.md` | -| Project commands | `.opencode/command/.md` or `.opencode/commands/.md` | -| Global commands | `~/.config/opencode/command(s)/.md` | -| Project skills | `.opencode/skill(s)//SKILL.md` | -| Global skills | `~/.config/opencode/skill(s)//SKILL.md` | -| External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` | - -Configs from each scope are deep-merged. Project overrides global. Unknown -top-level keys in `opencode.json` are rejected with `ConfigInvalidError`. - -## opencode.json - -Every field is optional. - -```json -{ - "$schema": "https://opencode.ai/config.json", - "username": "string", - "model": "provider/model-id", - "small_model": "provider/model-id", - "default_agent": "agent-name", - "shell": "/bin/zsh", - "logLevel": "DEBUG" | "INFO" | "WARN" | "ERROR", - "share": "manual" | "auto" | "disabled", - "autoupdate": true | false | "notify", - "snapshot": true, - "instructions": ["AGENTS.md", "docs/style.md"], - - "skills": { - "paths": [".opencode/skills", "/abs/path/to/skills"], - "urls": ["https://example.com/.well-known/skills/"] - }, - - "references": { - "docs": { - "path": "../docs", - "description": "Use for product behavior and documentation conventions" - }, - "sdk": { - "repository": "owner/sdk", - "branch": "main", - "description": "Use for SDK implementation details", - "hidden": true - } - }, - - "agent": { - "my-agent": { - "model": "anthropic/claude-sonnet-4-6", - "mode": "subagent", - "description": "...", - "permission": { "edit": "deny" } - } - }, - - "command": { - "deploy": { "description": "...", "template": "..." } - }, - - "provider": { - "anthropic": { "options": { "apiKey": "..." } } - }, - "disabled_providers": ["openai"], - "enabled_providers": ["anthropic"], - - "mcp": { - "playwright": { - "type": "local", - "command": ["npx", "-y", "@playwright/mcp"], - "enabled": true, - "environment": {} - }, - "remote-thing": { - "type": "remote", - "url": "https://...", - "headers": { "Authorization": "Bearer ..." } - } - }, - - "plugin": [ - "opencode-gemini-auth", - "opencode-foo@1.2.3", - "./local-plugin.ts", - ["opencode-bar", { "option": "value" }] - ], - - "permission": { - "edit": "deny", - "bash": { "git *": "allow", "*": "ask" } - }, - - "formatter": false, - "lsp": false, - - "experimental": { - "primary_tools": ["edit"], - "mcp_timeout": 30000 - }, - - "tool_output": { "max_lines": 200, "max_bytes": 8192 }, - - "compaction": { "auto": true, "tail_turns": 15 } -} -``` - -Shape notes worth being explicit about: - -- `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`. -- `skills` is an object with `paths` and/or `urls`, not an array. -- `references` is an object keyed by alias. Each value is a local path, Git repository, or string shorthand. -- `agent` is an object keyed by agent name, not an array. -- `command` is an object keyed by command name, not an array. -- `plugin` is an array of strings or `[name, options]` tuples, not an object. -- `mcp[name].command` is an array of strings, never a single string. `type` is required. -- `permission` is either a string action or an object keyed by tool name. - -## Skills - -opencode's skill loader scans for `**/SKILL.md` inside skill directories. The -file is named `SKILL.md` exactly, and lives in its own folder named after the -skill: - -``` -.opencode/skills/my-skill/SKILL.md -``` - -Frontmatter: - -```markdown ---- -name: my-skill -description: One sentence covering what this skill does AND when to trigger it. Front-load the literal keywords or filenames the user is likely to say. ---- - -# My Skill - -(skill body in markdown: instructions, examples, references) -``` - -- `name` is required, lowercase hyphen-separated, up to 64 chars, and matches the folder name. -- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both _what_ the skill does and _when_ to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics. -- Optional: `license`, `compatibility`, `metadata` (string-string map). - -Register skills from non-default locations via `skills.paths` (scanned -recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of -skills). - -## References - -References make local directories and Git repositories outside the active -project available as supporting context. Configure them under `references`, -keyed by the alias used in `@` autocomplete: - -```json -{ - "references": { - "docs": { - "path": "../product-docs", - "description": "Use for product behavior and terminology" - }, - "effect": { - "repository": "Effect-TS/effect", - "branch": "main", - "description": "Use for Effect implementation details" - } - } -} -``` - -Local `path` values may be relative to the declaring config, absolute, or use -`~/`. Git `repository` values accept Git URLs, host/path references, and GitHub -`owner/repo` shorthand; `branch` is optional. Both forms support optional -`description` and `hidden` fields. - -- Only references with a `description` are advertised to agents in system context. -- `hidden: true` removes a reference from TUI `@` autocomplete only. It remains available to agents and by direct path. -- Reference directories are automatically allowed through the external-directory boundary; normal read/edit/tool permissions still apply. -- String shorthand is supported: use `"docs": "../docs"` for local paths or `"effect": "Effect-TS/effect"` for Git repositories. - -## Agents - -Two ways to define an agent. Use the file form for anything non-trivial. - -### Inline (in `opencode.json`) - -```json -{ - "agent": { - "my-reviewer": { - "description": "Reviews PRs for style violations.", - "mode": "subagent", - "model": "anthropic/claude-sonnet-4-6", - "permission": { "edit": "deny", "bash": "ask" }, - "prompt": "You are a strict PR reviewer..." - } - } -} -``` - -### File - -``` -.opencode/agent/my-reviewer.md OR .opencode/agents/my-reviewer.md -``` - -```markdown ---- -description: Reviews PRs for style violations. -mode: subagent -model: anthropic/claude-sonnet-4-6 -permission: - edit: deny - bash: ask ---- - -You are a strict PR reviewer. Focus on... -``` - -The file body becomes the agent's `prompt`. Do not also put `prompt:` in the -frontmatter. - -`mode` is one of `"primary"`, `"subagent"`, `"all"`. - -Allowed top-level frontmatter fields: `name, model, variant, description, mode, -hidden, color, steps, options, permission, disable, temperature, top_p`. Any -unknown field is silently routed into `options`. - -To disable a built-in agent: `agent: { build: { disable: true } }`, or in a -file, `disable: true` in frontmatter. - -`default_agent` must point to a non-hidden, primary-mode agent. - -### Built-in agents - -opencode ships with `build`, `plan`, `general`, `explore`. Hidden internal agents: -`compaction`, `title`, `summary`. To override a built-in's fields, define the -same key in `agent: { : { ... } }`. - -## Commands - -opencode's command loader scans for `**/*.md` inside command directories. The -file is named after the command, and lives directly inside the `command` folder: - -``` -.opencode/command/deploy.md -``` - -Frontmatter: - -```markdown ---- -description: One sentence describing what the command does. -agent: build -model: anthropic/claude-sonnet-4-6 ---- - -(command body in markdown: the prompt opencode runs, with $ARGUMENTS for the user's input) -``` - -- `template` is the command body — everything below the frontmatter — and is required: it is the prompt opencode runs when the command is invoked. Do not also put a `template:` key in the frontmatter. -- `$ARGUMENTS` is replaced with everything the user typed after the command; `$1`, `$2`, … pull individual positional arguments. -- Optional: `description`, `agent`, `model`, `variant`, `subtask`. - -## Plugins - -`plugin:` is an array. Each entry is one of: - -```json -"plugin": [ - "opencode-gemini-auth", // npm spec, latest - "opencode-foo@1.2.3", // npm spec, pinned - "./local-plugin.ts", // file path, relative to the declaring config - "file:///abs/path/plugin.js", // file URL - ["opencode-bar", { "key": "val" }] // tuple form with options -] -``` - -Auto-discovered plugins (no config entry needed): any `*.ts` or `*.js` file in -`.opencode/plugin/` or `.opencode/plugins/`. - -A plugin module exports `default` (or any named export) of type -`Plugin = (input: PluginInput, options?) => Promise`. The export is a -function, not a plain object literal, and the function returns an object -(return `{}` if there is nothing to register). - -```ts -import type { Plugin } from "@opencode-ai/plugin" - -export default (async ({ client, project, directory, $ }) => { - return { - config: (cfg) => { - // cfg is the live merged config; mutate fields here. - }, - "tool.execute.before": async (input, output) => { - // mutate output.args before the tool runs - }, - } -}) satisfies Plugin -``` - -Hook surface (mutate `output` in place; return `void`): - -- `event(input)`: every bus event -- `config(cfg)`: once on init with the merged config -- `chat.message`, `chat.params`, `chat.headers` -- `tool.execute.before`, `tool.execute.after` -- `tool.definition` -- `command.execute.before` -- `shell.env` -- `permission.ask` -- `experimental.chat.messages.transform`, `experimental.chat.system.transform`, - `experimental.session.compacting`, `experimental.compaction.autocontinue`, - `experimental.text.complete` - -Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, -`auth: { ... }`, `provider: { ... }`. - -## MCP servers - -`mcp:` is an object keyed by server name. Each server is discriminated by -`type`: - -```json -{ - "mcp": { - "playwright": { - "type": "local", - "command": ["npx", "-y", "@playwright/mcp"], - "enabled": true, - "environment": { "BROWSER": "chromium" } - }, - "github": { - "type": "remote", - "url": "https://...", - "enabled": true, - "headers": { "Authorization": "Bearer {env:GITHUB_TOKEN}" } - }, - "old-server": { "enabled": false } - } -} -``` - -`command` is an array of strings. `environment` sets environment variables for -a local MCP server. `type` is required. Use `enabled: false` to -disable a server inherited from a parent config. String values such as header -tokens support `{env:VAR}` interpolation (and `{file:path}`); the shell-style -`${VAR}` is not substituted. - -## Permissions - -```json -"permission": { - "edit": "deny", - "bash": { "git *": "allow", "rm *": "deny", "*": "ask" }, - "external_directory": { "~/secrets/**": "deny", "*": "allow" } -} -``` - -Actions: `"allow"`, `"ask"`, `"deny"`. - -Per-tool value forms: `"allow"` shorthand (treated as `{"*": "allow"}`), or an -object `{ pattern: action }`. Within an object, **insertion order matters**. -opencode evaluates the LAST matching rule, so put broad rules first and narrow -rules last. - -`permission: "allow"` (a string at the top level) is shorthand for "allow -everything" and is rarely what the user wants. - -Known permission keys: `read, edit, glob, grep, list, bash, task, -external_directory, todowrite, question, webfetch, websearch, lsp, doom_loop, -skill`. Some of these (`todowrite, -question, webfetch, websearch, doom_loop`) only accept a flat -action, not a per-pattern object. - -`external_directory` patterns are filesystem paths (use `~/`, absolute paths, -or globs like `~/projects/**`). - -Per-agent `permission:` overrides top-level `permission:`. Plan Mode lives on -the `plan` agent's permission ruleset (`edit: deny *`). - -## Escape hatches - -When a user's config is broken and opencode won't start, these env vars help: - -- `OPENCODE_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json` - and start from globals only. Run from the project directory, opencode loads, - the user edits the broken file, then they restart without the flag. -- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config. -- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`: - inject inline JSON as a final local-scope merge. -- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins. -- `OPENCODE_PURE=1`: skip external plugins entirely. -- `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`, - `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under - `~/.claude/` and `~/.agents/`. - -## When proposing edits - -- Validate against the schema before writing. If you are unsure of a field's - exact shape, or the field is not covered in this skill, fetch - `https://opencode.ai/config.json` and read the schema rather than guessing. -- Preserve `$schema` and any existing fields the user did not ask to change. -- For agent, command, skill, and plugin definitions, prefer creating new files - in the correct location over inlining everything in `opencode.json`. -- If the user's existing config is malformed, point them at the env-var escape - hatches above so they can edit from inside opencode without breaking their - session. -- After saving any config change, remind the user to quit and restart opencode - — running sessions keep using the already-loaded config. diff --git a/packages/app-bundle/overlay/packages/core/src/ripgrep.ts b/packages/app-bundle/overlay/packages/core/src/ripgrep.ts deleted file mode 100644 index ac8ea52d..00000000 --- a/packages/app-bundle/overlay/packages/core/src/ripgrep.ts +++ /dev/null @@ -1,281 +0,0 @@ -export * as Ripgrep from "./ripgrep" - -import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect" -import { ChildProcess } from "effect/unstable/process" -import { Entry, Match } from "@opencode-ai/schema/filesystem" -import { makeGlobalNode } from "./effect/app-node" -import { AppProcess, collectStream, waitForAbort } from "./process" -import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" -import { RipgrepBinary } from "./ripgrep/binary" - -/** - * Small core-owned ripgrep execution adapter. It deliberately exposes raw - * process-oriented rows, not model text or permission behavior. Search maps - * these rows into filesystem results; leaf tools own - * presentation and permission prompts. - */ - -const ERROR_BYTES = 8 * 1024 -const MAX_RECORD_BYTES = 64 * 1024 -const MAX_SUBMATCHES = 100 - -const RawMatch = Schema.Struct({ - type: Schema.Literal("match"), - data: Schema.Struct({ - path: Schema.Struct({ text: Schema.String }), - lines: Schema.Struct({ text: Schema.String }), - line_number: PositiveInt, - absolute_offset: NonNegativeInt, - submatches: Schema.Array( - Schema.Struct({ - match: Schema.Struct({ text: Schema.String }), - start: NonNegativeInt, - end: NonNegativeInt, - }), - ), - }), -}) - -type RawMatchData = (typeof RawMatch.Type)["data"] - -export class Error extends Schema.TaggedErrorClass()("Ripgrep.Error", { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) {} - -export class InvalidPatternError extends Schema.TaggedErrorClass()("Ripgrep.InvalidPatternError", { - pattern: Schema.String, - message: Schema.String, -}) {} - -export interface FindInput { - readonly cwd: string - readonly pattern: string - readonly limit: number - readonly hidden?: boolean - readonly follow?: boolean - readonly signal?: AbortSignal - readonly onEntry?: (entry: Entry) => Effect.Effect -} - -export interface GlobInput { - readonly cwd: string - readonly pattern: string - readonly limit: number - readonly hidden?: boolean - readonly follow?: boolean - readonly signal?: AbortSignal -} - -export interface GrepInput { - readonly cwd: string - readonly pattern: string - readonly file?: string - readonly include?: string - readonly limit: number - readonly signal?: AbortSignal -} - -export interface Interface { - readonly find: (input: FindInput) => Effect.Effect - readonly glob: (input: GlobInput) => Effect.Effect - readonly grep: (input: GrepInput) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/Ripgrep") {} - -const failure = (message: string, cause?: unknown) => new Error({ message, cause }) - -const isInvalidPattern = (stderr: string) => - stderr.includes("regex parse error") || stderr.includes("error parsing regex") - -const layer = Layer.effect( - Service, - Effect.gen(function* () { - const process = yield* AppProcess.Service - const binary = yield* RipgrepBinary.Service - - const run = (input: { - readonly cwd: string - readonly args: string[] - readonly limit: number - readonly signal?: AbortSignal - readonly parse: (line: string) => Effect.Effect - readonly pattern?: string - readonly onItem?: (item: A) => Effect.Effect - }) => { - const program = Effect.scoped( - Effect.gen(function* () { - const handle = yield* process.spawn( - ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }), - ) - const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe( - Effect.map((output) => output.buffer.toString("utf8")), - Effect.forkScoped, - ) - let observed = 0 - const rows = yield* Stream.decodeText(handle.stdout).pipe( - Stream.splitLines, - Stream.filter((line) => line.length > 0), - Stream.mapEffect(input.parse), - Stream.filter((row): row is A => row !== undefined), - Stream.tap((row) => { - if (!input.onItem || observed++ >= input.limit) return Effect.void - return input.onItem(row) - }), - Stream.take(input.limit + 1), - Stream.runCollect, - Effect.map((chunk) => [...chunk]), - ) - const truncated = rows.length > input.limit - if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false } - - const code = yield* handle.exitCode - const stderr = yield* Fiber.join(stderrFiber) - if (input.pattern && code === 2 && isInvalidPattern(stderr)) { - return yield* new InvalidPatternError({ pattern: input.pattern, message: stderr.trim() }) - } - if (code !== 0 && code !== 1 && code !== 2) { - return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`) - } - return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 } - }), - ) - const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program - return abortable.pipe( - Effect.mapError((cause) => - cause instanceof Error || cause instanceof InvalidPatternError - ? cause - : failure("ripgrep execution failed", cause), - ), - ) - } - - return Service.of({ - glob: (input) => - run({ - cwd: input.cwd, - limit: input.limit, - signal: input.signal, - args: [ - "--no-config", - "--files", - ...(input.hidden ? ["--hidden"] : []), - ...(input.follow ? ["--follow"] : []), - `--glob=${input.pattern}`, - "--glob=!**/.git/**", - ".", - ], - parse: (line) => - Effect.succeed( - line - .replace(/^(?:\.[\\/])+/u, "") - .replace(/^[\\/]+/u, "") - .replaceAll("\\", "/"), - ), - }).pipe( - Effect.map((result) => - result.items.map((relative) => - Entry.make({ - path: RelativePath.make(relative), - type: "file", - }), - ), - ), - Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))), - ), - find: (input) => - run({ - cwd: input.cwd, - limit: input.limit, - signal: input.signal, - args: [ - "--no-config", - "--files", - ...(input.hidden ? ["--hidden"] : []), - ...(input.follow ? ["--follow"] : []), - ...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]), - "--glob=!**/.git/**", - ".", - ], - parse: (line) => { - const relative = line - .replace(/^(?:\.[\\/])+/u, "") - .replace(/^[\\/]+/u, "") - .replaceAll("\\", "/") - return Effect.succeed( - Entry.make({ - path: RelativePath.make(relative), - type: "file", - }), - ) - }, - onItem: input.onEntry, - }).pipe( - Effect.map((result) => result.items), - Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))), - ), - grep: (input) => - run({ - ...input, - args: [ - "--no-config", - "--json", - "--hidden", - "--no-messages", - ...(input.include ? [`--glob=${input.include}`] : []), - "--glob=!**/.git/**", - "--", - input.pattern, - input.file ?? ".", - ], - parse: (line) => - (Buffer.byteLength(line, "utf8") > MAX_RECORD_BYTES - ? Effect.fail(failure(`Ripgrep JSON record exceeded ${MAX_RECORD_BYTES} bytes`)) - : Effect.try({ - try: () => JSON.parse(line) as unknown, - catch: (cause) => failure("Invalid ripgrep JSON output", cause), - }) - ).pipe( - Effect.flatMap((json) => { - if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match") - return Effect.succeed(undefined) - return Schema.decodeUnknownEffect(RawMatch)(json).pipe( - Effect.map((match) => ({ - ...match.data, - path: { text: match.data.path.text.replace(/^\.[\\/]/, "") }, - submatches: match.data.submatches.slice(0, MAX_SUBMATCHES), - })), - Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)), - ) - }), - ), - }).pipe( - Effect.map((result) => - result.items.map((match) => { - const relative = match.path.text - .replace(/^(?:\.[\\/])+/u, "") - .replace(/^[\\/]+/u, "") - .replaceAll("\\", "/") - return Match.make({ - entry: Entry.make({ - path: RelativePath.make(relative), - type: "file", - }), - line: match.line_number, - offset: match.absolute_offset, - text: match.lines.text.length > 2_000 ? match.lines.text.slice(0, 2_000) + "..." : match.lines.text, - submatches: match.submatches.map((submatch) => ({ - text: submatch.match.text, - start: submatch.start, - end: submatch.end, - })), - }) - }), - ), - ), - }) - }), -) - -export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] }) diff --git a/packages/app-bundle/overlay/packages/core/src/session/compaction.ts b/packages/app-bundle/overlay/packages/core/src/session/compaction.ts deleted file mode 100644 index 4b21ff34..00000000 --- a/packages/app-bundle/overlay/packages/core/src/session/compaction.ts +++ /dev/null @@ -1,241 +0,0 @@ -export * as SessionCompaction from "./compaction" - -import { LLM, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm" -import { DateTime, Effect, Stream } from "effect" -import type { Config } from "../config" -import type { EventV2 } from "../event" -import { SessionEvent } from "./event" -import { SessionMessage } from "./message" -import { SessionSchema } from "./schema" -import { Token } from "../util/token" - -const DEFAULT_BUFFER = 20_000 -const DEFAULT_KEEP_TOKENS = 8_000 -const TOOL_OUTPUT_MAX_CHARS = 2_000 -const SUMMARY_OUTPUT_TOKENS = 4_096 -const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside