mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:58:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4de82d97ac | ||
|
|
849a4fe6e4 | ||
|
|
d0a0a30799 | ||
|
|
b395d418cb | ||
|
|
6a8939b7f7 | ||
|
|
def7220bfc | ||
|
|
b20b5a7f3b | ||
|
|
e1f51ce53f | ||
|
|
f6c5afe5a9 | ||
|
|
847771fe06 | ||
|
|
e11d4a1fc9 | ||
|
|
1b99e64e67 | ||
|
|
5005898468 | ||
|
|
627e640c69 | ||
|
|
b6b6f87969 | ||
|
|
9590b51a3f | ||
|
|
f1476dc7d4 | ||
|
|
b8bd88901a | ||
|
|
f1adabcddc | ||
|
|
a597364516 | ||
|
|
c40aecbdbe | ||
|
|
24470e52a5 | ||
|
|
d985fa4307 | ||
|
|
23bbc5cd14 | ||
|
|
6c1aabd5e0 | ||
|
|
a16dd3d4ed | ||
|
|
bbb1a19ed6 | ||
|
|
ebf6fc07a1 | ||
|
|
04513d9692 | ||
|
|
146720e197 | ||
|
|
b1f8cc04af | ||
|
|
082fe93e16 | ||
|
|
709c195905 | ||
|
|
3355b78d91 | ||
|
|
b84c63d034 | ||
|
|
057b5a9dee | ||
|
|
61aefc0759 | ||
|
|
f929f8f100 | ||
|
|
4a57013cf8 | ||
|
|
6b17dc6190 | ||
|
|
e98b90fc51 | ||
|
|
2f17fc9613 | ||
|
|
b8ea3ea091 | ||
|
|
82a5796159 | ||
|
|
9f38562237 | ||
|
|
5b4fb1f770 | ||
|
|
cb88db6ce3 | ||
|
|
66fdd51f0d | ||
|
|
1b0e4e4610 | ||
|
|
842f1dcfdb | ||
|
|
067dfa341f | ||
|
|
98dd65cd60 | ||
|
|
f826f7fc9b | ||
|
|
f0afb6750e | ||
|
|
703d09f306 | ||
|
|
aefaf140c1 | ||
|
|
27ecc46dc7 | ||
|
|
8c7c69c749 | ||
|
|
9b16d0b069 | ||
|
|
ccc11dc92d | ||
|
|
a47dabff22 | ||
|
|
1277ceb426 | ||
|
|
7b7335b7e9 | ||
|
|
7192fa8b7a | ||
|
|
4062b30409 | ||
|
|
44614c79c4 | ||
|
|
7d6b8105f1 | ||
|
|
a5a09c17fa | ||
|
|
10c6040b38 | ||
|
|
f77f5a343e | ||
|
|
41bf4b140b | ||
|
|
fd599277d6 | ||
|
|
2d27c49367 | ||
|
|
974271d6b1 | ||
|
|
985ee1e2ec | ||
|
|
83bee1e776 | ||
|
|
124714ca3a |
@@ -322,6 +322,7 @@ jobs:
|
||||
working-directory: packages/desktop
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: rtl-aware-development
|
||||
description: OpenCode Desktop should be RTL-aware. Use when implementing or reviewing RTL/LTR behavior in the web app, desktop app, CSS, menus, scrolling, resizing, icons, mixed-direction text, or Electron title bars.
|
||||
---
|
||||
|
||||
# RTL-Aware Development
|
||||
|
||||
Treat direction as independent from language. Test English in both directions as well as real RTL and mixed-script content.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Set `lang` and `dir` on the document, and propagate direction through component providers used by portaled menus and popovers. Do not change the selected locale merely to force RTL.
|
||||
- Keep DOM and focus order semantic. Flexbox and Grid already follow `dir`; do not add `row-reverse`, CSS `order`, or reversed markup just to mirror a layout.
|
||||
- Prefer logical CSS for semantic layout. Reserve physical coordinates for pointer positions, canvas geometry, native window controls, and other genuinely physical placement.
|
||||
|
||||
```css
|
||||
/* Avoid */
|
||||
padding-left: 12px;
|
||||
right: 0;
|
||||
border-right: 1px solid;
|
||||
text-align: left;
|
||||
|
||||
/* Prefer */
|
||||
padding-inline-start: 12px;
|
||||
inset-inline-end: 0;
|
||||
border-inline-end: 1px solid;
|
||||
text-align: start;
|
||||
```
|
||||
|
||||
- Isolate mixed-direction text. Use `dir="auto"` or `<bdi>` for unknown text; keep code, URLs, IDs, and filesystem paths LTR without forcing the surrounding component LTR.
|
||||
|
||||
```html
|
||||
<span class="file-row"><bdi dir="auto">README.md</bdi></span> <bdi dir="ltr"><code>C:\src\app.ts</code></bdi>
|
||||
```
|
||||
|
||||
- Mirror directional meaning, not every image. Back/forward, previous/next, disclosure, indentation, and directional progress may need mirroring. Do not mirror brands, clocks, media controls, charts, or text. Reverse physical gradients, `translateX`, SVG transforms, and animation deltas explicitly.
|
||||
- Map interactions through direction. `clientX` remains physical; resizing a logical edge needs an RTL-aware delta. Logical previous/next keyboard controls may swap ArrowLeft/ArrowRight. Follow the relevant WAI-ARIA widget pattern.
|
||||
- Do not assume LTR scrolling. RTL `scrollLeft` can start at `0` and become negative. Prefer `scrollIntoView({ inline: "nearest" })` or a tested direction-normalizing helper.
|
||||
- For Electron title bars, prefer native caption controls and use `titleBarOverlay` plus `env(titlebar-area-*)` for the safe content rectangle. Keep Windows/macOS native-control avoidance and `trafficLightPosition` physical; keep app navigation inside that rectangle logical. Mark interactive titlebar children `app-region: no-drag`.
|
||||
- Verify behavior, not screenshots alone. Check computed styles, pseudo-element geometry, hit zones, focus order, keyboard behavior, submenu direction, zoom/scaling, and both LTR and RTL scroll endpoints.
|
||||
|
||||
## Test Matrix
|
||||
|
||||
- English + LTR
|
||||
- English + forced RTL
|
||||
- A real RTL locale + RTL
|
||||
- Mixed RTL/LTR content, long labels, numbers, code, and paths
|
||||
- Keyboard, pointer resize, scrolling, menus/submenus, and Electron titlebar controls in both directions
|
||||
|
||||
## References
|
||||
|
||||
- [RTL Styling 101, Ahmad Shadeed](https://rtlstyling.com/posts/rtl-styling/)
|
||||
- [CSS-Tricks: RTL Styling 101](https://css-tricks.com/rtl-styling-101/)
|
||||
- [CSS-Tricks: CSS Logical Properties and Values](https://css-tricks.com/css-logical-properties-and-values/)
|
||||
- [W3C: Structural markup and right-to-left text](https://www.w3.org/International/questions/qa-html-dir)
|
||||
- [W3C: Inline bidirectional markup](https://www.w3.org/International/articles/inline-bidi-markup/)
|
||||
- [MDN: CSS logical properties and values](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Logical_properties_and_values)
|
||||
- [MDN: `dir`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir)
|
||||
- [MDN: `scrollLeft`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft)
|
||||
- [web.dev: Logical properties](https://web.dev/learn/css/logical-properties/)
|
||||
- [Electron: Custom title bar](https://www.electronjs.org/docs/latest/tutorial/custom-title-bar)
|
||||
- [WAI-ARIA: Window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
|
||||
- [Kobalte: I18n Provider](https://kobalte.dev/docs/core/components/i18n-provider/)
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -96,7 +96,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
@@ -144,7 +144,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -158,7 +158,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -194,7 +194,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -221,7 +221,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -243,7 +243,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -267,7 +267,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -287,7 +287,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -381,7 +381,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"drizzle-orm": "catalog:",
|
||||
@@ -435,7 +435,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -449,7 +449,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -461,7 +461,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -493,7 +493,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -509,7 +509,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
@@ -540,7 +540,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
@@ -559,7 +559,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -690,7 +690,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -766,7 +766,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -781,7 +781,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -796,7 +796,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
|
||||
@@ -836,7 +836,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -849,7 +849,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -883,7 +883,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -902,7 +902,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -944,7 +944,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -971,7 +971,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -1022,7 +1022,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1078,6 +1078,7 @@
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
||||
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-GRjnvvyj37H36RqiCB7dz5ALAEwvw16izwuk1wsHEpU=",
|
||||
"aarch64-linux": "sha256-0OIn1o6dpqIQ5XgIMzpenMCMqsYzraaYVJk+te5eINU=",
|
||||
"aarch64-darwin": "sha256-sQSQcuox78d8wT1lsKYHqVBl11NusiszQ+gu2XYXZi8=",
|
||||
"x86_64-darwin": "sha256-SINkhMRd4oM1zABSs1Uf3OAzxJ1lgcycl1U4fmi+baY="
|
||||
"x86_64-linux": "sha256-uduwrM143NDSc+tXsi4lVVfoMll2a3BDHRUjuO7GB68=",
|
||||
"aarch64-linux": "sha256-6DUda78XdXY6DP86lIUkweSjys3iG4Y4mo1PiaNuXbg=",
|
||||
"aarch64-darwin": "sha256-AkJwfLULLZVwwz+XU1QcFUZoIS7oVPCn+n/MXEaxrqE=",
|
||||
"x86_64-darwin": "sha256-hAxKGdiITTxQ2uujQt6prNjo3NxGAMMeo+9HlMWK6GU="
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -158,6 +158,7 @@
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
## Localization
|
||||
|
||||
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
|
||||
- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
|
||||
- Render count-sensitive copy only through `language.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `language.t(...)`.
|
||||
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
|
||||
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
|
||||
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
## Required Reading
|
||||
|
||||
- Before writing, changing, or reviewing E2E tests, ALWAYS read and follow Playwright's official [Best Practices](https://playwright.dev/docs/best-practices), [Auto-waiting](https://playwright.dev/docs/actionability), and [Assertions](https://playwright.dev/docs/test-assertions) guides.
|
||||
- Use the official [Locators](https://playwright.dev/docs/locators), [Network](https://playwright.dev/docs/network), and [Test Isolation](https://playwright.dev/docs/browser-contexts) guides when those concerns apply.
|
||||
|
||||
## Test Hygiene
|
||||
|
||||
- Test user-visible behavior with isolated, deterministic data and scoped, unique locators.
|
||||
- Prefer role, label, text, and explicit test-contract locators. Do not use `.first()` or `.last()` merely to silence strictness errors.
|
||||
- Use locator actions, Playwright auto-waiting, and web-first assertions for observable readiness and outcomes.
|
||||
- NEVER use `waitForTimeout`, `setTimeout`, sleeps, animation-frame counts, or other wall-clock delays to synchronize a test. Wait for the specific UI state, request, response, event, or application outcome instead.
|
||||
- Do not treat navigation, a network response, DOM attachment, or visibility alone as proof that asynchronously rendered UI is ready. Assert the state the next action actually requires.
|
||||
- Register event and network waits before the action that triggers them.
|
||||
- Do not retry state-changing actions. Retry idempotent readiness checks, then perform the action once and assert its outcome.
|
||||
- Keep action and assertion timeouts adaptive. Do not use short timeouts as readiness probes or rely on retries to hide flakes.
|
||||
- Assert exact outcomes and identities so stale state, duplicate rendering, and interactions with the wrong element cannot pass.
|
||||
@@ -56,6 +56,32 @@ Benchmarks do not assert machine-dependent performance budgets. Streaming proces
|
||||
|
||||
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
|
||||
|
||||
## Desktop profiler
|
||||
|
||||
The desktop profiler launches the existing production build directly, creates isolated desktop state, chooses an available CDP port, and writes reports under the OS temporary directory by default.
|
||||
|
||||
```sh
|
||||
bun run profile:desktop --help
|
||||
```
|
||||
|
||||
Create a private partial snapshot from the default local database and run Home once:
|
||||
|
||||
```sh
|
||||
bun run profile:desktop --partial-snapshot-out /tmp/opencode-perf.db \
|
||||
--window-end 2026-08-04T06:14:26.878Z \
|
||||
--scenarios home,calibration --skip-build
|
||||
```
|
||||
|
||||
Repeat against the immutable partial snapshot:
|
||||
|
||||
```sh
|
||||
bun run profile:desktop --mode partial-snapshot --db /tmp/opencode-perf.db \
|
||||
--window-end 2026-08-04T06:14:26.878Z \
|
||||
--scenarios home,calibration --runs 3 --skip-build
|
||||
```
|
||||
|
||||
Partial snapshots contain private application data and must not be committed or shared. The profiler copies each partial snapshot to a per-run working database and remaps selected project paths to temporary workspaces, leaving the source snapshot unchanged. `PROFILE_SUMMARY` is the compact comparison output; `PROFILE_REPORT` points to the complete JSON report with the database hash, invocation parameters, raw runs, and attribution data.
|
||||
|
||||
## Chrome traces
|
||||
|
||||
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import { mkdir, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { progress } from "./progress"
|
||||
import type { Options, Target } from "./types"
|
||||
|
||||
export async function createPartialSnapshot(source: string, destination: string, options: Options, targets: Target[]) {
|
||||
await mkdir(path.dirname(destination), { recursive: true })
|
||||
await rm(destination, { force: true })
|
||||
const input = new Database(source, { readonly: true })
|
||||
const schema = input
|
||||
.query(
|
||||
`SELECT type, name, sql FROM sqlite_schema
|
||||
WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END, name`,
|
||||
)
|
||||
.all() as { type: string; name: string; sql: string }[]
|
||||
input.close()
|
||||
|
||||
const output = new Database(destination, { create: true })
|
||||
output.run("PRAGMA foreign_keys = OFF")
|
||||
schema.filter((item) => item.type === "table").forEach((item) => output.run(item.sql))
|
||||
output.run("ATTACH DATABASE ? AS source", source)
|
||||
const selected = [...new Set(targets.map((target) => target.id))]
|
||||
const placeholders = selected.map(() => "?").join(",")
|
||||
|
||||
for (const table of schema.filter((item) => item.type === "table").map((item) => item.name)) {
|
||||
progress("copying partial snapshot table", { table })
|
||||
if (table === "event") continue
|
||||
if (table === "message") {
|
||||
output.run(
|
||||
`INSERT INTO main.message SELECT * FROM source.message
|
||||
WHERE (time_created >= ? AND time_created < ? AND session_id IN (
|
||||
SELECT id FROM source.session WHERE parent_id IS NULL
|
||||
)) OR session_id IN (${placeholders})`,
|
||||
options.windowStart,
|
||||
options.windowEnd,
|
||||
...selected,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (table === "part") {
|
||||
output.run("INSERT INTO main.part SELECT * FROM source.part WHERE message_id IN (SELECT id FROM main.message)")
|
||||
continue
|
||||
}
|
||||
if (["session_context_epoch", "session_input", "session_message", "session_share", "todo"].includes(table)) {
|
||||
output.run(
|
||||
`INSERT INTO main."${table}" SELECT * FROM source."${table}" WHERE session_id IN (${placeholders})`,
|
||||
...selected,
|
||||
)
|
||||
continue
|
||||
}
|
||||
output.run(`INSERT INTO main."${table}" SELECT * FROM source."${table}"`)
|
||||
}
|
||||
output.run("DETACH DATABASE source")
|
||||
schema.filter((item) => item.type !== "table").forEach((item) => output.run(item.sql))
|
||||
output.close()
|
||||
}
|
||||
|
||||
export async function fingerprint(file: string) {
|
||||
const input = Bun.file(file)
|
||||
const hasher = new Bun.CryptoHasher("sha256")
|
||||
for await (const chunk of input.stream()) hasher.update(chunk)
|
||||
return { bytes: input.size, sha256: hasher.digest("hex") }
|
||||
}
|
||||
|
||||
export function loadCorpus(options: Options) {
|
||||
const database = new Database(options.database, { readonly: true })
|
||||
database.run("PRAGMA query_only = ON")
|
||||
const sessions = database
|
||||
.query(
|
||||
`SELECT id, project_id AS projectID, directory, title
|
||||
FROM session AS candidate
|
||||
WHERE parent_id IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM message
|
||||
WHERE session_id = candidate.id AND time_created >= ? AND time_created < ?
|
||||
)`,
|
||||
)
|
||||
.all(options.windowStart, options.windowEnd) as { id: string; projectID: string; directory: string; title: string }[]
|
||||
const messageRows = database.query(
|
||||
`SELECT id, data FROM message
|
||||
WHERE session_id = ? AND time_created >= ? AND time_created < ?
|
||||
ORDER BY time_created, id`,
|
||||
)
|
||||
const partRows = database.query(`SELECT data FROM part WHERE message_id = ? ORDER BY id`)
|
||||
const ranked = sessions
|
||||
.map((session) => {
|
||||
const messages = messageRows.all(session.id, options.windowStart, options.windowEnd) as {
|
||||
id: string
|
||||
data: string
|
||||
}[]
|
||||
const parts = messages.flatMap((message) => partRows.all(message.id) as { data: string }[])
|
||||
return {
|
||||
...session,
|
||||
bytes:
|
||||
messages.reduce((sum, message) => sum + Buffer.byteLength(message.data), 0) +
|
||||
parts.reduce((sum, part) => sum + Buffer.byteLength(part.data), 0),
|
||||
messages: messages.length,
|
||||
parts: parts.length,
|
||||
userTurns: messages.filter((message) => JSON.parse(message.data).role === "user").length,
|
||||
}
|
||||
})
|
||||
.filter((session) => session.messages > 0)
|
||||
.sort((a, b) => a.bytes - b.bytes || a.id.localeCompare(b.id))
|
||||
if (ranked.length === 0) throw new Error("No sessions found in the profile window")
|
||||
const select = (label: Target["label"], percentile: number) => ({
|
||||
label,
|
||||
...ranked[Math.max(0, Math.ceil(ranked.length * percentile) - 1)]!,
|
||||
})
|
||||
const targets = [select("p50", 0.5), select("p95", 0.95), select("max", 1)] satisfies Target[]
|
||||
const typingText = loadTypingText(database, partRows, messageRows, targets[2]!, options)
|
||||
const projectIDs = [...new Set(ranked.map((session) => session.projectID))]
|
||||
database.close()
|
||||
return { targets, typingText, projectIDs }
|
||||
}
|
||||
|
||||
function loadTypingText(
|
||||
database: Database,
|
||||
partRows: ReturnType<Database["query"]>,
|
||||
messageRows: ReturnType<Database["query"]>,
|
||||
target: Target,
|
||||
options: Options,
|
||||
) {
|
||||
const messages = messageRows.all(target.id, options.windowStart, options.windowEnd) as { id: string; data: string }[]
|
||||
const text = messages
|
||||
.filter((message) => JSON.parse(message.data).role === "user")
|
||||
.flatMap((message) =>
|
||||
(partRows.all(message.id) as { data: string }[]).flatMap((part) => {
|
||||
const data = JSON.parse(part.data)
|
||||
return data.type === "text" && typeof data.text === "string" ? [data.text] : []
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => b.length - a.length)[0]
|
||||
if (!text) throw new Error("No real user prompt found for composer profiling")
|
||||
return text
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import type { Options } from "./types"
|
||||
|
||||
export async function prepareDesktopState(
|
||||
options: Options,
|
||||
databasePath: string,
|
||||
userData: string,
|
||||
run: number,
|
||||
projectIDs: string[],
|
||||
) {
|
||||
const database = new Database(databasePath)
|
||||
const projects = database.query("SELECT id, worktree, sandboxes FROM project ORDER BY id").all() as {
|
||||
id: string
|
||||
worktree: string
|
||||
sandboxes: string
|
||||
}[]
|
||||
const selected = new Set(projectIDs)
|
||||
const profileProjects = projects.filter((project) => selected.has(project.id))
|
||||
const worktrees =
|
||||
options.mode === "partial-snapshot"
|
||||
? await remapDirectories(database, profileProjects, path.join(options.output, "workspaces", String(run)))
|
||||
: profileProjects.map((project) => project.worktree)
|
||||
database.close()
|
||||
|
||||
await Bun.write(
|
||||
path.join(userData, "opencode.settings"),
|
||||
JSON.stringify({ firstLaunchOnboardingComplete: true, oldLayoutEligible: true, tauriMigrated: true }),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(userData, "opencode.global.dat"),
|
||||
JSON.stringify({
|
||||
server: JSON.stringify({
|
||||
list: [],
|
||||
projects: { local: worktrees.map((worktree) => ({ worktree, expanded: true })) },
|
||||
lastProject: worktrees[0] ? { local: worktrees[0] } : {},
|
||||
recentlyClosed: {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function remapDirectories(
|
||||
database: Database,
|
||||
projects: { id: string; worktree: string; sandboxes: string }[],
|
||||
root: string,
|
||||
) {
|
||||
await mkdir(root, { recursive: true })
|
||||
const mappings = new Map<string, string>()
|
||||
const worktrees = await Promise.all(
|
||||
projects.map(async (project, index) => {
|
||||
const worktree = path.join(root, `project-${String(index + 1).padStart(3, "0")}`)
|
||||
await mkdir(worktree, { recursive: true })
|
||||
mappings.set(project.worktree, worktree)
|
||||
const sandboxes = JSON.parse(project.sandboxes) as string[]
|
||||
const nextSandboxes = await Promise.all(
|
||||
sandboxes.map(async (sandbox, sandboxIndex) => {
|
||||
const next = path.join(worktree, `sandbox-${sandboxIndex + 1}`)
|
||||
await mkdir(next, { recursive: true })
|
||||
mappings.set(sandbox, next)
|
||||
return next
|
||||
}),
|
||||
)
|
||||
database.run("UPDATE project SET worktree = ?, sandboxes = ? WHERE id = ?", worktree, JSON.stringify(nextSandboxes), project.id)
|
||||
return worktree
|
||||
}),
|
||||
)
|
||||
const byProject = new Map(projects.map((project, index) => [project.id, worktrees[index]!]))
|
||||
const sessions = database.query("SELECT id, project_id, directory FROM session").all() as {
|
||||
id: string
|
||||
project_id: string
|
||||
directory: string
|
||||
}[]
|
||||
const directories = database.query("SELECT * FROM project_directory").all() as {
|
||||
project_id: string
|
||||
directory: string
|
||||
type: string | null
|
||||
strategy: string | null
|
||||
time_created: number
|
||||
}[]
|
||||
const selected = new Set(projects.map((project) => project.id))
|
||||
const nextDirectories = await Promise.all(
|
||||
directories.filter((item) => selected.has(item.project_id)).map(async (item, index) => {
|
||||
const directory =
|
||||
mappings.get(item.directory) ?? path.join(byProject.get(item.project_id) ?? root, `directory-${index + 1}`)
|
||||
await mkdir(directory, { recursive: true })
|
||||
return { ...item, directory }
|
||||
}),
|
||||
)
|
||||
database.transaction(() => {
|
||||
sessions.filter((session) => selected.has(session.project_id)).forEach((session) =>
|
||||
database.run(
|
||||
"UPDATE session SET directory = ? WHERE id = ?",
|
||||
mappings.get(session.directory) ?? byProject.get(session.project_id) ?? worktrees[0]!,
|
||||
session.id,
|
||||
),
|
||||
)
|
||||
database.run(
|
||||
`DELETE FROM project_directory WHERE project_id IN (${projects.map(() => "?").join(",")})`,
|
||||
...projects.map((project) => project.id),
|
||||
)
|
||||
nextDirectories.forEach((item) =>
|
||||
database.run(
|
||||
`INSERT INTO project_directory (project_id, directory, type, strategy, time_created)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
item.project_id,
|
||||
item.directory,
|
||||
item.type,
|
||||
item.strategy,
|
||||
item.time_created,
|
||||
),
|
||||
)
|
||||
})()
|
||||
return worktrees
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import { afterAll, expect, test } from "bun:test"
|
||||
import { mkdir, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { createPartialSnapshot, fingerprint } from "./corpus"
|
||||
import { parseOptions } from "./options"
|
||||
|
||||
const directory = path.join(import.meta.dir, `.tmp-${process.pid}`)
|
||||
const source = path.join(directory, "source.db")
|
||||
const partialSnapshot = path.join(directory, "partial-snapshot.db")
|
||||
await mkdir(directory, { recursive: true })
|
||||
const database = new Database(source, { create: true })
|
||||
database.run("CREATE TABLE sample (value TEXT NOT NULL)")
|
||||
database.run("INSERT INTO sample VALUES ('repeatable')")
|
||||
database.close()
|
||||
|
||||
afterAll(() => rm(directory, { recursive: true, force: true }))
|
||||
|
||||
test("parses a portable fixed-window partial snapshot invocation", () => {
|
||||
const options = parseOptions([
|
||||
"--mode",
|
||||
"partial-snapshot",
|
||||
"--db",
|
||||
source,
|
||||
"--window-end",
|
||||
"2026-08-04T06:14:26.878Z",
|
||||
"--window-hours",
|
||||
"24",
|
||||
"--scenarios",
|
||||
"home,calibration",
|
||||
"--runs",
|
||||
"3",
|
||||
"--skip-build",
|
||||
])!
|
||||
|
||||
expect(options.database).toBe(source)
|
||||
expect(options.windowEnd).toBe(1_785_824_066_878)
|
||||
expect(options.windowStart).toBe(1_785_737_666_878)
|
||||
expect(options.scenarios).toEqual(["home", "calibration"])
|
||||
expect(options.runs).toBe(3)
|
||||
expect(options.build).toBe(false)
|
||||
})
|
||||
|
||||
test("creates a consistent private partial database snapshot", async () => {
|
||||
const options = parseOptions(["--db", source, "--window-end", "2026-08-04T06:14:26.878Z"])!
|
||||
await createPartialSnapshot(source, partialSnapshot, options, [])
|
||||
const copy = new Database(partialSnapshot, { readonly: true })
|
||||
expect(copy.query("SELECT value FROM sample").get()).toEqual({ value: "repeatable" })
|
||||
copy.close()
|
||||
expect(await fingerprint(partialSnapshot)).toEqual({
|
||||
bytes: expect.any(Number),
|
||||
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { existsSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { scenarios, type Options, type Scenario } from "./types"
|
||||
|
||||
const help = `Desktop renderer profiler
|
||||
|
||||
Usage:
|
||||
bun run profile:desktop [options]
|
||||
|
||||
Options:
|
||||
--mode local|partial-snapshot
|
||||
Local corpus or fixed partial snapshot (default: local)
|
||||
--db <path> SQLite database (default: opencode data directory)
|
||||
--partial-snapshot-out <path>
|
||||
Copy the benchmark corpus to a private partial snapshot
|
||||
--output <directory> Report directory (default: OS temp directory)
|
||||
--window-end <ISO|epoch> End of corpus window (default: now; required for partial snapshot)
|
||||
--window-hours <hours> Corpus window size (default: 24)
|
||||
--scenarios <names> Comma list: ${scenarios.join(",")} (default: all)
|
||||
--runs <count> Restart Electron and repeat (default: 1)
|
||||
--skip-build Use the existing desktop production build
|
||||
--diagnostics Capture Chrome traces
|
||||
--cpu Capture sampled CPU summaries
|
||||
--response-urls Attribute Response.text durations by URL
|
||||
--help Show this message
|
||||
|
||||
Partial snapshots contain private application data. Do not commit or share them.
|
||||
`
|
||||
|
||||
export function parseOptions(args: string[], now = Date.now()): Options | undefined {
|
||||
if (args.includes("--help")) {
|
||||
console.log(help)
|
||||
return
|
||||
}
|
||||
|
||||
const value = (name: string) => {
|
||||
const index = args.indexOf(name)
|
||||
if (index === -1) return
|
||||
const result = args[index + 1]
|
||||
if (!result || result.startsWith("--")) throw new Error(`${name} requires a value`)
|
||||
return result
|
||||
}
|
||||
const mode = value("--mode") ?? "local"
|
||||
if (mode !== "local" && mode !== "partial-snapshot") throw new Error(`Unsupported mode: ${mode}`)
|
||||
const endValue = value("--window-end")
|
||||
if (mode === "partial-snapshot" && !endValue)
|
||||
throw new Error("--window-end is required in partial-snapshot mode")
|
||||
const windowEnd = endValue ? parseTime(endValue) : now
|
||||
const windowHours = number(value("--window-hours") ?? "24", "--window-hours")
|
||||
const selected = (value("--scenarios")?.split(",") ?? [...scenarios]).map((item) => item.trim())
|
||||
if (selected.some((item) => !scenarios.includes(item as Scenario)))
|
||||
throw new Error(`--scenarios must contain only: ${scenarios.join(", ")}`)
|
||||
const database = path.resolve(value("--db") ?? path.join(Global.Path.data, "opencode.db"))
|
||||
if (!existsSync(database)) throw new Error(`Database does not exist: ${database}`)
|
||||
|
||||
return {
|
||||
mode,
|
||||
database,
|
||||
output: path.resolve(
|
||||
value("--output") ?? path.join(tmpdir(), "opencode-performance", new Date(windowEnd).toISOString().replace(/[:.]/g, "-")),
|
||||
),
|
||||
windowStart: windowEnd - windowHours * 60 * 60 * 1_000,
|
||||
windowEnd,
|
||||
scenarios: selected as Scenario[],
|
||||
runs: number(value("--runs") ?? "1", "--runs"),
|
||||
build: !args.includes("--skip-build"),
|
||||
diagnostics: args.includes("--diagnostics"),
|
||||
cpu: args.includes("--cpu"),
|
||||
responseURLs: args.includes("--response-urls"),
|
||||
partialSnapshotOut: value("--partial-snapshot-out")
|
||||
? path.resolve(value("--partial-snapshot-out")!)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function parseTime(value: string) {
|
||||
const result = /^\d+$/.test(value) ? Number(value) : Date.parse(value)
|
||||
if (!Number.isFinite(result)) throw new Error(`Invalid --window-end: ${value}`)
|
||||
return result
|
||||
}
|
||||
|
||||
function number(value: string, option: string) {
|
||||
const result = Number(value)
|
||||
if (!Number.isFinite(result) || result <= 0) throw new Error(`${option} must be greater than zero`)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import type { Options, ProbeResult } from "./types"
|
||||
|
||||
export async function installProbe(page: Page, options: Options) {
|
||||
await page.addInitScript((attributeResponses) => {
|
||||
const state = {
|
||||
longTasks: [] as number[],
|
||||
animationFrames: [] as ProbeResult["animationFrames"],
|
||||
frameGaps: [] as number[],
|
||||
responseText: [] as ProbeResult["responseText"],
|
||||
}
|
||||
;(window as Window & { __opencodeRendererProfile?: typeof state }).__opencodeRendererProfile = state
|
||||
if (PerformanceObserver.supportedEntryTypes.includes("longtask")) {
|
||||
new PerformanceObserver((list) =>
|
||||
state.longTasks.push(...list.getEntries().map((entry) => entry.duration)),
|
||||
).observe({ type: "longtask" })
|
||||
}
|
||||
if (PerformanceObserver.supportedEntryTypes.includes("long-animation-frame")) {
|
||||
new PerformanceObserver((list) =>
|
||||
state.animationFrames.push(
|
||||
...list.getEntries().map((entry) => {
|
||||
const frame = entry as PerformanceEntry & {
|
||||
blockingDuration: number
|
||||
scripts?: {
|
||||
duration: number
|
||||
forcedStyleAndLayoutDuration?: number
|
||||
sourceFunctionName?: string
|
||||
sourceURL?: string
|
||||
sourceCharPosition?: number
|
||||
invoker?: string
|
||||
invokerType?: string
|
||||
}[]
|
||||
}
|
||||
return {
|
||||
duration: frame.duration,
|
||||
blockingDuration: frame.blockingDuration,
|
||||
forcedStyleAndLayoutDuration:
|
||||
frame.scripts?.reduce((sum, script) => sum + (script.forcedStyleAndLayoutDuration ?? 0), 0) ?? 0,
|
||||
scripts:
|
||||
frame.scripts?.map((script) => ({
|
||||
function: script.sourceFunctionName || "(anonymous)",
|
||||
source: script.sourceURL?.split("/").at(-1) || "(document)",
|
||||
position: script.sourceCharPosition ?? -1,
|
||||
invoker: script.invoker ?? "(unknown)",
|
||||
invokerType: script.invokerType ?? "(unknown)",
|
||||
duration: script.duration,
|
||||
forcedStyleAndLayoutDuration: script.forcedStyleAndLayoutDuration ?? 0,
|
||||
})) ?? [],
|
||||
}
|
||||
}),
|
||||
),
|
||||
).observe({ type: "long-animation-frame" })
|
||||
}
|
||||
let previous = performance.now()
|
||||
const frame = (now: number) => {
|
||||
const gap = now - previous
|
||||
if (gap > 20) state.frameGaps.push(gap)
|
||||
previous = now
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
requestAnimationFrame(frame)
|
||||
if (!attributeResponses) return
|
||||
const responseText = Response.prototype.text
|
||||
Response.prototype.text = function () {
|
||||
const started = performance.now()
|
||||
const url = this.url
|
||||
return responseText.call(this).then((text) => {
|
||||
state.responseText.push({ url, duration: performance.now() - started })
|
||||
return text
|
||||
})
|
||||
}
|
||||
}, options.responseURLs)
|
||||
}
|
||||
|
||||
export async function resetProbe(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const state = (window as Window & { __opencodeRendererProfile?: ProbeResult }).__opencodeRendererProfile
|
||||
if (!state) return
|
||||
state.longTasks.length = 0
|
||||
state.animationFrames.length = 0
|
||||
state.frameGaps.length = 0
|
||||
state.responseText.length = 0
|
||||
})
|
||||
}
|
||||
|
||||
export async function collectProbe(page: Page) {
|
||||
return page.evaluate(
|
||||
() => (window as Window & { __opencodeRendererProfile?: ProbeResult }).__opencodeRendererProfile!,
|
||||
)
|
||||
}
|
||||
|
||||
export function summarizeProbe(probe: ProbeResult) {
|
||||
const scripts = new Map<
|
||||
string,
|
||||
{
|
||||
function: string
|
||||
source: string
|
||||
position: number
|
||||
invoker: string
|
||||
invokerType: string
|
||||
durationMs: number
|
||||
forcedStyleAndLayoutMs: number
|
||||
}
|
||||
>()
|
||||
probe.animationFrames
|
||||
.flatMap((frame) => frame.scripts)
|
||||
.forEach((script) => {
|
||||
const key = `${script.source}:${script.position}:${script.invoker}`
|
||||
const current = scripts.get(key) ?? {
|
||||
function: script.function,
|
||||
source: script.source,
|
||||
position: script.position,
|
||||
invoker: script.invoker,
|
||||
invokerType: script.invokerType,
|
||||
durationMs: 0,
|
||||
forcedStyleAndLayoutMs: 0,
|
||||
}
|
||||
current.durationMs += script.duration
|
||||
current.forcedStyleAndLayoutMs += script.forcedStyleAndLayoutDuration
|
||||
scripts.set(key, current)
|
||||
})
|
||||
return {
|
||||
longTasks: {
|
||||
count: probe.longTasks.length,
|
||||
totalMs: sum(probe.longTasks),
|
||||
maxMs: Math.max(0, ...probe.longTasks),
|
||||
},
|
||||
longAnimationFrames: {
|
||||
count: probe.animationFrames.length,
|
||||
totalBlockingMs: sum(probe.animationFrames.map((frame) => frame.blockingDuration)),
|
||||
maxDurationMs: Math.max(0, ...probe.animationFrames.map((frame) => frame.duration)),
|
||||
forcedStyleAndLayoutMs: sum(probe.animationFrames.map((frame) => frame.forcedStyleAndLayoutDuration)),
|
||||
scripts: [...scripts.values()].sort((a, b) => b.durationMs - a.durationMs).slice(0, 15),
|
||||
},
|
||||
frameGaps: {
|
||||
count: probe.frameGaps.length,
|
||||
maxMs: Math.max(0, ...probe.frameGaps),
|
||||
},
|
||||
responseText: probe.responseText
|
||||
.map((item) => ({ path: responsePath(item.url), durationMs: item.duration }))
|
||||
.sort((a, b) => b.durationMs - a.durationMs),
|
||||
}
|
||||
}
|
||||
|
||||
export async function startCPUProfile(page: Page, enabled: boolean) {
|
||||
if (!enabled) return { stop: async () => [] }
|
||||
const session = await page.context().newCDPSession(page)
|
||||
await session.send("Profiler.enable")
|
||||
await session.send("Profiler.setSamplingInterval", { interval: 1_000 })
|
||||
await session.send("Profiler.start")
|
||||
return {
|
||||
async stop() {
|
||||
const result = await session.send("Profiler.stop")
|
||||
await session.detach()
|
||||
const self = new Map<number, number>()
|
||||
result.profile.samples?.forEach((id, index) => {
|
||||
self.set(id, (self.get(id) ?? 0) + (result.profile.timeDeltas?.[index] ?? 0) / 1_000)
|
||||
})
|
||||
return result.profile.nodes
|
||||
.map((node) => ({
|
||||
function: node.callFrame.functionName || "(anonymous)",
|
||||
source: sourceName(node.callFrame.url),
|
||||
line: node.callFrame.lineNumber + 1,
|
||||
selfMs: self.get(node.id) ?? 0,
|
||||
}))
|
||||
.filter((node) => node.selfMs >= 1)
|
||||
.sort((a, b) => b.selfMs - a.selfMs)
|
||||
.slice(0, 40)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function responsePath(value: string) {
|
||||
try {
|
||||
return new URL(value).pathname
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function sourceName(value: string) {
|
||||
if (!value) return "(native)"
|
||||
try {
|
||||
return new URL(value).pathname.split("/").at(-1) || "(document)"
|
||||
} catch {
|
||||
return value.split(/[\\/]/).at(-1) || value
|
||||
}
|
||||
}
|
||||
|
||||
function sum(values: number[]) {
|
||||
return values.reduce((total, value) => total + value, 0)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const started = performance.now()
|
||||
|
||||
export function progress(message: string, details?: Record<string, unknown>) {
|
||||
const elapsed = ((performance.now() - started) / 1_000).toFixed(1)
|
||||
const suffix = details ? ` ${JSON.stringify(details)}` : ""
|
||||
console.error(`[desktop-profile +${elapsed}s] ${message}${suffix}`)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { chromium, type Page } from "@playwright/test"
|
||||
import { copyFile, mkdir, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { prepareDesktopState } from "./desktop-state"
|
||||
import { progress } from "./progress"
|
||||
import type { Options } from "./types"
|
||||
|
||||
export async function withDesktop<T>(
|
||||
options: Options,
|
||||
desktop: string,
|
||||
run: number,
|
||||
projectIDs: string[],
|
||||
use: (page: Page) => Promise<T>,
|
||||
) {
|
||||
const port = availablePort()
|
||||
const endpoint = `http://127.0.0.1:${port}`
|
||||
const userData = path.join(options.output, `user-data-${run}`)
|
||||
const database =
|
||||
options.mode === "partial-snapshot" ? path.join(options.output, `working-database-${run}.db`) : options.database
|
||||
await rm(userData, { recursive: true, force: true })
|
||||
await mkdir(userData, { recursive: true })
|
||||
if (database !== options.database) await copyFile(options.database, database)
|
||||
await prepareDesktopState(options, database, userData, run, projectIDs)
|
||||
const electron = path.join(
|
||||
desktop,
|
||||
"node_modules",
|
||||
"electron",
|
||||
"dist",
|
||||
(await Bun.file(path.join(desktop, "node_modules", "electron", "path.txt")).text()).trim(),
|
||||
)
|
||||
progress("launching Electron", { run, port })
|
||||
const child = Bun.spawn([electron, "."], {
|
||||
cwd: desktop,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_DB: database,
|
||||
OPENCODE_CHANNEL: "dev",
|
||||
OPENCODE_PROFILE_LOAF: "1",
|
||||
OPENCODE_PROFILE_CDP_PORT: String(port),
|
||||
OPENCODE_PROFILE_USER_DATA: userData,
|
||||
OPENCODE_PERFORMANCE_TRACE_DIR: options.diagnostics ? path.join(options.output, "traces", String(run)) : "",
|
||||
OPENCODE_PERFORMANCE_RUN_ID: `desktop-${run}`,
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const stdout = drain(child.stdout, "stdout")
|
||||
const stderr = drain(child.stderr, "stderr")
|
||||
let browser: Awaited<ReturnType<typeof chromium.connectOverCDP>> | undefined
|
||||
|
||||
try {
|
||||
progress("waiting for CDP", { run })
|
||||
await waitForCDP(endpoint, child, stdout, stderr)
|
||||
progress("connecting Playwright", { run })
|
||||
browser = await chromium.connectOverCDP(endpoint)
|
||||
progress("waiting for renderer", { run })
|
||||
const page = await waitForRenderer(browser)
|
||||
progress("waiting for desktop API", { run })
|
||||
await page.waitForFunction(() => typeof window.api === "object", undefined, { timeout: 60_000 })
|
||||
progress("desktop ready", { run })
|
||||
return await use(page)
|
||||
} finally {
|
||||
progress("stopping Electron", { run })
|
||||
await browser?.close().catch(() => {})
|
||||
await killTree(child.pid)
|
||||
await Promise.allSettled([stdout, stderr])
|
||||
if (database !== options.database) {
|
||||
await Bun.sleep(500)
|
||||
await rm(database, { force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(command: string[], cwd: string, database: string) {
|
||||
const child = Bun.spawn(command, {
|
||||
cwd,
|
||||
env: { ...process.env, OPENCODE_DB: database, OPENCODE_CHANNEL: "dev" },
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
const code = await child.exited
|
||||
if (code !== 0) throw new Error(`${command.join(" ")} exited with ${code}`)
|
||||
}
|
||||
|
||||
function availablePort() {
|
||||
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
|
||||
const port = server.port
|
||||
server.stop(true)
|
||||
return port
|
||||
}
|
||||
|
||||
async function waitForCDP(
|
||||
endpoint: string,
|
||||
child: ReturnType<typeof Bun.spawn>,
|
||||
stdout: Promise<string>,
|
||||
stderr: Promise<string>,
|
||||
) {
|
||||
const timeout = Date.now() + 5 * 60_000
|
||||
let heartbeat = Date.now() + 10_000
|
||||
while (Date.now() < timeout) {
|
||||
const ready = await fetch(`${endpoint}/json/version`)
|
||||
.then((response) => response.ok)
|
||||
.catch(() => false)
|
||||
if (ready) return
|
||||
if (child.exitCode !== null)
|
||||
throw new Error(`Desktop exited before CDP was ready (${child.exitCode})\n${await stdout}\n${await stderr}`)
|
||||
if (Date.now() >= heartbeat) {
|
||||
progress("still waiting for CDP")
|
||||
heartbeat = Date.now() + 10_000
|
||||
}
|
||||
await Bun.sleep(250)
|
||||
}
|
||||
throw new Error("Timed out waiting for desktop CDP")
|
||||
}
|
||||
|
||||
async function waitForRenderer(browser: Awaited<ReturnType<typeof chromium.connectOverCDP>>) {
|
||||
const timeout = Date.now() + 60_000
|
||||
let heartbeat = Date.now() + 10_000
|
||||
while (Date.now() < timeout) {
|
||||
const page = browser
|
||||
.contexts()
|
||||
.flatMap((context) => context.pages())
|
||||
.find((candidate) => candidate.url().startsWith("oc://renderer"))
|
||||
if (page) return page
|
||||
if (Date.now() >= heartbeat) {
|
||||
progress("still waiting for renderer")
|
||||
heartbeat = Date.now() + 10_000
|
||||
}
|
||||
await Bun.sleep(100)
|
||||
}
|
||||
throw new Error("Desktop renderer target was not found")
|
||||
}
|
||||
|
||||
async function drain(stream: ReadableStream<Uint8Array>, label: string) {
|
||||
const decoder = new TextDecoder()
|
||||
let output = ""
|
||||
let pending = ""
|
||||
for await (const chunk of stream) {
|
||||
const text = decoder.decode(chunk, { stream: true })
|
||||
output = (output + text).slice(-50_000)
|
||||
const lines = (pending + text).split(/\r?\n/)
|
||||
pending = lines.pop() ?? ""
|
||||
lines.filter(Boolean).forEach((line) => progress(`Electron ${label}`, { line: line.slice(0, 500) }))
|
||||
}
|
||||
if (pending) progress(`Electron ${label}`, { line: pending.slice(0, 500) })
|
||||
return output + decoder.decode()
|
||||
}
|
||||
|
||||
async function killTree(pid: number) {
|
||||
if (process.platform !== "win32") {
|
||||
process.kill(pid, "SIGTERM")
|
||||
return
|
||||
}
|
||||
const child = Bun.spawn(["taskkill", "/pid", String(pid), "/T", "/F"], { stdout: "ignore", stderr: "ignore" })
|
||||
await child.exited
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { progress } from "./progress"
|
||||
|
||||
export async function setDesktopRoute(page: Page, route: string) {
|
||||
await page.evaluate(async (value) => {
|
||||
const api = window.api as typeof window.api & { getWindowID?: () => Promise<string> }
|
||||
const id = (await api.getWindowID?.()) ?? "browser"
|
||||
localStorage.setItem(`opencode.desktop.window.${id}.last-active-url`, value)
|
||||
}, route)
|
||||
}
|
||||
|
||||
export async function waitForQuietDOM(page: Page) {
|
||||
progress("waiting for DOM to settle")
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
let timer = setTimeout(done, 750)
|
||||
const deadline = setTimeout(done, 30_000)
|
||||
const observer = new MutationObserver(() => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(done, 750)
|
||||
})
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
|
||||
function done() {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(deadline)
|
||||
observer.disconnect()
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
}),
|
||||
)
|
||||
progress("DOM settled")
|
||||
}
|
||||
|
||||
export async function waitForSelector(page: Page, selector: string, label: string) {
|
||||
progress("waiting for UI", { label })
|
||||
try {
|
||||
await page.waitForSelector(selector, { timeout: 30_000 })
|
||||
} catch (error) {
|
||||
progress("UI wait failed", {
|
||||
label,
|
||||
url: page.url(),
|
||||
body: (await page.locator("body").innerText().catch(() => "")).replace(/\s+/g, " ").slice(0, 500),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
progress("UI ready", { label })
|
||||
}
|
||||
|
||||
export async function domCounts(page: Page, review = false) {
|
||||
return page.evaluate((review) => ({
|
||||
elements: document.getElementsByTagName("*").length,
|
||||
...(review
|
||||
? {
|
||||
diffViewers: document.querySelectorAll('[data-component="file"][data-mode="diff"]').length,
|
||||
diffLines: document.querySelectorAll("[data-line]").length,
|
||||
}
|
||||
: {
|
||||
timelineRows: document.querySelectorAll("[data-timeline-row]").length,
|
||||
messageRows: document.querySelectorAll("[data-message-id]").length,
|
||||
markdownRoots: document.querySelectorAll('[data-component="markdown"]').length,
|
||||
diffViewers: document.querySelectorAll('[data-component="file"][data-mode="diff"]').length,
|
||||
}),
|
||||
}), review)
|
||||
}
|
||||
|
||||
export function sum(values: number[]) {
|
||||
return values.reduce((total, value) => total + value, 0)
|
||||
}
|
||||
|
||||
export function percentile(values: number[], quantile: number) {
|
||||
return values.toSorted((a, b) => a - b)[Math.max(0, Math.ceil(values.length * quantile) - 1)] ?? 0
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { startChromeTrace } from "../chrome-trace"
|
||||
import { collectProbe, resetProbe, startCPUProfile, summarizeProbe } from "./probe"
|
||||
import { progress } from "./progress"
|
||||
import { domCounts, percentile, setDesktopRoute, sum, waitForQuietDOM, waitForSelector } from "./scenario-utils"
|
||||
import type { Options, Target } from "./types"
|
||||
|
||||
export async function runScenarios(page: Page, options: Options, targets: Target[], typingText: string) {
|
||||
const results: unknown[] = []
|
||||
if (options.scenarios.includes("home")) results.push(await profileHome(page, options))
|
||||
if (options.scenarios.includes("calibration")) results.push(await profileCalibration(page))
|
||||
if (options.scenarios.includes("session")) {
|
||||
for (const target of targets) results.push(await profileSession(page, options, target))
|
||||
}
|
||||
if (options.scenarios.some((scenario) => ["composer", "history", "review"].includes(scenario))) {
|
||||
await openSession(page, targets[2]!)
|
||||
}
|
||||
if (options.scenarios.includes("composer")) results.push(await profileComposer(page, options, typingText))
|
||||
if (options.scenarios.includes("history")) results.push(await profileHistory(page, options, targets[2]!))
|
||||
if (options.scenarios.includes("review")) {
|
||||
const review = await profileReview(page, options)
|
||||
if (review) results.push(review)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async function profileHome(page: Page, options: Options) {
|
||||
const measured = await measure(page, options, "home", async () => {
|
||||
await setDesktopRoute(page, "/")
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 })
|
||||
await waitForSelector(page, '[data-component="home-session-row"]', "Home session rows")
|
||||
await waitForQuietDOM(page)
|
||||
})
|
||||
return { ...measured, dom: await domCounts(page) }
|
||||
}
|
||||
|
||||
async function profileCalibration(page: Page) {
|
||||
await resetProbe(page)
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(function opencodeProfileCalibration() {
|
||||
const end = performance.now() + 80
|
||||
while (performance.now() < end) {
|
||||
// Deliberate benchmark-only main-thread block.
|
||||
}
|
||||
requestAnimationFrame(() => setTimeout(resolve, 100))
|
||||
})
|
||||
}),
|
||||
)
|
||||
return { name: "attribution-calibration", ...summarizeProbe(await collectProbe(page)) }
|
||||
}
|
||||
|
||||
async function profileSession(page: Page, options: Options, target: Target) {
|
||||
await prepareHome(page)
|
||||
const measured = await measure(page, options, `session-${target.label}`, async () => {
|
||||
await navigateSession(page, target)
|
||||
await waitForSelector(page, '[data-component="prompt-input"]', "session composer")
|
||||
await waitForQuietDOM(page)
|
||||
})
|
||||
return { ...measured, context: targetContext(target), dom: await domCounts(page) }
|
||||
}
|
||||
|
||||
async function profileComposer(page: Page, options: Options, typingText: string) {
|
||||
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]').first()
|
||||
await editor.click()
|
||||
await page.keyboard.press("Control+A")
|
||||
await page.keyboard.press("Backspace")
|
||||
const printable = [...typingText].filter((character) => !["\r", "\n", "\t"].includes(character))
|
||||
const measuredText = printable.slice(-120).join("")
|
||||
const prefix = printable.slice(0, -measuredText.length).join("")
|
||||
if (prefix) await page.keyboard.insertText(prefix)
|
||||
await waitForQuietDOM(page)
|
||||
const durations: number[] = []
|
||||
const measured = await measure(page, options, "composer-typing", async () => {
|
||||
for (const character of measuredText) {
|
||||
const started = performance.now()
|
||||
await page.keyboard.type(character)
|
||||
durations.push(performance.now() - started)
|
||||
}
|
||||
await waitForQuietDOM(page)
|
||||
})
|
||||
await page.keyboard.press("Control+A")
|
||||
await page.keyboard.press("Backspace")
|
||||
return {
|
||||
...measured,
|
||||
context: { promptCharacters: printable.length, measuredCharacters: measuredText.length },
|
||||
typing: {
|
||||
totalMs: sum(durations),
|
||||
meanMs: sum(durations) / durations.length,
|
||||
p50Ms: percentile(durations, 0.5),
|
||||
p95Ms: percentile(durations, 0.95),
|
||||
maxMs: Math.max(...durations),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function profileHistory(page: Page, options: Options, target: Target) {
|
||||
await waitForSelector(page, '[data-component="prompt-input"]', "history session composer")
|
||||
await waitForQuietDOM(page)
|
||||
let requests = 0
|
||||
const onResponse = (response: { url(): string }) => {
|
||||
if (/\/session\/[^/]+\/message(?:\?|$)/.test(response.url())) requests++
|
||||
}
|
||||
page.on("response", onResponse)
|
||||
const measured = await measure(page, options, "session-max-history-boundary", async () => {
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }).first()
|
||||
await scroller.evaluate((element) => {
|
||||
element.scrollTop = 0
|
||||
element.dispatchEvent(new WheelEvent("wheel", { deltaY: -10_000, bubbles: true }))
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: true }))
|
||||
})
|
||||
const timeout = Date.now() + 60_000
|
||||
while (requests === 0 && Date.now() < timeout) await page.waitForTimeout(50)
|
||||
if (requests === 0) throw new Error("History boundary did not request a page")
|
||||
await waitForQuietDOM(page)
|
||||
})
|
||||
page.off("response", onResponse)
|
||||
return { ...measured, context: targetContext(target), messageRequests: requests }
|
||||
}
|
||||
|
||||
async function profileReview(page: Page, options: Options) {
|
||||
const button = page.getByRole("button", { name: "Toggle review" })
|
||||
if (!(await button.isVisible().catch(() => false))) return
|
||||
const panel = page.locator("#review-panel")
|
||||
if (await panel.isVisible().catch(() => false)) {
|
||||
await button.click()
|
||||
await panel.waitFor({ state: "hidden", timeout: 60_000 })
|
||||
await waitForQuietDOM(page)
|
||||
}
|
||||
const measured = await measure(page, options, "review-open", async () => {
|
||||
await button.click()
|
||||
await panel.waitFor({ state: "visible", timeout: 60_000 })
|
||||
await waitForQuietDOM(page)
|
||||
})
|
||||
return { ...measured, dom: await domCounts(page, true) }
|
||||
}
|
||||
|
||||
async function measure(page: Page, options: Options, name: string, action: () => Promise<void>) {
|
||||
progress("scenario started", { name })
|
||||
await resetProbe(page)
|
||||
const stopTrace = options.diagnostics ? await startChromeTrace(page, name) : undefined
|
||||
const cpu = await startCPUProfile(page, options.cpu)
|
||||
const started = performance.now()
|
||||
await action()
|
||||
const result = {
|
||||
name,
|
||||
elapsedMs: performance.now() - started,
|
||||
...summarizeProbe(await collectProbe(page)),
|
||||
cpu: await cpu.stop(),
|
||||
trace: await stopTrace?.(),
|
||||
}
|
||||
progress("scenario completed", { name, elapsedMs: Math.round(result.elapsedMs), longTasks: result.longTasks.count })
|
||||
return result
|
||||
}
|
||||
|
||||
async function openSession(page: Page, target: Target) {
|
||||
await navigateSession(page, target)
|
||||
await waitForSelector(page, '[data-component="prompt-input"]', "session composer")
|
||||
await waitForQuietDOM(page)
|
||||
}
|
||||
|
||||
async function prepareHome(page: Page) {
|
||||
await setDesktopRoute(page, "/")
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 })
|
||||
await waitForSelector(page, '[data-component="home-session-row"]', "Home session rows")
|
||||
await waitForQuietDOM(page)
|
||||
}
|
||||
|
||||
async function navigateSession(page: Page, target: Target) {
|
||||
await setDesktopRoute(page, `/server/${base64Encode("sidecar")}/session/${target.id}`)
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 })
|
||||
}
|
||||
|
||||
function targetContext(target: Target) {
|
||||
return { serializedBytes: target.bytes, messages: target.messages, parts: target.parts, userTurns: target.userTurns }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export const scenarios = ["home", "calibration", "session", "composer", "history", "review"] as const
|
||||
|
||||
export type Scenario = (typeof scenarios)[number]
|
||||
|
||||
export type Options = {
|
||||
mode: "local" | "partial-snapshot"
|
||||
database: string
|
||||
output: string
|
||||
windowStart: number
|
||||
windowEnd: number
|
||||
scenarios: Scenario[]
|
||||
runs: number
|
||||
build: boolean
|
||||
diagnostics: boolean
|
||||
cpu: boolean
|
||||
responseURLs: boolean
|
||||
partialSnapshotOut?: string
|
||||
}
|
||||
|
||||
export type Target = {
|
||||
label: "p50" | "p95" | "max"
|
||||
id: string
|
||||
projectID: string
|
||||
directory: string
|
||||
title: string
|
||||
bytes: number
|
||||
messages: number
|
||||
parts: number
|
||||
userTurns: number
|
||||
}
|
||||
|
||||
export type ProbeResult = {
|
||||
longTasks: number[]
|
||||
animationFrames: {
|
||||
duration: number
|
||||
blockingDuration: number
|
||||
forcedStyleAndLayoutDuration: number
|
||||
scripts: {
|
||||
function: string
|
||||
source: string
|
||||
position: number
|
||||
invoker: string
|
||||
invokerType: string
|
||||
duration: number
|
||||
forcedStyleAndLayoutDuration: number
|
||||
}[]
|
||||
}[]
|
||||
frameGaps: number[]
|
||||
responseText: { url: string; duration: number }[]
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { createPartialSnapshot, fingerprint, loadCorpus } from "./desktop-profile/corpus"
|
||||
import { parseOptions } from "./desktop-profile/options"
|
||||
import { installProbe } from "./desktop-profile/probe"
|
||||
import { progress } from "./desktop-profile/progress"
|
||||
import { withDesktop, run } from "./desktop-profile/runtime"
|
||||
import { runScenarios } from "./desktop-profile/scenarios"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "../../../..")
|
||||
const desktop = path.join(root, "packages/desktop")
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
if (!options) process.exit(0)
|
||||
|
||||
await mkdir(options.output, { recursive: true })
|
||||
progress("loading corpus", { mode: options.mode })
|
||||
let corpus = loadCorpus(options)
|
||||
if (options.partialSnapshotOut) {
|
||||
progress("creating partial snapshot")
|
||||
await createPartialSnapshot(options.database, options.partialSnapshotOut, options, corpus.targets)
|
||||
options.database = options.partialSnapshotOut
|
||||
options.mode = "partial-snapshot"
|
||||
corpus = loadCorpus(options)
|
||||
}
|
||||
if (options.build) {
|
||||
progress("building desktop production bundle")
|
||||
await run(["bun", "run", "build"], desktop, options.database)
|
||||
}
|
||||
|
||||
progress("corpus ready", { targets: corpus.targets.map((target) => target.label), runs: options.runs })
|
||||
const runs = []
|
||||
for (let index = 1; index <= options.runs; index++) {
|
||||
runs.push(
|
||||
await withDesktop(options, desktop, index, corpus.projectIDs, async (page) => {
|
||||
await installProbe(page, options)
|
||||
await page.evaluate(() => {
|
||||
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ ...settings, general: { ...settings.general, newLayoutDesigns: true } }),
|
||||
)
|
||||
})
|
||||
return runScenarios(page, options, corpus.targets, corpus.typingText)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const report = {
|
||||
schemaVersion: 2,
|
||||
source: options.mode === "partial-snapshot" ? "partial-database-snapshot" : "local-opencode-db",
|
||||
command: process.argv.slice(2),
|
||||
diagnostics: options.diagnostics,
|
||||
profileCPU: options.cpu,
|
||||
database: await fingerprint(options.database),
|
||||
window: {
|
||||
start: new Date(options.windowStart).toISOString(),
|
||||
end: new Date(options.windowEnd).toISOString(),
|
||||
},
|
||||
revision: (await Bun.$`git rev-parse HEAD`.cwd(root).text()).trim(),
|
||||
targets: corpus.targets.map(({ id: _, projectID: __, directory: ___, title: ____, ...target }) => target),
|
||||
summary: summarize(runs),
|
||||
runs: runs.map((results, index) => ({ index: index + 1, results })),
|
||||
}
|
||||
const file = path.join(options.output, "renderer-profile.json")
|
||||
await Bun.write(file, JSON.stringify(report, null, 2))
|
||||
console.log(`PROFILE_REPORT ${file}`)
|
||||
console.log(`PROFILE_SUMMARY ${JSON.stringify(report.summary)}`)
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
|
||||
function summarize(runs: unknown[][]) {
|
||||
type Result = {
|
||||
name: string
|
||||
elapsedMs?: number
|
||||
longTasks: { count: number; totalMs: number; maxMs: number }
|
||||
longAnimationFrames: { totalBlockingMs: number }
|
||||
typing?: { p50Ms: number; p95Ms: number; maxMs: number }
|
||||
}
|
||||
return Object.fromEntries(
|
||||
[...Map.groupBy(runs.flat() as Result[], (result) => result.name)].map(([name, samples]) => [
|
||||
name,
|
||||
{
|
||||
samples: samples.length,
|
||||
elapsedMedianMs: median(samples.flatMap((sample) => sample.elapsedMs ?? [])),
|
||||
longTasks: {
|
||||
maxCount: Math.max(...samples.map((sample) => sample.longTasks.count)),
|
||||
maxTotalMs: Math.max(...samples.map((sample) => sample.longTasks.totalMs)),
|
||||
maxTaskMs: Math.max(...samples.map((sample) => sample.longTasks.maxMs)),
|
||||
},
|
||||
maxBlockingMs: Math.max(...samples.map((sample) => sample.longAnimationFrames.totalBlockingMs)),
|
||||
...(samples[0]?.typing
|
||||
? {
|
||||
typingMedianMs: {
|
||||
p50: median(samples.flatMap((sample) => sample.typing?.p50Ms ?? [])),
|
||||
p95: median(samples.flatMap((sample) => sample.typing?.p95Ms ?? [])),
|
||||
max: median(samples.flatMap((sample) => sample.typing?.maxMs ?? [])),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function median(values: number[]) {
|
||||
if (values.length === 0) return
|
||||
return values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import "./profile-desktop"
|
||||
@@ -197,7 +197,9 @@ export async function setupTimeline(
|
||||
)
|
||||
},
|
||||
async waitForPart(partID: string) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible()
|
||||
const part = page.locator(`[data-timeline-part-id="${partID}"]`)
|
||||
await expect(part).toHaveCount(1)
|
||||
await expect(part).toBeVisible()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ test("opens the comment editor when code is clicked", async ({ page }) => {
|
||||
await line.click()
|
||||
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
|
||||
})
|
||||
|
||||
test("opens the comment editor when a line number is clicked", async ({ page }) => {
|
||||
@@ -27,6 +28,7 @@ test("opens the comment editor when a line number is clicked", async ({ page })
|
||||
await lineNumber.click()
|
||||
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
||||
})
|
||||
|
||||
test("opens the comment editor for a line number range", async ({ page }) => {
|
||||
@@ -36,15 +38,10 @@ test("opens the comment editor for a line number range", async ({ page }) => {
|
||||
await expectAppVisible(start)
|
||||
await expectAppVisible(end)
|
||||
|
||||
const from = await start.boundingBox()
|
||||
const to = await end.boundingBox()
|
||||
if (!from || !to) throw new Error("Missing line number bounds")
|
||||
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2)
|
||||
await page.mouse.up()
|
||||
await start.dragTo(end)
|
||||
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
|
||||
})
|
||||
|
||||
test("shows a comment button when a line number is hovered", async ({ page }) => {
|
||||
@@ -54,31 +51,38 @@ test("shows a comment button when a line number is hovered", async ({ page }) =>
|
||||
|
||||
const comment = review.getByRole("button", { name: "Comment", exact: true })
|
||||
await expect(async () => {
|
||||
await page.mouse.move(0, 0)
|
||||
await lineNumber.hover()
|
||||
await expect(comment).toBeVisible({ timeout: 500 })
|
||||
await comment.click({ timeout: 500 })
|
||||
}).toPass()
|
||||
await expect(lineNumber).toHaveAttribute("data-hovered", "")
|
||||
await expect(comment).toHaveCount(1)
|
||||
await expect(comment).toHaveCSS("pointer-events", "auto")
|
||||
await comment.focus()
|
||||
await expect(comment).toBeFocused()
|
||||
}).toPass({ timeout: 10_000 })
|
||||
await comment.press("Enter")
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
||||
})
|
||||
|
||||
test("stages a submitted line comment in the prompt context", async ({ page }) => {
|
||||
const requests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`)
|
||||
expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET")
|
||||
})
|
||||
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
await review.getByText("export const value = 'after'", { exact: true }).click()
|
||||
await review.getByRole("textbox").fill("Use the existing value instead")
|
||||
await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click()
|
||||
const textbox = review.getByRole("textbox")
|
||||
await expect(textbox).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
|
||||
await textbox.fill("Use the existing value instead")
|
||||
const submit = review.locator('[data-slot="line-comment-action"][data-variant="primary"]')
|
||||
await expect(submit).toBeEnabled()
|
||||
await submit.click()
|
||||
|
||||
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
|
||||
await page.getByRole("tab", { name: "Session" }).click()
|
||||
const context = page.getByText("Use the existing value instead", { exact: true }).last()
|
||||
await expect(context).toBeVisible()
|
||||
await expect(context.locator("..")).toContainText("review.ts:2")
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
|
||||
async function openReview(page: Page) {
|
||||
@@ -144,15 +148,22 @@ async function openReview(page: Page) {
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff")
|
||||
await page.getByRole("tab", { name: "Changes" }).click()
|
||||
const changes = page.getByRole("tab", { name: "Changes" })
|
||||
const diffResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "GET" && response.ok() && new URL(response.url()).pathname === "/api/vcs/diff",
|
||||
)
|
||||
await changes.click()
|
||||
expect((await (await diffResponse).json()).data).toHaveLength(1)
|
||||
await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/)
|
||||
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
await expectAppVisible(review)
|
||||
await review
|
||||
.getByRole("heading", { name: /review\.ts/ })
|
||||
.getByRole("button")
|
||||
.first()
|
||||
.click()
|
||||
const file = review.locator('[data-file="src/review.ts"]')
|
||||
await expectAppVisible(file)
|
||||
const trigger = file.getByRole("button", { expanded: false })
|
||||
await expect(trigger).toHaveCount(1)
|
||||
await trigger.click()
|
||||
await expect(file.getByRole("button", { expanded: true })).toBeVisible()
|
||||
await expect(file.getByText("export const value = 'after'", { exact: true })).toBeVisible()
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const initialPageSize = 20
|
||||
const historyPageSize = 200
|
||||
const historyPageSize = 50
|
||||
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
|
||||
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
||||
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
status,
|
||||
textPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
@@ -17,7 +16,7 @@ test("keeps one connection open while delivering multiple events", async ({ page
|
||||
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)
|
||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
||||
expect(await timeline.transport.acknowledgements()).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -51,20 +50,28 @@ test("parses split JSON and a split multibyte code point", async ({ page }) => {
|
||||
})
|
||||
|
||||
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
|
||||
const sentinelID = "prt_transport_heartbeat_sentinel"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])],
|
||||
})
|
||||
const before = await page.locator("[data-timeline-row]").allTextContents()
|
||||
await timeline.waitForPart("prt_transport_steady")
|
||||
const before = await stableTimelineRows(page)
|
||||
|
||||
await timeline.transport.heartbeat()
|
||||
await timeline.settle()
|
||||
await timeline.transport.writeRaw(": heartbeat\n\n")
|
||||
await timeline.transport.send(partUpdated(textPart(sentinelID, "heartbeat processed")))
|
||||
await timeline.waitForPart(sentinelID)
|
||||
|
||||
expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before)
|
||||
expect(await timeline.transport.connections()).toHaveLength(1)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const rows = await timelineRows(page)
|
||||
return rows.filter((row) => before.some((item) => item.key === row.key))
|
||||
})
|
||||
.toEqual(before)
|
||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
||||
})
|
||||
|
||||
test("reconnects after a clean close", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const timeline = await setupTimeline(page)
|
||||
const first = await timeline.transport.waitForConnection()
|
||||
|
||||
await timeline.transport.close()
|
||||
@@ -77,27 +84,30 @@ test("reconnects after a clean close", async ({ page }) => {
|
||||
})
|
||||
|
||||
test("reconnects after a stream error", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const timeline = await setupTimeline(page)
|
||||
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 timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error")))
|
||||
|
||||
await timeline.waitForPart("prt_transport_error")
|
||||
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",
|
||||
})
|
||||
const timeline = await setupTimeline(page, { protocol: "v2" })
|
||||
const first = await timeline.transport.send(
|
||||
partUpdated(textPart("prt_transport_id", "event with id")),
|
||||
{ id: "timeline-event-7" },
|
||||
"/api/event",
|
||||
)
|
||||
await timeline.waitForPart("prt_transport_id")
|
||||
|
||||
await timeline.transport.error("retry with event id")
|
||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
||||
await timeline.transport.error("retry with event id", "/api/event")
|
||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID, path: "/api/event" })
|
||||
|
||||
expect(first.eventID).toBe("timeline-event-7")
|
||||
expect(connection.headers["last-event-id"]).toBeUndefined()
|
||||
@@ -112,5 +122,35 @@ test("passes through non-event fetches", async ({ page }) => {
|
||||
})
|
||||
|
||||
expect(health).toEqual({ healthy: true })
|
||||
expect(await timeline.transport.connections()).toHaveLength(1)
|
||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
||||
})
|
||||
|
||||
async function stableTimelineRows(page: Page) {
|
||||
let previous: Awaited<ReturnType<typeof timelineRows>> | undefined
|
||||
let stable = 0
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const next = await timelineRows(page)
|
||||
stable = JSON.stringify(next) === JSON.stringify(previous) ? stable + 1 : 0
|
||||
previous = next
|
||||
return stable
|
||||
},
|
||||
{ intervals: [50, 50, 100] },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(2)
|
||||
return previous!
|
||||
}
|
||||
|
||||
function timelineRows(page: Page) {
|
||||
return page.locator("[data-timeline-key]").evaluateAll((elements) =>
|
||||
elements.map((element) => ({
|
||||
key: element.getAttribute("data-timeline-key"),
|
||||
row: element.querySelector("[data-timeline-row]")?.getAttribute("data-timeline-row"),
|
||||
parts: Array.from(element.querySelectorAll("[data-timeline-part-id]"), (part) =>
|
||||
part.getAttribute("data-timeline-part-id"),
|
||||
),
|
||||
text: element.textContent,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const root = "C:/OpenCode/WorkspaceProject"
|
||||
const workspace = "C:/OpenCode/worktree/project/feature"
|
||||
const createdWorkspace = "C:/OpenCode/worktree/project/quick-contrast-fix"
|
||||
const project = {
|
||||
id: "proj_workspaces",
|
||||
worktree: root,
|
||||
vcs: "git" as const,
|
||||
name: "workspace-project",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [workspace],
|
||||
}
|
||||
const provider = {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test model", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
}
|
||||
const diff = {
|
||||
file: "src/workspace.ts",
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
patch: "@@ -1 +1 @@\n-export const workspace = false\n+export const workspace = true",
|
||||
}
|
||||
|
||||
function userMessage(sessionID: string, id: string, text: string, withDiff = false) {
|
||||
return {
|
||||
info: {
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", modelID: "test" },
|
||||
...(withDiff ? { summary: { diffs: [diff] } } : {}),
|
||||
},
|
||||
parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text }],
|
||||
}
|
||||
}
|
||||
|
||||
async function init(page: Page, tab: Record<string, unknown>) {
|
||||
await page.addInitScript(
|
||||
({ root, server, tab }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: root, expanded: true }] }, lastProject: { local: root } }),
|
||||
)
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ server, ...tab }]))
|
||||
},
|
||||
{ root, server, tab },
|
||||
)
|
||||
}
|
||||
|
||||
test("selects an existing workspace from the start menu", async ({ page }) => {
|
||||
const draftID = "draft_workspaces"
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: root,
|
||||
project,
|
||||
provider,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await init(page, { type: "draft", draftID, directory: root })
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
|
||||
|
||||
await page.getByRole("button", { name: /^local$/i }).click()
|
||||
await page.getByRole("menuitem", { name: /Workspace/ }).hover()
|
||||
await page.getByRole("menuitem", { name: "feature" }).click()
|
||||
await expect(page.getByRole("button", { name: /feature/ })).toBeVisible()
|
||||
})
|
||||
|
||||
test("lists and manually deletes workspaces from settings", async ({ page }) => {
|
||||
const draftID = "draft_workspace_settings"
|
||||
const cleanWorkspace = `${workspace}-clean`
|
||||
const inventory = { ...project, sandboxes: [cleanWorkspace] }
|
||||
|
||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: root,
|
||||
project: inventory,
|
||||
provider,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/experimental/worktree**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "DELETE" },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (route.request().method() !== "DELETE") return route.fallback()
|
||||
await transport.send({
|
||||
directory: "global",
|
||||
payload: {
|
||||
id: "evt_workspace_deleted_settings",
|
||||
type: "project.updated",
|
||||
properties: { ...inventory, sandboxes: [] },
|
||||
},
|
||||
})
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: "true",
|
||||
})
|
||||
})
|
||||
await init(page, { type: "draft", draftID, directory: root })
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await transport.waitForConnection()
|
||||
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
|
||||
await page.getByRole("button", { name: /local|new workspace/i }).click()
|
||||
const workspacesTrigger = page.getByRole("menuitem", { name: /Workspace/ })
|
||||
if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover()
|
||||
await page.getByRole("menuitem", { name: "View all" }).click()
|
||||
|
||||
const settings = page.locator(".settings-v2-dialog")
|
||||
await expect(settings.getByRole("tab", { name: "Workspaces" })).toHaveAttribute("data-selected")
|
||||
await expect(settings.getByText(cleanWorkspace, { exact: true })).toBeVisible()
|
||||
|
||||
await settings.getByRole("button", { name: 'Delete workspace "feature-clean"?' }).click()
|
||||
const confirmation = page
|
||||
.locator('[data-component="dialog-v2"]')
|
||||
.filter({ hasText: 'Delete workspace "feature-clean"?' })
|
||||
const removed = page.waitForRequest(
|
||||
(request) => request.method() === "DELETE" && new URL(request.url()).pathname === "/experimental/worktree",
|
||||
)
|
||||
await confirmation.getByRole("button", { name: "Delete workspace" }).click()
|
||||
const request = await removed
|
||||
expect(new URL(request.url()).searchParams.get("directory")).toBe(root)
|
||||
expect(request.postDataJSON()).toEqual({ directory: cleanWorkspace })
|
||||
await expect(settings.getByText(cleanWorkspace, { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("submits the owning prompt after a new workspace becomes ready", async ({ page }) => {
|
||||
const draftID = "draft_workspace_submit"
|
||||
const sessionID = "ses_workspace_submit"
|
||||
const session = {
|
||||
id: sessionID,
|
||||
slug: "workspace-submit",
|
||||
projectID: project.id,
|
||||
directory: createdWorkspace,
|
||||
title: "New session",
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: root,
|
||||
project,
|
||||
provider,
|
||||
sessions: [session],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/experimental/worktree**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify({ name: "quick-contrast-fix", directory: createdWorkspace, branch: "quick-contrast-fix" }),
|
||||
})
|
||||
})
|
||||
await page.route("**/session**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname !== "/session") return route.fallback()
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify(session),
|
||||
})
|
||||
})
|
||||
await page.route(`**/session/${sessionID}/prompt_async**`, async (route) => {
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||
})
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
})
|
||||
})
|
||||
await init(page, { type: "draft", draftID, directory: root })
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await transport.waitForConnection()
|
||||
await page.getByRole("button", { name: /^local$/i }).click()
|
||||
await page.getByRole("menuitem", { name: "New workspace" }).click()
|
||||
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
||||
await editor.fill("Build workspace support")
|
||||
await page.locator('[data-action="prompt-submit"]').click()
|
||||
|
||||
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
|
||||
await expect(lifecycle).toContainText("Creating workspace")
|
||||
const sent = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname === `/session/${sessionID}/prompt_async`,
|
||||
)
|
||||
await transport.send({
|
||||
directory: createdWorkspace,
|
||||
payload: {
|
||||
id: "evt_submit_ready",
|
||||
type: "worktree.ready",
|
||||
properties: { name: "quick-contrast-fix" },
|
||||
},
|
||||
})
|
||||
await sent
|
||||
await expect(lifecycle).toContainText("Workspace created")
|
||||
})
|
||||
|
||||
test("moves a changed local session through workspace creation without changing lifecycle semantics", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sessionID = "ses_workspace_move_new"
|
||||
const messageID = "msg_workspace_move_new"
|
||||
const session = {
|
||||
id: sessionID,
|
||||
slug: "workspace-move-new",
|
||||
projectID: project.id,
|
||||
directory: root,
|
||||
title: "Create a workspace",
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: root,
|
||||
project,
|
||||
provider,
|
||||
sessions: [session],
|
||||
pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Create isolated workspace", true)] }),
|
||||
vcsDiff: [diff],
|
||||
})
|
||||
await page.route("**/experimental/worktree**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify({ name: "quick-contrast-fix", directory: createdWorkspace, branch: "quick-contrast-fix" }),
|
||||
})
|
||||
})
|
||||
await page.route("**/experimental/control-plane/move-session", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||
})
|
||||
return
|
||||
}
|
||||
session.directory = createdWorkspace
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: "null",
|
||||
})
|
||||
})
|
||||
await init(page, { type: "session", sessionId: sessionID })
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await page.locator("[data-session-title]").getByRole("button", { name: "Session details" }).click()
|
||||
const panel = page.locator('[data-component="session-summary-panel"]')
|
||||
await panel.getByRole("button", { name: "Local repository" }).click()
|
||||
await expect(page.getByRole("menuitem", { name: "New workspace" })).toBeVisible()
|
||||
await page.getByRole("menuitem", { name: "New workspace" }).click()
|
||||
|
||||
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
|
||||
await expect(lifecycle).toContainText("Creating workspace")
|
||||
const moved = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname === "/experimental/control-plane/move-session",
|
||||
)
|
||||
await transport.send({
|
||||
directory: createdWorkspace,
|
||||
payload: {
|
||||
id: "evt_worktree_ready",
|
||||
type: "worktree.ready",
|
||||
properties: { name: "quick-contrast-fix" },
|
||||
},
|
||||
})
|
||||
expect((await moved).postDataJSON()).toEqual({
|
||||
sessionID,
|
||||
destination: { directory: createdWorkspace },
|
||||
moveChanges: true,
|
||||
})
|
||||
await transport.send({
|
||||
directory: createdWorkspace,
|
||||
payload: {
|
||||
id: "evt_workspace_created",
|
||||
type: "session.next.moved",
|
||||
properties: {
|
||||
timestamp: Date.now(),
|
||||
sessionID,
|
||||
location: { directory: createdWorkspace },
|
||||
subdirectory: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
await expect(lifecycle).toContainText("Workspace created")
|
||||
})
|
||||
@@ -29,23 +29,38 @@ export type SseEventOptions = {
|
||||
|
||||
export type SseTransport<T> = {
|
||||
server: string
|
||||
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
|
||||
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
waitForConnection(options?: {
|
||||
after?: number
|
||||
timeout?: number
|
||||
path?: SseConnectionRecord["path"]
|
||||
}): Promise<SseConnectionRecord>
|
||||
send(payload: T, options?: SseEventOptions, path?: SseConnectionRecord["path"]): Promise<SseDeliveryAcknowledgement>
|
||||
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
|
||||
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
|
||||
close(): Promise<void>
|
||||
disconnect(message?: string): Promise<void>
|
||||
error(message?: string): Promise<void>
|
||||
error(message?: string, path?: SseConnectionRecord["path"]): Promise<void>
|
||||
connections(): Promise<SseConnectionRecord[]>
|
||||
acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
|
||||
}
|
||||
|
||||
type BrowserCommand<T> =
|
||||
| { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] }
|
||||
| {
|
||||
type: "send"
|
||||
deliveries: { payload: T; options?: SseEventOptions }[]
|
||||
burst: boolean
|
||||
cuts?: number[]
|
||||
path?: SseConnectionRecord["path"]
|
||||
}
|
||||
| { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
|
||||
| { type: "end"; mode: "close" | "disconnect" | "error"; message?: string }
|
||||
| {
|
||||
type: "end"
|
||||
mode: "close" | "disconnect" | "error"
|
||||
message?: string
|
||||
path?: SseConnectionRecord["path"]
|
||||
}
|
||||
| { type: "connections" }
|
||||
| { type: "acknowledgements" }
|
||||
|
||||
@@ -73,7 +88,8 @@ export async function installSseTransport<T>(
|
||||
let nextConnectionID = 0
|
||||
let nextDeliveryID = 0
|
||||
|
||||
const current = () => connections.findLast((connection) => connection.endedAt === undefined)
|
||||
const current = (path?: SseConnectionRecord["path"]) =>
|
||||
connections.findLast((connection) => connection.endedAt === undefined && (!path || connection.path === path))
|
||||
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
|
||||
const boundaries = [...new Set(cuts ?? [])]
|
||||
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
|
||||
@@ -125,8 +141,8 @@ export async function installSseTransport<T>(
|
||||
acknowledgements.push(acknowledgement)
|
||||
return acknowledgement
|
||||
}
|
||||
const end = (mode: "close" | "disconnect" | "error", message?: string) => {
|
||||
const connection = current()
|
||||
const end = (mode: "close" | "disconnect" | "error", message?: string, path?: SseConnectionRecord["path"]) => {
|
||||
const connection = current(path)
|
||||
if (!connection) throw new Error("SSE transport has no active connection")
|
||||
connection.endedAt = performance.now()
|
||||
connection.endedBy = mode
|
||||
@@ -146,8 +162,8 @@ export async function installSseTransport<T>(
|
||||
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 (input.type === "end") return end(input.mode, input.message, input.path)
|
||||
const connection = current(input.type === "send" ? input.path : undefined)
|
||||
if (!connection) throw new Error("SSE transport has no active connection")
|
||||
if (input.type === "raw") {
|
||||
marker(input.marker)
|
||||
@@ -247,21 +263,29 @@ export async function installSseTransport<T>(
|
||||
return {
|
||||
server,
|
||||
async waitForConnection(input = {}) {
|
||||
await page.waitForFunction(
|
||||
(after) => {
|
||||
const connection = await page.waitForFunction(
|
||||
({ after, path }) => {
|
||||
const transport = (window as BrowserTransport).__testSseTransport
|
||||
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
|
||||
return connections?.some((connection) => connection.id > after)
|
||||
return connections?.findLast(
|
||||
(connection) =>
|
||||
connection.id > after && connection.endedAt === undefined && (!path || connection.path === path),
|
||||
)
|
||||
},
|
||||
input.after ?? 0,
|
||||
{ after: input.after ?? 0, path: input.path },
|
||||
{ timeout: input.timeout },
|
||||
)
|
||||
return (await command<SseConnectionRecord[]>({ type: "connections" })).findLast(
|
||||
(connection) => connection.id > (input.after ?? 0),
|
||||
)!
|
||||
let result: SseConnectionRecord | undefined
|
||||
try {
|
||||
result = await connection.jsonValue()
|
||||
} finally {
|
||||
await connection.dispose()
|
||||
}
|
||||
if (!result) throw new Error("SSE transport connection disappeared while waiting")
|
||||
return result
|
||||
},
|
||||
send(payload, eventOptions) {
|
||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
|
||||
send(payload, eventOptions, path) {
|
||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, path })
|
||||
},
|
||||
burst(payloads, eventOptions = []) {
|
||||
return command({
|
||||
@@ -299,8 +323,8 @@ export async function installSseTransport<T>(
|
||||
disconnect(message) {
|
||||
return command({ type: "end", mode: "disconnect", message })
|
||||
},
|
||||
error(message) {
|
||||
return command({ type: "end", mode: "error", message })
|
||||
error(message, path) {
|
||||
return command({ type: "end", mode: "error", message, path })
|
||||
},
|
||||
connections() {
|
||||
return command({ type: "connections" })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.12",
|
||||
"version": "1.18.14",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -28,7 +28,8 @@
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report e2e/playwright-report",
|
||||
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
|
||||
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
|
||||
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts",
|
||||
"profile:desktop": "bun run e2e/performance/profile-desktop.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import "@/index.css"
|
||||
import * as Sentry from "@sentry/solid"
|
||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||
import type { UiI18n } from "@opencode-ai/ui/context/i18n"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { File } from "@opencode-ai/session-ui/file"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
@@ -58,18 +58,24 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import LegacyLayout from "@/pages/layout"
|
||||
import NewLayout from "@/pages/layout-new"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||
|
||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
||||
import { NewHome } from "@/pages/home"
|
||||
import { LegacyHome } from "@/pages/home/legacy-home"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
const NewLayout = lazy(() => import("@/pages/layout-new"))
|
||||
const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome })))
|
||||
const LegacyLayout = lazy(() => import("@/pages/layout"))
|
||||
const LegacyHome = lazy(() => import("@/pages/home/legacy-home").then((module) => ({ default: module.LegacyHome })))
|
||||
const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
|
||||
const SessionPage = lazy(() => import("@/pages/session").then((module) => ({ default: module.SessionPage })))
|
||||
const SessionRouteErrorBoundary = lazy(() =>
|
||||
import("@/pages/session").then((module) => ({ default: module.SessionRouteErrorBoundary })),
|
||||
)
|
||||
const TargetSessionRouteContent = lazy(() =>
|
||||
import("@/pages/session").then((module) => ({ default: module.TargetSessionRouteContent })),
|
||||
)
|
||||
|
||||
const SessionRoute = () => {
|
||||
const settings = useSettings()
|
||||
@@ -235,7 +241,13 @@ function UiI18nBridge(props: ParentProps) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<I18nProvider
|
||||
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
|
||||
value={{
|
||||
locale: language.intl,
|
||||
layoutLocale: language.layoutLocale,
|
||||
t: language.t as UiI18n["t"],
|
||||
plural: language.plural,
|
||||
pluralForm: language.pluralForm,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</I18nProvider>
|
||||
|
||||
@@ -88,7 +88,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
delay="intent"
|
||||
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
||||
value={
|
||||
<ModelTooltip
|
||||
|
||||
@@ -460,7 +460,7 @@ function ModelSelectorPopoverV2View(props: {
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
delay="intent"
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
|
||||
@@ -10,9 +10,6 @@ import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
||||
import homeImage from "@/assets/help/home.png"
|
||||
import tabsImage from "@/assets/help/tabs.png"
|
||||
|
||||
// TODO: wire to changelog / seen-state when available
|
||||
const showPopover = () => true
|
||||
|
||||
// can remove this after the tabs rollout has been out for a while
|
||||
export function TabsInfoPopup() {
|
||||
const settings = useSettings()
|
||||
@@ -134,7 +131,6 @@ export function TabsInfoPopup() {
|
||||
<p>{language.t("help.tabs.home")}</p>
|
||||
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||
<p>{language.t("help.tabs.persistence")}</p>
|
||||
<p>{language.t("help.tabs.worktrees")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
|
||||
@@ -47,7 +47,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
{...(!props.newLayoutDesigns ? { openDelay: 800 } : {})}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
|
||||
@@ -53,12 +53,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
||||
<For each={props.comments ?? []}>
|
||||
{(item) => (
|
||||
<div class="relative group shrink-0">
|
||||
<TooltipV2
|
||||
value={item.comment}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
contentClass="max-w-[300px] break-words"
|
||||
>
|
||||
<TooltipV2 value={item.comment} placement="top" contentClass="max-w-[300px] break-words">
|
||||
<CommentCardV2
|
||||
comment={item.comment ?? ""}
|
||||
path={item.path}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Prompt, PromptStore } from "@/context/prompt"
|
||||
import type { ModelSelection } from "@/context/local"
|
||||
import { Worktree } from "@/utils/worktree"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
const createdClients: string[] = []
|
||||
const createdSessions: string[] = []
|
||||
const sessionCreateInputs: Array<{
|
||||
type SessionCreateInput = {
|
||||
agent?: string
|
||||
model?: { id: string; providerID: string; variant?: string }
|
||||
location?: { directory: string }
|
||||
}> = []
|
||||
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
||||
}
|
||||
const optimistic: Array<{
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
@@ -22,24 +22,35 @@ const optimistic: Array<{
|
||||
variant?: string
|
||||
}
|
||||
}> = []
|
||||
const optimisticSeeded: boolean[] = []
|
||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||
const syncedDirectories: string[] = []
|
||||
const sentShellDirectories: string[] = []
|
||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||
const sentPrompts: string[] = []
|
||||
const promptInputs: unknown[] = []
|
||||
const sentCommands: unknown[] = []
|
||||
const commands: Array<{ name: string }> = []
|
||||
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
|
||||
const syncedServers: string[] = []
|
||||
const optimisticServers: string[] = []
|
||||
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
|
||||
let serverSessionSyncs = 0
|
||||
|
||||
let params: { id?: string } = {}
|
||||
let search: { draftId?: string } = {}
|
||||
let selected = "/repo/worktree-a"
|
||||
let variant: string | undefined
|
||||
let permissionServer = "server-a"
|
||||
let createSessionGate: Promise<void> | undefined
|
||||
let createWorktreeGate: Promise<void> | undefined
|
||||
let worktreeFailure: Error | undefined
|
||||
let worktreeHung = false
|
||||
let worktreeCreates = 0
|
||||
let activeSDK = "server-a"
|
||||
let activeServerSync = "server-a"
|
||||
let activeDirectorySync = "server-a"
|
||||
let commands: Array<{ name: string }> = []
|
||||
let worktreeDirectory = "/repo/new-0"
|
||||
let worktreeID = 0
|
||||
const draftServers: Record<string, string> = {}
|
||||
|
||||
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||
@@ -67,19 +78,21 @@ const prompt = {
|
||||
replaceComments: () => undefined,
|
||||
items: () => [],
|
||||
},
|
||||
capture: () => prompt,
|
||||
capture: (scope?: unknown, target?: unknown) => {
|
||||
promptCaptures.push({ scope, target })
|
||||
return prompt
|
||||
},
|
||||
}
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
const clientFor = (directory: string) => {
|
||||
createdClients.push(directory)
|
||||
return {
|
||||
api: {
|
||||
session: {
|
||||
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
||||
create: async (input: SessionCreateInput) => {
|
||||
await createSessionGate
|
||||
const location = input.location?.directory ?? directory
|
||||
createdSessions.push(location)
|
||||
sessionCreateInputs.push(input)
|
||||
return {
|
||||
id: `session-${createdSessions.length}`,
|
||||
projectID: "project",
|
||||
@@ -102,6 +115,7 @@ const clientFor = (directory: string) => {
|
||||
},
|
||||
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||
sentShell.push(input)
|
||||
sentShellDirectories.push(directory)
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -110,7 +124,16 @@ const clientFor = (directory: string) => {
|
||||
abort: async () => ({ data: undefined }),
|
||||
},
|
||||
worktree: {
|
||||
create: async () => ({ data: { directory: `${directory}/new` } }),
|
||||
create: async (_input: unknown, options?: { signal?: AbortSignal }) => {
|
||||
worktreeCreates++
|
||||
if (worktreeHung)
|
||||
return new Promise<never>((_, reject) => {
|
||||
options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true })
|
||||
})
|
||||
await createWorktreeGate
|
||||
if (worktreeFailure) throw worktreeFailure
|
||||
return { data: { directory: worktreeDirectory } }
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -127,13 +150,13 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||
createOpencodeClient: (input: { directory: string }) => {
|
||||
createdClients.push(input.directory)
|
||||
return clientFor(input.directory)
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/ui/toast", () => ({
|
||||
Toast: { Region: () => null },
|
||||
toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined },
|
||||
showToast: () => 0,
|
||||
}))
|
||||
|
||||
@@ -151,20 +174,13 @@ beforeAll(async () => {
|
||||
current: () => ({ name: "agent" }),
|
||||
},
|
||||
session: {
|
||||
promote(directory: string, sessionID: string) {
|
||||
promoted.push({ directory, sessionID })
|
||||
},
|
||||
promote: () => undefined,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/permission", () => {
|
||||
const state = (server: string) => ({
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
enabledAutoAccept.push({ server, sessionID, directory })
|
||||
},
|
||||
})
|
||||
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
|
||||
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
|
||||
})
|
||||
|
||||
mock.module("@/context/server", () => ({
|
||||
@@ -173,7 +189,10 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@/context/tabs", () => ({
|
||||
useTabs: () => ({
|
||||
draft: () => ({ server: "project-server" }),
|
||||
draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }),
|
||||
updateDraft: (draftID: string, draft: { worktree?: string }) => {
|
||||
updatedDrafts.push({ draftID, ...draft })
|
||||
},
|
||||
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
|
||||
promotedDrafts.push({ draftID, ...session })
|
||||
},
|
||||
@@ -194,72 +213,75 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@/context/sdk", () => ({
|
||||
useSDK: () => {
|
||||
const sdk = {
|
||||
scope: "local",
|
||||
directory: "/repo/main",
|
||||
return () => ({
|
||||
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
|
||||
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
|
||||
client: rootClient,
|
||||
api: rootClient.api,
|
||||
url: "http://localhost:4096",
|
||||
createApi: (directory: string) => clientFor(directory).api,
|
||||
createClient(opts: any) {
|
||||
return clientFor(opts.directory)
|
||||
},
|
||||
}
|
||||
return () => sdk
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/sync", () => ({
|
||||
useSync: () => () => ({
|
||||
data: { command: commands },
|
||||
session: {
|
||||
optimistic: {
|
||||
add: (value: {
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||
}) => {
|
||||
optimistic.push(value)
|
||||
optimisticSeeded.push(
|
||||
!!value.directory &&
|
||||
!!value.sessionID &&
|
||||
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
|
||||
)
|
||||
useSync: () => () => {
|
||||
const server = activeDirectorySync
|
||||
return {
|
||||
data: { command: commands },
|
||||
session: {
|
||||
optimistic: {
|
||||
add: (value: {
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||
}) => {
|
||||
optimisticServers.push(server)
|
||||
optimistic.push(value)
|
||||
},
|
||||
remove: () => undefined,
|
||||
},
|
||||
remove: () => undefined,
|
||||
},
|
||||
},
|
||||
set: () => undefined,
|
||||
}),
|
||||
set: () => undefined,
|
||||
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/server-sync", () => ({
|
||||
useServerSync: () => () => ({
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
set: () => undefined,
|
||||
sync: async () => {
|
||||
serverSessionSyncs++
|
||||
},
|
||||
},
|
||||
child: (directory: string) => {
|
||||
syncedDirectories.push(directory)
|
||||
storedSessions[directory] ??= []
|
||||
return [
|
||||
{ session: storedSessions[directory] },
|
||||
(...args: unknown[]) => {
|
||||
if (args[0] !== "session") return
|
||||
const next = args[1]
|
||||
if (typeof next === "function") {
|
||||
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
||||
return
|
||||
}
|
||||
if (Array.isArray(next)) {
|
||||
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
||||
}
|
||||
useServerSync: () => () => {
|
||||
const server = activeServerSync
|
||||
return {
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
set: () => undefined,
|
||||
sync: async () => {
|
||||
serverSessionSyncs++
|
||||
},
|
||||
]
|
||||
},
|
||||
}),
|
||||
},
|
||||
child: (directory: string) => {
|
||||
syncedServers.push(server)
|
||||
storedSessions[directory] ??= []
|
||||
return [
|
||||
{ session: storedSessions[directory] },
|
||||
(...args: unknown[]) => {
|
||||
if (args[0] !== "session") return
|
||||
const next = args[1]
|
||||
if (typeof next === "function") {
|
||||
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
||||
return
|
||||
}
|
||||
if (Array.isArray(next)) {
|
||||
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/platform", () => ({
|
||||
@@ -279,198 +301,153 @@ beforeAll(async () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
createdClients.length = 0
|
||||
createdSessions.length = 0
|
||||
sessionCreateInputs.length = 0
|
||||
enabledAutoAccept.length = 0
|
||||
optimistic.length = 0
|
||||
optimisticSeeded.length = 0
|
||||
promoted.length = 0
|
||||
promotedDrafts.length = 0
|
||||
updatedDrafts.length = 0
|
||||
sentCommands.length = 0
|
||||
sentPrompts.length = 0
|
||||
promptInputs.length = 0
|
||||
sentCommands.length = 0
|
||||
commands.length = 0
|
||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
syncedServers.length = 0
|
||||
optimisticServers.length = 0
|
||||
promptCaptures.length = 0
|
||||
params = {}
|
||||
search = {}
|
||||
sentShell.length = 0
|
||||
syncedDirectories.length = 0
|
||||
sentShellDirectories.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
variant = undefined
|
||||
permissionServer = "server-a"
|
||||
activeSDK = "server-a"
|
||||
activeServerSync = "server-a"
|
||||
activeDirectorySync = "server-a"
|
||||
commands = []
|
||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
worktreeDirectory = `/repo/new-${++worktreeID}`
|
||||
createSessionGate = undefined
|
||||
serverSessionSyncs = 0
|
||||
createWorktreeGate = undefined
|
||||
worktreeFailure = undefined
|
||||
worktreeHung = false
|
||||
worktreeCreates = 0
|
||||
for (const key of Object.keys(draftServers)) delete draftServers[key]
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
const makeSubmit = (overrides: Partial<Parameters<typeof createPromptSubmit>[0]> = {}) =>
|
||||
createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe("prompt submit worktree selection", () => {
|
||||
test("reads the latest worktree accessor value per submit", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
test("admits only one concurrent new-workspace submission", async () => {
|
||||
selected = "create"
|
||||
let release = () => {}
|
||||
createWorktreeGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const submit = makeSubmit()
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
const first = submit.handleSubmit(event)
|
||||
const duplicate = submit.handleSubmit(event)
|
||||
expect(worktreeCreates).toBe(1)
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
selected = "/repo/worktree-b"
|
||||
await submit.handleSubmit(event)
|
||||
release()
|
||||
await Promise.all([first, duplicate])
|
||||
expect(createdSessions).toEqual([worktreeDirectory])
|
||||
Worktree.ready(ServerScope.local, worktreeDirectory)
|
||||
await settle()
|
||||
|
||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(sessionCreateInputs).toEqual([
|
||||
{
|
||||
agent: "agent",
|
||||
model: { id: "model", providerID: "provider", variant: undefined },
|
||||
location: { directory: "/repo/worktree-a" },
|
||||
},
|
||||
{
|
||||
agent: "agent",
|
||||
model: { id: "model", providerID: "provider", variant: undefined },
|
||||
location: { directory: "/repo/worktree-b" },
|
||||
},
|
||||
])
|
||||
expect(sentShell).toEqual([
|
||||
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||
])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||
expect(serverSessionSyncs).toBe(0)
|
||||
expect(promoted).toEqual([
|
||||
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
||||
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
||||
])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||
expect(worktreeCreates).toBe(1)
|
||||
expect(createdSessions).toHaveLength(1)
|
||||
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||
})
|
||||
|
||||
test("applies auto-accept to newly created sessions", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => true,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
test("aborts a hung new-workspace request and allows retry", async () => {
|
||||
selected = "create"
|
||||
worktreeHung = true
|
||||
let resets = 0
|
||||
const submit = makeSubmit({
|
||||
onNewSessionWorktreeReset: () => resets++,
|
||||
worktreeRequestTimeoutMs: 1,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
expect(worktreeCreates).toBe(1)
|
||||
expect(createdSessions).toEqual([])
|
||||
expect(selected).toBe("create")
|
||||
expect(promptValue).toEqual([{ type: "text", content: "ls", start: 0, end: 2 }])
|
||||
expect(resets).toBe(0)
|
||||
|
||||
worktreeHung = false
|
||||
await submit.handleSubmit(event)
|
||||
Worktree.ready(ServerScope.local, worktreeDirectory)
|
||||
await settle()
|
||||
|
||||
expect(worktreeCreates).toBe(2)
|
||||
expect(createdSessions).toEqual([worktreeDirectory])
|
||||
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||
expect(resets).toBe(1)
|
||||
})
|
||||
|
||||
test("keeps auto-accept bound to the submission server", async () => {
|
||||
test("keeps async submission effects bound to the initiating context", async () => {
|
||||
search = { draftId: "draft-1" }
|
||||
draftServers["draft-1"] = "project-server-a"
|
||||
draftServers["draft-2"] = "project-server-b"
|
||||
let release = () => {}
|
||||
createSessionGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => true,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
let submitted = 0
|
||||
const submit = makeSubmit({
|
||||
onSubmit: () => submitted++,
|
||||
})
|
||||
|
||||
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
permissionServer = "server-b"
|
||||
const result = submit.handleSubmit(event)
|
||||
activeSDK = "server-b"
|
||||
activeServerSync = "server-b"
|
||||
activeDirectorySync = "server-b"
|
||||
search.draftId = "draft-2"
|
||||
release()
|
||||
await result
|
||||
await settle()
|
||||
|
||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
})
|
||||
|
||||
test("promotes drafts using the selected project's server", async () => {
|
||||
search = { draftId: "draft-1" }
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
|
||||
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
|
||||
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
|
||||
expect(optimisticServers).toEqual(["server-a"])
|
||||
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
|
||||
expect(WorkspaceOperation.get("server-b" as ServerScope, "session-1")).toBeUndefined()
|
||||
expect(submitted).toBe(0)
|
||||
})
|
||||
|
||||
test("includes the selected variant on optimistic prompts", async () => {
|
||||
params = { id: "session-1" }
|
||||
params.id = "session-1"
|
||||
variant = "high"
|
||||
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
const submit = makeSubmit({
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
await Bun.sleep(0)
|
||||
|
||||
@@ -500,24 +477,12 @@ describe("prompt submit worktree selection", () => {
|
||||
commands.push({ name: "review" })
|
||||
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
||||
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
const submit = makeSubmit({
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(sentCommands).toEqual([
|
||||
{
|
||||
@@ -533,66 +498,43 @@ describe("prompt submit worktree selection", () => {
|
||||
expect(serverSessionSyncs).toBe(0)
|
||||
})
|
||||
|
||||
test("uses an injected model selection", async () => {
|
||||
params = { id: "session-1" }
|
||||
const model = {
|
||||
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
|
||||
variant: { current: () => "draft-variant" },
|
||||
} as unknown as ModelSelection
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
model,
|
||||
test("waits for a new workspace before sending an initial shell", async () => {
|
||||
selected = "create"
|
||||
const submit = makeSubmit({
|
||||
mode: () => "shell",
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
|
||||
expect(optimistic[0]).toMatchObject({
|
||||
message: {
|
||||
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("seeds new sessions before optimistic prompts are added", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
expect(sentShell).toEqual([])
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending")
|
||||
|
||||
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
|
||||
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
|
||||
expect(optimisticSeeded).toEqual([true])
|
||||
Worktree.ready(ServerScope.local, worktreeDirectory)
|
||||
await settle()
|
||||
|
||||
expect(sentShellDirectories).toEqual([worktreeDirectory])
|
||||
expect(sentShell[0]).toMatchObject({
|
||||
sessionID: "session-1",
|
||||
command: "ls",
|
||||
agent: "agent",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
})
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
|
||||
})
|
||||
|
||||
test("settles a pending workspace operation when the initial prompt is aborted", async () => {
|
||||
selected = "create"
|
||||
const submit = makeSubmit()
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending")
|
||||
params = { id: "session-1" }
|
||||
|
||||
await submit.abort()
|
||||
await settle()
|
||||
|
||||
expect(sentPrompts).toEqual([])
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")).toMatchObject({
|
||||
status: "failed",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,10 +15,12 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||
import { useSync, type DirectorySync } from "@/context/sync"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { WORKSPACE_PREPARATION_TIMEOUT_MS, workspaceRequestWithTimeout } from "@/utils/workspace-request"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
@@ -27,9 +29,13 @@ import { blobDataUrl } from "@/utils/draft-store"
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
cleanup: VoidFunction
|
||||
scope: ServerScope
|
||||
sessionID: string
|
||||
serverSync: ServerSync
|
||||
}
|
||||
|
||||
const pending = new Map<string, PendingPrompt>()
|
||||
const submitting = new Set<string>()
|
||||
|
||||
export type FollowupDraft = {
|
||||
sessionID: string
|
||||
@@ -43,6 +49,7 @@ export type FollowupDraft = {
|
||||
|
||||
type FollowupSendInput = {
|
||||
api: DirectorySDK["api"]["session"]
|
||||
scope: ServerScope
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
draft: FollowupDraft
|
||||
@@ -56,6 +63,8 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ?
|
||||
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
|
||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
const operation = WorkspaceOperation.get(input.scope, input.draft.sessionID)
|
||||
if (operation?.status === "pending" && operation.messageID !== input.messageID) return false
|
||||
const text = draftText(input.draft.prompt)
|
||||
const images = draftImages(input.draft.prompt)
|
||||
const setBusy = () => {
|
||||
@@ -229,6 +238,7 @@ type PromptSubmitInput = {
|
||||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
model?: ModelSelection
|
||||
worktreeRequestTimeoutMs?: number
|
||||
}
|
||||
|
||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
@@ -244,7 +254,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||
const pendingKey = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID)
|
||||
let pendingSubmission: { key: string; scope: ServerScope; sessionID: string } | undefined
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
||||
@@ -257,18 +268,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
const abort = async () => {
|
||||
const sessionID = params.id
|
||||
const routeSessionID = params.id
|
||||
const owned =
|
||||
pendingSubmission && (!routeSessionID || routeSessionID === pendingSubmission.sessionID)
|
||||
? pending.get(pendingSubmission.key)
|
||||
: undefined
|
||||
const sessionID = routeSessionID ?? owned?.sessionID
|
||||
if (!sessionID) return Promise.resolve()
|
||||
|
||||
serverSync().session.set("todo", sessionID, [])
|
||||
;(owned?.serverSync ?? serverSync()).session.set("todo", sessionID, [])
|
||||
|
||||
input.onAbort?.()
|
||||
|
||||
const key = pendingKey(sessionID)
|
||||
const queued = pending.get(key)
|
||||
const key = owned ? pendingSubmission!.key : pendingKey(sdk().scope, sessionID)
|
||||
const queued = owned ?? pending.get(key)
|
||||
if (queued) {
|
||||
queued.abort.abort()
|
||||
queued.cleanup()
|
||||
WorkspaceOperation.fail(queued.scope, queued.sessionID)
|
||||
pending.delete(key)
|
||||
return Promise.resolve()
|
||||
}
|
||||
@@ -300,9 +316,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const seed = (dir: string, info: Session) => {
|
||||
serverSync().session.remember(info)
|
||||
const [, setStore] = serverSync().child(dir)
|
||||
const seed = (target: ServerSync, dir: string, info: Session) => {
|
||||
target.session.remember(info)
|
||||
const [, setStore] = target.child(dir)
|
||||
setStore("session", (list: Session[]) => {
|
||||
const result = Binary.search(list, info.id, (item) => item.id)
|
||||
const next = [...list]
|
||||
@@ -334,6 +350,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (input.working()) void abort()
|
||||
return
|
||||
}
|
||||
if (params.id && WorkspaceOperation.get(sdk().scope, params.id)?.status === "pending") return
|
||||
|
||||
const modelSelection = input.model ?? local.model
|
||||
const currentModel = modelSelection.current()
|
||||
@@ -347,295 +364,377 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return
|
||||
}
|
||||
|
||||
input.addToHistory(currentPrompt, mode)
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
const projectDirectory = sdk().directory
|
||||
const submissionSDK = sdk()
|
||||
const submissionSync = sync()
|
||||
const submissionServerSync = serverSync()
|
||||
const submissionScope = submissionSDK.scope
|
||||
const projectDirectory = submissionSDK.directory
|
||||
const projectRoot = submissionSync.project?.worktree ?? projectDirectory
|
||||
const sessionID = params.id
|
||||
const isNewSession = !sessionID
|
||||
const currentSession = input.info()
|
||||
const draftID = search.draftId
|
||||
const draftServer = draftID ? tabs.draft(draftID).server : undefined
|
||||
const capturePrompt = prompt.capture
|
||||
const localSession = local.session
|
||||
const handoff = layout.handoff
|
||||
const resetWorktree = input.onNewSessionWorktreeReset
|
||||
const onSubmit = input.onSubmit
|
||||
const permissionState = permission.currentServerState()
|
||||
const isNewSession = !params.id
|
||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
const submissionKey = ScopedKey.from(
|
||||
submissionScope,
|
||||
draftID ? `draft:${draftID}` : sessionID ? `session:${sessionID}` : `directory:${projectDirectory}`,
|
||||
)
|
||||
if (submitting.has(submissionKey)) return
|
||||
submitting.add(submissionKey)
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
let client = sdk().client
|
||||
try {
|
||||
input.addToHistory(currentPrompt, mode)
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await client.worktree
|
||||
.create({ directory: projectDirectory })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
let sessionDirectory = projectDirectory
|
||||
let client = submissionSDK.client
|
||||
let api = submissionSDK.api.session
|
||||
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await workspaceRequestWithTimeout(
|
||||
(signal) => client.worktree.create({ directory: projectDirectory }, { signal }),
|
||||
language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
input.worktreeRequestTimeoutMs ?? WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
)
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!createdWorktree?.directory) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: language.t("common.requestFailed"),
|
||||
})
|
||||
return
|
||||
}
|
||||
WorktreeState.pending(submissionScope, createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||
sessionDirectory = worktreeSelection
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
client = submissionSDK.createClient({
|
||||
directory: sessionDirectory,
|
||||
throwOnError: true,
|
||||
})
|
||||
api = submissionSDK.createApi(sessionDirectory).session
|
||||
submissionServerSync.child(sessionDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
let session = currentSession
|
||||
if (!session && isNewSession) {
|
||||
const created = await submissionSDK.api.session
|
||||
.create({
|
||||
agent: currentAgent.name,
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.then(normalizeSessionInfo)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!createdWorktree?.directory) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: language.t("common.requestFailed"),
|
||||
if (created) {
|
||||
seed(submissionServerSync, sessionDirectory, created)
|
||||
session = created
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
if (draftID) tabs.updateDraft(draftID, { worktree: undefined })
|
||||
if (!draftID) resetWorktree?.()
|
||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||
localSession.promote(sessionDirectory, session.id, {
|
||||
agent: currentAgent.name,
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
variant: variant ?? null,
|
||||
})
|
||||
handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(
|
||||
capturePrompt(
|
||||
{ dir: base64Encode(sessionDirectory), id: session.id },
|
||||
{ server: draftServer, scope: submissionScope },
|
||||
),
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
if (!session) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: language.t("prompt.toast.promptSendFailed.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||
sessionDirectory = worktreeSelection
|
||||
const model = {
|
||||
modelID: currentModel.id,
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const draft: FollowupDraft = {
|
||||
sessionID: session.id,
|
||||
sessionDirectory,
|
||||
prompt: currentPrompt,
|
||||
context,
|
||||
agent,
|
||||
model,
|
||||
variant,
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
client = sdk().createClient({
|
||||
directory: sessionDirectory,
|
||||
throwOnError: true,
|
||||
})
|
||||
serverSync().child(sessionDirectory)
|
||||
const clearInput = () => {
|
||||
submission.clear()
|
||||
input.setMode("normal")
|
||||
input.setPopover(null)
|
||||
}
|
||||
|
||||
input.onNewSessionWorktreeReset?.()
|
||||
}
|
||||
|
||||
let session = input.info()
|
||||
if (!session && isNewSession) {
|
||||
const created = await sdk()
|
||||
.api.session.create({
|
||||
agent: currentAgent.name,
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.then(normalizeSessionInfo)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
if (created) {
|
||||
seed(sessionDirectory, created)
|
||||
session = created
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||
local.session.promote(sessionDirectory, session.id, {
|
||||
agent: currentAgent.name,
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
variant: variant ?? null,
|
||||
})
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
const draftID = search.draftId
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||
const restoreInput = () => {
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||
if (!submission.current(prompt.capture())) return true
|
||||
input.setMode(mode)
|
||||
input.setPopover(null)
|
||||
requestAnimationFrame(() => {
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||
input.queueScroll()
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: language.t("prompt.toast.promptSendFailed.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const model = {
|
||||
modelID: currentModel.id,
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const draft: FollowupDraft = {
|
||||
sessionID: session.id,
|
||||
sessionDirectory,
|
||||
prompt: currentPrompt,
|
||||
context,
|
||||
agent,
|
||||
model,
|
||||
variant,
|
||||
}
|
||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||
input.onQueue?.(draft)
|
||||
clearContext(submission.target())
|
||||
clearInput()
|
||||
return
|
||||
}
|
||||
|
||||
const clearInput = () => {
|
||||
submission.clear()
|
||||
input.setMode("normal")
|
||||
input.setPopover(null)
|
||||
}
|
||||
const startWorkspaceOperation = (messageID: string) => {
|
||||
if (!isNewSession) return
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create" && sessionDirectory !== projectRoot) {
|
||||
WorkspaceOperation.start(submissionScope, session.id, "move", sessionDirectory, messageID)
|
||||
WorkspaceOperation.complete(submissionScope, session.id)
|
||||
}
|
||||
if (worktreeSelection !== "create") return
|
||||
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
|
||||
WorkspaceOperation.start(submissionScope, session.id, "create", sessionDirectory, messageID)
|
||||
if (worktree?.status === "ready") WorkspaceOperation.complete(submissionScope, session.id)
|
||||
if (worktree?.status === "failed") WorkspaceOperation.fail(submissionScope, session.id)
|
||||
}
|
||||
|
||||
const restoreInput = () => {
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||
if (!submission.current(prompt.capture())) return true
|
||||
input.setMode(mode)
|
||||
input.setPopover(null)
|
||||
requestAnimationFrame(() => {
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||
input.queueScroll()
|
||||
})
|
||||
return true
|
||||
}
|
||||
const waitForWorktree = async (cleanup: VoidFunction) => {
|
||||
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
|
||||
if (!worktree) return true
|
||||
if (worktree.status === "ready") {
|
||||
WorkspaceOperation.complete(submissionScope, session.id)
|
||||
return true
|
||||
}
|
||||
if (worktree.status === "failed") {
|
||||
WorkspaceOperation.fail(submissionScope, session.id)
|
||||
throw new Error(worktree.message)
|
||||
}
|
||||
|
||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||
input.onQueue?.(draft)
|
||||
clearContext(submission.target())
|
||||
clearInput()
|
||||
return
|
||||
}
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
submissionSync.set("session_status", session.id, { type: "busy" })
|
||||
}
|
||||
|
||||
input.onSubmit?.()
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
const eventID = Event.ID.create()
|
||||
sdk()
|
||||
.api.session.shell({
|
||||
const controller = new AbortController()
|
||||
const key = pendingKey(submissionScope, session.id)
|
||||
pendingSubmission = { key, scope: submissionScope, sessionID: session.id }
|
||||
pending.set(key, {
|
||||
abort: controller,
|
||||
cleanup,
|
||||
scope: submissionScope,
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
command: text,
|
||||
agent,
|
||||
model,
|
||||
serverSync: submissionServerSync,
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
|
||||
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
if (controller.signal.aborted) {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
return
|
||||
}
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
|
||||
const timeoutMs = 5 * 60 * 1000
|
||||
const timer = { id: undefined as number | undefined }
|
||||
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
timer.id = window.setTimeout(() => {
|
||||
resolve({
|
||||
status: "failed",
|
||||
message: language.t("workspace.error.stillPreparing"),
|
||||
})
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
const result = await Promise.race([
|
||||
WorktreeState.wait(submissionScope, sessionDirectory),
|
||||
abortWait,
|
||||
timeout,
|
||||
]).finally(() => {
|
||||
pending.delete(key)
|
||||
if (pendingSubmission?.key === key) pendingSubmission = undefined
|
||||
if (timer.id === undefined) return
|
||||
clearTimeout(timer.id)
|
||||
})
|
||||
if (controller.signal.aborted) return false
|
||||
if (result.status === "failed") {
|
||||
WorkspaceOperation.fail(submissionScope, session.id)
|
||||
throw new Error(result.message)
|
||||
}
|
||||
WorkspaceOperation.complete(submissionScope, session.id)
|
||||
return true
|
||||
}
|
||||
|
||||
if (!draftID || search.draftId === draftID) onSubmit?.()
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
const eventID = Event.ID.create()
|
||||
startWorkspaceOperation(eventID)
|
||||
void waitForWorktree(() => {
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (text.startsWith("/")) {
|
||||
const [cmdName, ...args] = text.split(" ")
|
||||
const commandName = cmdName.slice(1)
|
||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
const messageID = Identifier.ascending("message")
|
||||
serverSync().session.set("session_status", session.id, { type: "busy" })
|
||||
sdk()
|
||||
.api.session.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
name: attachment.filename,
|
||||
})),
|
||||
),
|
||||
.then((ready) => {
|
||||
if (!ready) return
|
||||
return api.shell({
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
command: text,
|
||||
agent,
|
||||
model,
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
serverSync().session.set("session_status", session.id, { type: "idle" })
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
const messageID = Identifier.ascending("message")
|
||||
|
||||
const removeOptimisticMessage = () => {
|
||||
sync().session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||
clearInput()
|
||||
|
||||
const waitForWorktree = async () => {
|
||||
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
|
||||
if (!worktree || worktree.status !== "pending") return true
|
||||
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync().set("session_status", session.id, { type: "busy" })
|
||||
if (text.startsWith("/")) {
|
||||
const [cmdName, ...args] = text.split(" ")
|
||||
const commandName = cmdName.slice(1)
|
||||
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
const messageID = Identifier.ascending("message")
|
||||
startWorkspaceOperation(messageID)
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
|
||||
void waitForWorktree(() => {
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||
restoreInput()
|
||||
})
|
||||
.then(async (ready) => {
|
||||
if (!ready) return
|
||||
return api.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
name: attachment.filename,
|
||||
})),
|
||||
),
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
const messageID = Identifier.ascending("message")
|
||||
startWorkspaceOperation(messageID)
|
||||
|
||||
const removeOptimisticMessage = () => {
|
||||
submissionSync.session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||
clearInput()
|
||||
|
||||
const cleanup = () => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync().set("session_status", session.id, { type: "idle" })
|
||||
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
}
|
||||
|
||||
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
||||
|
||||
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
if (controller.signal.aborted) {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
return
|
||||
void sendFollowupDraft({
|
||||
api,
|
||||
scope: submissionScope,
|
||||
sync: submissionSync,
|
||||
serverSync: submissionServerSync,
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
before: () => waitForWorktree(cleanup),
|
||||
}).catch((err) => {
|
||||
pending.delete(pendingKey(submissionScope, session.id))
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
})
|
||||
|
||||
const timeoutMs = 5 * 60 * 1000
|
||||
const timer = { id: undefined as number | undefined }
|
||||
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
timer.id = window.setTimeout(() => {
|
||||
resolve({
|
||||
status: "failed",
|
||||
message: language.t("workspace.error.stillPreparing"),
|
||||
})
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
const result = await Promise.race([
|
||||
WorktreeState.wait(sdk().scope, sessionDirectory),
|
||||
abortWait,
|
||||
timeout,
|
||||
]).finally(() => {
|
||||
if (timer.id === undefined) return
|
||||
clearTimeout(timer.id)
|
||||
})
|
||||
pending.delete(pendingKey(session.id))
|
||||
if (controller.signal.aborted) return false
|
||||
if (result.status === "failed") throw new Error(result.message)
|
||||
return true
|
||||
} finally {
|
||||
submitting.delete(submissionKey)
|
||||
}
|
||||
|
||||
void sendFollowupDraft({
|
||||
api: sdk().api.session,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
before: waitForWorktree,
|
||||
}).catch((err) => {
|
||||
pending.delete(pendingKey(session.id))
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync().set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -11,25 +10,42 @@ export function PromptWorkspaceSelector(props: {
|
||||
projectRoot: string
|
||||
workspaces: string[]
|
||||
branch?: string
|
||||
onboarding?: boolean
|
||||
onChange: (value: string) => void
|
||||
onDone: () => void
|
||||
onViewAll: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
let pending: string | undefined
|
||||
const [search, setSearch] = createSignal("")
|
||||
let searchInput: HTMLInputElement | undefined
|
||||
let focusSearch = false
|
||||
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
|
||||
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
|
||||
const workspaces = createMemo(() => {
|
||||
const query = search().trim().toLowerCase()
|
||||
if (!query) return props.workspaces
|
||||
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
|
||||
})
|
||||
const icon = () => {
|
||||
if (selected() === "main") return "monitor"
|
||||
if (selected() === "create") return "workspace-new"
|
||||
return "workspace"
|
||||
return "workspace-isolated"
|
||||
}
|
||||
const select = (value: string) => {
|
||||
pending = value
|
||||
pending = { type: "select", value }
|
||||
}
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) return
|
||||
const value = pending
|
||||
if (open) {
|
||||
setSearch("")
|
||||
return
|
||||
}
|
||||
const action = pending
|
||||
pending = undefined
|
||||
if (value) props.onChange(value)
|
||||
if (action?.type === "select") props.onChange(action.value)
|
||||
if (action?.type === "viewAll") {
|
||||
props.onViewAll()
|
||||
return
|
||||
}
|
||||
props.onDone()
|
||||
}
|
||||
const label = () => {
|
||||
@@ -41,87 +57,217 @@ export function PromptWorkspaceSelector(props: {
|
||||
return (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
|
||||
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{label()}</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content class="w-[180px]">
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||
<MenuV2.Item onSelect={() => select("main")}>
|
||||
<IconV2 name="monitor" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
|
||||
<Show when={selected() === "main"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => select("create")}>
|
||||
<IconV2 name="workspace-new" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
||||
<Show when={selected() === "create"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
<Show when={props.workspaces.length > 0}>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<MenuV2.SubTrigger>
|
||||
<IconV2 name="workspace" />
|
||||
{language.t("session.new.workspace.existing")}
|
||||
</MenuV2.SubTrigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.SubContent class="max-w-[200px]">
|
||||
<For each={props.workspaces}>
|
||||
{(workspace) => (
|
||||
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||
<IconV2 name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
<Show when={selected() === workspace}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
)}
|
||||
</For>
|
||||
</MenuV2.SubContent>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2.Sub>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
value={
|
||||
props.onboarding ? (
|
||||
<div class="flex flex-col gap-1 text-left">
|
||||
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
|
||||
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||
<span>{language.t("workspace.onboarding.title")}</span>
|
||||
</div>
|
||||
<span class="font-[440] text-v2-text-text-muted">
|
||||
{language.t("workspace.onboarding.description")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
language.t("session.new.workspace.trigger.tooltip")
|
||||
)
|
||||
}
|
||||
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||
class="min-w-0"
|
||||
>
|
||||
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<MenuV2.Trigger
|
||||
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
|
||||
>
|
||||
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{label()}</span>
|
||||
<Show when={props.onboarding}>
|
||||
<span
|
||||
data-slot="workspace-onboarding-dot"
|
||||
aria-hidden="true"
|
||||
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
|
||||
/>
|
||||
</Show>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<PromptGitStatus branch={props.branch} />
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content class="w-[200px]">
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||
<MenuV2.Item onSelect={() => select("main")}>
|
||||
<Icon name="monitor" />
|
||||
<TooltipV2
|
||||
placement="right"
|
||||
value={
|
||||
<span class="flex flex-col gap-0.5">
|
||||
<span>{language.t("session.new.workspace.local")}</span>
|
||||
<span class="font-[440] text-v2-text-text-muted">
|
||||
{language.t("session.new.workspace.local.tooltip")}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
|
||||
</TooltipV2>
|
||||
<Show when={selected() === "main"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => select("create")}>
|
||||
<Icon name="workspace-new" />
|
||||
<TooltipV2
|
||||
placement="right"
|
||||
value={
|
||||
<span class="flex flex-col gap-0.5">
|
||||
<span>{language.t("workspace.new")}</span>
|
||||
<span class="font-[440] text-v2-text-text-muted">
|
||||
{language.t("session.new.workspace.new.tooltip")}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
|
||||
</TooltipV2>
|
||||
<Show when={selected() === "create"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
<Show
|
||||
when={props.workspaces.length > 0}
|
||||
fallback={
|
||||
<>
|
||||
<MenuV2.Separator class="h-[0.5px]" />
|
||||
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</MenuV2.Item>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuV2.Separator class="h-[0.5px]" />
|
||||
<MenuV2.Sub
|
||||
gutter={0}
|
||||
overlap
|
||||
overflowPadding={8}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
focusSearch = false
|
||||
return
|
||||
}
|
||||
if (!focusSearch || props.workspaces.length < 10) return
|
||||
focusSearch = false
|
||||
requestAnimationFrame(() => searchInput?.focus())
|
||||
}}
|
||||
>
|
||||
<MenuV2.SubTrigger
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "ArrowRight" ||
|
||||
event.key === "ArrowLeft" ||
|
||||
event.key === "Enter" ||
|
||||
event.key === " "
|
||||
)
|
||||
focusSearch = true
|
||||
}}
|
||||
>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</span>
|
||||
</MenuV2.SubTrigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
||||
<Show when={props.workspaces.length >= 10}>
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={(element) => {
|
||||
searchInput = element
|
||||
}}
|
||||
value={search()}
|
||||
placeholder={language.t("session.new.workspace.search.placeholder")}
|
||||
aria-label={language.t("session.new.workspace.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"
|
||||
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Escape" ||
|
||||
event.key === "ArrowDown" ||
|
||||
event.key === "ArrowUp" ||
|
||||
event.key === "Enter"
|
||||
)
|
||||
return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
<Show when={selected() === workspace}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
)}
|
||||
</For>
|
||||
<MenuV2.Separator class="h-[0.5px]" />
|
||||
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.SubContent>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2.Sub>
|
||||
</Show>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</TooltipV2>
|
||||
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ml-1" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
|
||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
|
||||
const language = useLanguage()
|
||||
const label = () => {
|
||||
if (props.noGit) return language.t("session.new.git.none")
|
||||
if (!props.branch) return undefined
|
||||
if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch })
|
||||
return props.branch
|
||||
}
|
||||
|
||||
const icon = () => {
|
||||
if (props.noGit) return "monitor"
|
||||
if (props.from) return "branch-out"
|
||||
return "branch"
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={label()}>
|
||||
{(value) => (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
value={value()}
|
||||
class="min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{value()}</span>
|
||||
</div>
|
||||
</TooltipV2>
|
||||
</>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
value={value()}
|
||||
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
|
||||
<Icon
|
||||
name={icon()}
|
||||
size="small"
|
||||
class="shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{value()}</span>
|
||||
</div>
|
||||
</TooltipV2>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { Project } from "@opencode-ai/sdk/v2/client"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For, Show, type ComponentProps, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { Worktree } from "@/utils/worktree"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { workspaceDirectories } from "@/utils/workspace"
|
||||
import {
|
||||
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
|
||||
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
workspaceRequestWithTimeout,
|
||||
} from "@/utils/workspace-request"
|
||||
|
||||
export function SessionWorkspaceMenu(props: {
|
||||
eligible?: boolean
|
||||
sessionID: string
|
||||
project: Project
|
||||
directory: string
|
||||
messageID?: string
|
||||
placement?: ComponentProps<typeof MenuV2>["placement"]
|
||||
gutter?: number
|
||||
class?: string
|
||||
contentClass?: string
|
||||
children: JSX.Element
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const openWorkspaces = useSettingsDialog("workspaces")
|
||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||
const operationPending = () => WorkspaceOperation.get(serverSDK().scope, props.sessionID)?.status === "pending"
|
||||
const blocked = () =>
|
||||
props.eligible === false || operationPending() || serverSync().session.data.session_working(props.sessionID)
|
||||
const workspaces = () =>
|
||||
workspaceDirectories(props.project).filter((workspace) => pathKey(workspace) !== pathKey(props.directory))
|
||||
|
||||
const fail = (scope: ServerScope, sessionID: string, message: string) => {
|
||||
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
|
||||
WorkspaceOperation.fail(scope, sessionID)
|
||||
showToast({ variant: "error", title: language.t("workspace.move.failed"), description: message })
|
||||
}
|
||||
const move = async (selection: "create" | string) => {
|
||||
if (store.selected || blocked()) return
|
||||
const sdk = serverSDK()
|
||||
const sync = serverSync()
|
||||
const scope = sdk.scope
|
||||
const sessionID = props.sessionID
|
||||
const messageID = props.messageID
|
||||
const root = props.project.worktree
|
||||
const source = props.directory
|
||||
setStore("selected", selection)
|
||||
|
||||
try {
|
||||
const destination =
|
||||
selection === "create"
|
||||
? await createWorkspace(root, sessionID, messageID, sdk, (message) => fail(scope, sessionID, message), {
|
||||
createFailed: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
stillPreparing: language.t("workspace.error.stillPreparing"),
|
||||
})
|
||||
: selection
|
||||
if (!destination) return
|
||||
|
||||
WorkspaceOperation.start(scope, sessionID, selection === "create" ? "create" : "move", destination, messageID)
|
||||
if (sync.session.data.session_working(sessionID)) throw new Error(language.t("workspace.move.failed"))
|
||||
await workspaceRequestWithTimeout(
|
||||
(signal) =>
|
||||
sdk.client.experimental.controlPlane.moveSession(
|
||||
{
|
||||
sessionID,
|
||||
destination: { directory: destination },
|
||||
moveChanges: true,
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
language.t("workspace.move.failed"),
|
||||
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
)
|
||||
const session = await workspaceRequestWithTimeout(
|
||||
(signal) => sync.session.resolve(sessionID, { force: true, signal }),
|
||||
language.t("workspace.move.failed"),
|
||||
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
|
||||
)
|
||||
if (!session || pathKey(session.directory) !== pathKey(destination))
|
||||
throw new Error(language.t("workspace.move.failed"))
|
||||
WorkspaceOperation.complete(scope, sessionID, destination)
|
||||
sync.reindexSession(sessionID, source)
|
||||
} catch (error) {
|
||||
fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed"))
|
||||
} finally {
|
||||
setStore("selected", undefined)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuV2
|
||||
placement={props.placement ?? "bottom-end"}
|
||||
gutter={props.gutter ?? 4}
|
||||
modal={false}
|
||||
onOpenChange={props.onOpenChange}
|
||||
>
|
||||
<MenuV2.Trigger class={props.class} disabled={blocked()}>
|
||||
{props.children}
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content class={`w-[200px] ${props.contentClass ?? ""}`}>
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("workspace.move.menu.title")}</MenuV2.GroupLabel>
|
||||
<Show when={pathKey(props.directory) !== pathKey(props.project.worktree)}>
|
||||
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}>
|
||||
<Icon name="monitor" />
|
||||
{language.t("session.new.workspace.local")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
|
||||
<Icon name="workspace-new" />
|
||||
{language.t("workspace.new")}
|
||||
</MenuV2.Item>
|
||||
<Show when={workspaces().length > 0}>
|
||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<MenuV2.SubTrigger>
|
||||
<Icon name="workspace-isolated" />
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</MenuV2.SubTrigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
</MenuV2.Item>
|
||||
)}
|
||||
</For>
|
||||
</MenuV2.SubContent>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2.Sub>
|
||||
</Show>
|
||||
</MenuV2.Group>
|
||||
<MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
|
||||
<MenuV2.Item onSelect={() => openWorkspaces()}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
)
|
||||
}
|
||||
|
||||
async function createWorkspace(
|
||||
root: string,
|
||||
sessionID: string,
|
||||
messageID: string | undefined,
|
||||
serverSDK: ReturnType<ReturnType<typeof useServerSDK>>,
|
||||
fail: (message: string) => void,
|
||||
messages: { createFailed: string; stillPreparing: string },
|
||||
) {
|
||||
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", root, messageID)
|
||||
const created = await workspaceRequestWithTimeout(
|
||||
(signal) => serverSDK.client.worktree.create({ directory: root }, { signal }),
|
||||
messages.createFailed,
|
||||
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
)
|
||||
.then((result) => result.data)
|
||||
.catch((error) => {
|
||||
fail(error instanceof Error ? error.message : messages.createFailed)
|
||||
return undefined
|
||||
})
|
||||
if (!created?.directory) return
|
||||
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", created.directory, messageID)
|
||||
Worktree.pending(serverSDK.scope, created.directory)
|
||||
const timer = { id: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
const timeout = new Promise<ReturnType<typeof Worktree.get>>((resolve) => {
|
||||
timer.id = setTimeout(
|
||||
() => resolve({ status: "failed", message: messages.stillPreparing }),
|
||||
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
)
|
||||
})
|
||||
const ready = await Promise.race([Worktree.wait(serverSDK.scope, created.directory), timeout]).finally(() => {
|
||||
if (timer.id) clearTimeout(timer.id)
|
||||
})
|
||||
if (!ready || ready.status === "failed") {
|
||||
fail(ready?.message ?? messages.createFailed)
|
||||
return
|
||||
}
|
||||
return created.directory
|
||||
}
|
||||
@@ -5,12 +5,15 @@ import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { findLast } from "@opencode-ai/core/util/array"
|
||||
import { same } from "@/utils/same"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { File } from "@opencode-ai/session-ui/file"
|
||||
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
@@ -220,6 +223,31 @@ export function SessionContextTab() {
|
||||
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
||||
] satisfies { label: string; value: () => JSX.Element }[]
|
||||
|
||||
const exportSession = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
try {
|
||||
const data = await fetchSessionExport({
|
||||
sessionID,
|
||||
client: sdk().client,
|
||||
})
|
||||
const filename = sessionExportFilename(data.info)
|
||||
downloadSessionExport(filename, data)
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("toast.session.export.success.title"),
|
||||
description: language.t("toast.session.export.success.description", { filename }),
|
||||
})
|
||||
} catch (err) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.export.failed.title"),
|
||||
description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
@@ -328,7 +356,18 @@ export function SessionContextTab() {
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
class="gap-1.5 px-2 text-text-weak hover:text-text-base"
|
||||
onClick={exportSession}
|
||||
>
|
||||
<Icon name="download" size="small" />
|
||||
<span>{language.t("context.export.session")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Accordion multiple>
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useModels } from "@/context/models"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { SettingsList } from "./settings-list"
|
||||
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
||||
@@ -43,6 +44,7 @@ export const SettingsModels: Component = () => {
|
||||
const SettingsModelsContent: Component = () => {
|
||||
const language = useLanguage()
|
||||
const models = useModels()
|
||||
useServerSync()().loadProviders()
|
||||
|
||||
const list = useFilteredList<ModelItem>({
|
||||
items: (_filter) => models.list(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, createMemo, createSignal, startTransition } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { SettingsGeneralV2 } from "./general"
|
||||
@@ -10,8 +10,8 @@ import { SettingsProvidersV2 } from "./providers"
|
||||
import { SettingsModelsV2 } from "./models"
|
||||
import "./settings-v2.css"
|
||||
import { SettingsServersV2 } from "./servers"
|
||||
import { SettingsWorkspacesV2 } from "./workspaces"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
|
||||
@@ -22,18 +22,16 @@ export const DialogSettings: Component<{
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const layout = useLayout()
|
||||
const tabs = useTabs()
|
||||
const serverSync = useServerSync()
|
||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||
const directory = createMemo(() => {
|
||||
const route = layout.route()
|
||||
if (route.type === "dir-new-sesssion") return route.dir
|
||||
if (route.type === "draft") {
|
||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||
return draft?.type === "draft" ? draft.directory : undefined
|
||||
const active = tabs.active()
|
||||
if (active?.type === "draft") return active.directory
|
||||
if (active?.type === "session") {
|
||||
const session = serverSync().session.get(active.sessionId)
|
||||
if (session) return session.directory
|
||||
}
|
||||
if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -71,6 +69,10 @@ export const DialogSettings: Component<{
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<TabsV2.Trigger value="workspaces">
|
||||
<Icon name="workspace-isolated" />
|
||||
{language.t("settings.tab.workspaces")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="servers">
|
||||
<Icon name="server" />
|
||||
{language.t("status.popover.tab.servers")}
|
||||
@@ -99,6 +101,9 @@ export const DialogSettings: Component<{
|
||||
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
||||
<SettingsKeybinds v2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="workspaces" class="settings-v2-panel">
|
||||
<SettingsWorkspacesV2 activeDirectory={directory()} />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="servers" class="settings-v2-panel">
|
||||
<SettingsServersV2 />
|
||||
</TabsV2.Content>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useUpdaterAction } from "../updater-action"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings"
|
||||
import { ExternalLink } from "../external-link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
@@ -87,6 +87,34 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController
|
||||
)
|
||||
}
|
||||
|
||||
const WorkspaceDestinationSetting: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const options = createMemo((): { value: WorkspaceDefaultDestination; label: string }[] => [
|
||||
{ value: "last-used", label: language.t("settings.workspaces.default.lastUsed") },
|
||||
{ value: "local", label: language.t("settings.workspaces.default.local") },
|
||||
{ value: "new", label: language.t("settings.workspaces.default.new") },
|
||||
])
|
||||
|
||||
return (
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.workspaces.default.title")}
|
||||
description={language.t("settings.workspaces.default.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
options={options()}
|
||||
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
|
||||
value={(option) => option.value}
|
||||
label={(option) => option.label}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
}
|
||||
|
||||
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
const options = createMemo(() =>
|
||||
@@ -329,6 +357,7 @@ export const SettingsGeneralV2: Component<{
|
||||
<SettingsListV2>
|
||||
<LanguageSetting />
|
||||
|
||||
<WorkspaceDestinationSetting />
|
||||
<PermissionScopeSetting controller={permissionScope} />
|
||||
|
||||
<ShellSetting controller={shell} />
|
||||
@@ -391,18 +420,6 @@ export const SettingsGeneralV2: Component<{
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showFileTree.title")}
|
||||
description={language.t("settings.general.row.showFileTree.description")}
|
||||
>
|
||||
<div data-action="settings-show-file-tree">
|
||||
<Switch
|
||||
checked={settings.general.showFileTree()}
|
||||
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showSearch.title")}
|
||||
description={language.t("settings.general.row.showSearch.description")}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useModels } from "@/context/models"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
@@ -23,6 +24,7 @@ export const SettingsModelsV2: Component = () => {
|
||||
const language = useLanguage()
|
||||
const models = useModels()
|
||||
const serverSdk = useServerSDK()
|
||||
useServerSync()().loadProviders()
|
||||
const [store, setStore] = persisted(
|
||||
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
|
||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||
|
||||
@@ -684,6 +684,223 @@
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-tab-header.settings-v2-workspaces-header {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-header .settings-v2-tab-title {
|
||||
font-weight: 610;
|
||||
}
|
||||
|
||||
.settings-v2-tab-body.settings-v2-workspaces {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-count {
|
||||
font-size: 15px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-delete-all {
|
||||
color: var(--v2-state-fg-danger);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--v2-background-bg-base);
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-row:not(:last-child) {
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-row-header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-row-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-main [data-component="tooltip-v2-trigger"] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-path {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--v2-text-text-base);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-meta {
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-active,
|
||||
.settings-v2-workspaces-more {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-sessions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-radius: 4px;
|
||||
background-color: var(--v2-background-bg-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 16px;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session:not(:last-child) {
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session > span:first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-block: 48px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.settings-v2-workspaces-header {
|
||||
padding: 24px 20px 20px;
|
||||
}
|
||||
|
||||
.settings-v2-tab-body.settings-v2-workspaces {
|
||||
padding: 0 20px 24px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar,
|
||||
.settings-v2-workspaces-main {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-path {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-active {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
|
||||
width: 480px;
|
||||
max-width: calc(100vw - 32px);
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import type { Component } from "solid-js"
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import type { Project, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import {
|
||||
containsDirectory,
|
||||
filterWorkspaceInventory,
|
||||
inspectWorkspaceDeletion,
|
||||
mergeWorkspaceSessionInventory,
|
||||
removeWorkspacesSequentially,
|
||||
sessionsForWorkspace,
|
||||
type WorkspaceDeleteInspection,
|
||||
workspaceInventory,
|
||||
} from "@/utils/workspace"
|
||||
import { listAllSessions, normalizeSessionInfo } from "@/utils/session"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import "./settings-v2.css"
|
||||
|
||||
type Workspace = {
|
||||
directory: string
|
||||
project: Project
|
||||
}
|
||||
|
||||
export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const tabs = useTabs()
|
||||
const platform = usePlatform()
|
||||
const [store, setStore] = createStore({
|
||||
project: "all",
|
||||
transaction: undefined as "confirm" | "running" | undefined,
|
||||
})
|
||||
|
||||
const workspaces = createMemo(() => workspaceInventory(serverSync().data.project))
|
||||
const projects = createMemo(() => serverSync().data.project.filter((project) => project.sandboxes?.length))
|
||||
const projectName = (project: Project) => project.name || getFilename(project.worktree)
|
||||
const projectOptions = createMemo(() => [
|
||||
{ id: "all", label: language.t("settings.workspaces.filter.all") },
|
||||
...projects().map((project) => ({ id: project.id, label: projectName(project) })),
|
||||
])
|
||||
const selectedProject = createMemo(() =>
|
||||
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
|
||||
)
|
||||
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
|
||||
const captureDeleteContext = () => {
|
||||
const sdk = serverSDK()
|
||||
return { sdk, sync: serverSync(), server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
|
||||
}
|
||||
const loadSessions = async (context = captureDeleteContext()) => {
|
||||
const protocol = await context.sdk.protocol
|
||||
const fetched =
|
||||
protocol === "v1"
|
||||
? await context.sdk.api.session.list({ limit: 1000, order: "desc" }).then((response) => {
|
||||
if (response.data.length >= 1000) throw new Error("Incomplete legacy session inventory")
|
||||
return response.data.map(normalizeSessionInfo)
|
||||
})
|
||||
: await listAllSessions(context.sdk.api.session, { order: "desc" })
|
||||
return mergeWorkspaceSessionInventory(
|
||||
fetched,
|
||||
Object.values(context.sync.session.data.info).filter((session): session is Session => !!session),
|
||||
)
|
||||
}
|
||||
const sessionQuery = useQuery(() => ({
|
||||
queryKey: [serverSDK().scope, null, "settings-workspace-sessions"] as const,
|
||||
queryFn: () => loadSessions(),
|
||||
refetchOnMount: "always",
|
||||
}))
|
||||
const workspaceSessions = (workspace: Workspace) => sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory)
|
||||
const sessionCount = (workspace: Workspace) => {
|
||||
if (sessionQuery.isPending) return language.t("session.messages.loading")
|
||||
if (sessionQuery.isError) return language.t("common.requestFailed")
|
||||
const count = workspaceSessions(workspace).length
|
||||
return language.plural("settings.workspaces.sessions", count, {
|
||||
count,
|
||||
project: projectName(workspace.project),
|
||||
})
|
||||
}
|
||||
const lastActive = (workspace: Workspace) => {
|
||||
const updated = workspaceSessions(workspace)[0]?.time.updated
|
||||
if (!updated) return undefined
|
||||
return getRelativeTime(new Date(updated).toISOString(), language.t)
|
||||
}
|
||||
const sessionTime = (session: Session) => {
|
||||
if (!session.time.updated) return undefined
|
||||
return getRelativeTime(new Date(session.time.updated).toISOString(), language.t)
|
||||
}
|
||||
|
||||
const inspect = async (workspace: Workspace, context = captureDeleteContext()) => {
|
||||
const [working, branch, sessions] = await Promise.all([
|
||||
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
|
||||
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
|
||||
loadSessions(context),
|
||||
])
|
||||
const result = inspectWorkspaceDeletion({
|
||||
workspace: workspace.directory,
|
||||
activeDirectory: context.activeDirectory,
|
||||
sessions,
|
||||
status: working.data.length > 0 || branch.data.length > 0 ? "dirty" : "clean",
|
||||
})
|
||||
return { result, sessions }
|
||||
}
|
||||
const inspectionMessage = (result: WorkspaceDeleteInspection) => {
|
||||
if (result === "active") return language.t("settings.workspaces.delete.blocked.active")
|
||||
if (result === "linked") return language.t("settings.workspaces.delete.blocked.linked")
|
||||
if (result === "dirty") return language.t("workspace.status.dirty")
|
||||
return language.t("workspace.status.clean")
|
||||
}
|
||||
const blocked = (result: WorkspaceDeleteInspection) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: inspectionMessage(result),
|
||||
})
|
||||
}
|
||||
|
||||
const remove = async (workspace: Workspace, allowDirty = false, context = captureDeleteContext()) => {
|
||||
const preflight = await inspect(workspace, context)
|
||||
if (preflight.result !== "safe" && (!allowDirty || preflight.result !== "dirty")) {
|
||||
blocked(preflight.result)
|
||||
return
|
||||
}
|
||||
const removed = await context.sdk.client.worktree
|
||||
.remove({
|
||||
directory: workspace.project.worktree,
|
||||
worktreeRemoveInput: { directory: workspace.directory },
|
||||
})
|
||||
.then((result) => result.data)
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
return false
|
||||
})
|
||||
if (!removed) return
|
||||
tabs.store.forEach((tab) => {
|
||||
if (tab.type !== "draft" || tab.server !== context.server) return
|
||||
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
|
||||
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
|
||||
if (!directoryMatches && !worktreeMatches) return
|
||||
tabs.updateDraft(tab.draftID, {
|
||||
directory: directoryMatches ? workspace.project.worktree : tab.directory,
|
||||
worktree: undefined,
|
||||
})
|
||||
})
|
||||
clearWorkspaceTerminals(
|
||||
workspace.directory,
|
||||
preflight.sessions.map((session) => session.id),
|
||||
platform,
|
||||
context.sdk.scope,
|
||||
)
|
||||
context.sync.set(
|
||||
"project",
|
||||
produce((draft) => {
|
||||
const project = draft.find((item) => item.id === workspace.project.id)
|
||||
if (!project) return
|
||||
project.sandboxes = (project.sandboxes ?? []).filter(
|
||||
(directory) => pathKey(directory) !== pathKey(workspace.directory),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
let inspectionID = 0
|
||||
const releaseConfirmation = () => {
|
||||
if (store.transaction === "confirm") setStore("transaction", undefined)
|
||||
}
|
||||
const transact = async (task: () => Promise<void>) => {
|
||||
if (store.transaction !== "confirm") return
|
||||
setStore("transaction", "running")
|
||||
try {
|
||||
await task()
|
||||
} catch (error) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
} finally {
|
||||
setStore("transaction", undefined)
|
||||
}
|
||||
}
|
||||
const confirmDelete = (workspace: Workspace) => {
|
||||
if (store.transaction) return
|
||||
const context = captureDeleteContext()
|
||||
const current = ++inspectionID
|
||||
setStore("transaction", "confirm")
|
||||
void dialog.push(
|
||||
() => (
|
||||
<DialogDeleteWorkspace
|
||||
workspace={workspace}
|
||||
scope={context.sdk.scope}
|
||||
inspectionID={current}
|
||||
inspect={() => inspect(workspace, context)}
|
||||
inspectionMessage={inspectionMessage}
|
||||
onDelete={() => transact(() => remove(workspace, true, context))}
|
||||
/>
|
||||
),
|
||||
releaseConfirmation,
|
||||
)
|
||||
}
|
||||
const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
|
||||
await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, context))
|
||||
}
|
||||
const confirmDeleteAll = () => {
|
||||
if (store.transaction) return
|
||||
const context = captureDeleteContext()
|
||||
const inventory = [...filtered()]
|
||||
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
|
||||
setStore("transaction", "confirm")
|
||||
void dialog.push(
|
||||
() => (
|
||||
<DialogDeleteAllWorkspaces
|
||||
count={inventory.length}
|
||||
project={project}
|
||||
onDelete={() => transact(() => removeAll(inventory, context))}
|
||||
/>
|
||||
),
|
||||
releaseConfirmation,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header settings-v2-workspaces-header">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body settings-v2-workspaces">
|
||||
<div class="settings-v2-workspaces-toolbar">
|
||||
<span class="settings-v2-workspaces-count">
|
||||
{language.plural("settings.workspaces.count", filtered().length)}
|
||||
</span>
|
||||
<div class="settings-v2-workspaces-toolbar-actions">
|
||||
<Show when={projects().length > 1}>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
options={projectOptions()}
|
||||
current={projectOptions().find((option) => option.id === selectedProject())}
|
||||
value={(option) => option.id}
|
||||
label={(option) => option.label}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && setStore("project", option.id)}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={filtered().length > 0}>
|
||||
<MenuV2 placement="bottom-end" gutter={4}>
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
disabled={!!store.transaction}
|
||||
icon={<Icon name="outline-dots" size="small" />}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Item onSelect={confirmDeleteAll}>
|
||||
<span class="settings-v2-workspaces-delete-all">
|
||||
{language.t("settings.workspaces.deleteAll")}
|
||||
</span>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-workspaces-inventory">
|
||||
<Show
|
||||
when={filtered().length > 0}
|
||||
fallback={<div class="settings-v2-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
|
||||
>
|
||||
<SettingsListV2>
|
||||
<For each={filtered()}>
|
||||
{(workspace) => {
|
||||
const linked = () => workspaceSessions(workspace)
|
||||
return (
|
||||
<div class="settings-v2-workspaces-row">
|
||||
<div class="settings-v2-workspaces-row-header">
|
||||
<div class="settings-v2-workspaces-copy">
|
||||
<div class="settings-v2-workspaces-main">
|
||||
<TooltipV2
|
||||
value={workspace.directory}
|
||||
placement="top-start"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span tabIndex={0} aria-label={workspace.directory} class="settings-v2-workspaces-path">
|
||||
{workspace.directory}
|
||||
</span>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
|
||||
</div>
|
||||
<div class="settings-v2-workspaces-row-actions">
|
||||
<Show when={lastActive(workspace)}>
|
||||
{(value) => (
|
||||
<TooltipV2
|
||||
value={language.t("settings.workspaces.lastActiveSession")}
|
||||
placement="top-end"
|
||||
>
|
||||
<span tabIndex={0} class="settings-v2-workspaces-active">
|
||||
{value()}
|
||||
</span>
|
||||
</TooltipV2>
|
||||
)}
|
||||
</Show>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
aria-label={language.t("workspace.delete.confirm", {
|
||||
name: getFilename(workspace.directory),
|
||||
})}
|
||||
disabled={!!store.transaction}
|
||||
icon={<Icon name="trash" size="small" />}
|
||||
onClick={() => confirmDelete(workspace)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={linked().length > 0}>
|
||||
<div class="settings-v2-workspaces-sessions">
|
||||
<For each={linked()}>
|
||||
{(session) => (
|
||||
<div class="settings-v2-workspaces-session">
|
||||
<span>{session.title}</span>
|
||||
<Show when={sessionTime(session)}>
|
||||
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SettingsListV2>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const remove = () => {
|
||||
const deleting = props.onDelete()
|
||||
dialog.close()
|
||||
void deleting
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog fit>
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup
|
||||
title={language.t("settings.workspaces.deleteAll")}
|
||||
description={
|
||||
<>
|
||||
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
|
||||
<br />
|
||||
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 type="button" variant="danger" onClick={remove}>
|
||||
{language.t("settings.workspaces.deleteAll")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDeleteWorkspace(props: {
|
||||
workspace: Workspace
|
||||
scope: ServerScope
|
||||
inspectionID: number
|
||||
inspect: () => Promise<{ result: WorkspaceDeleteInspection; sessions: Session[] }>
|
||||
inspectionMessage: (result: WorkspaceDeleteInspection) => string
|
||||
onDelete: () => Promise<void>
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const status = useQuery(() => ({
|
||||
queryKey: [props.scope, pathKey(props.workspace.directory), "workspace-delete-status", props.inspectionID] as const,
|
||||
queryFn: props.inspect,
|
||||
staleTime: 0,
|
||||
}))
|
||||
const description = () => {
|
||||
if (status.isPending) return language.t("workspace.status.checking")
|
||||
if (status.isError) return language.t("workspace.status.error")
|
||||
return props.inspectionMessage(status.data?.result ?? "unknown")
|
||||
}
|
||||
const remove = () => {
|
||||
const deleting = props.onDelete()
|
||||
dialog.close()
|
||||
void deleting
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog fit>
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup
|
||||
title={language.t("workspace.delete.title")}
|
||||
description={
|
||||
<>
|
||||
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
|
||||
<br />
|
||||
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
|
||||
{props.workspace.directory}
|
||||
</code>
|
||||
<br />
|
||||
{language.t("settings.workspaces.delete.warning")}
|
||||
<br />
|
||||
{description()}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={
|
||||
status.isPending || status.isError || (status.data?.result !== "safe" && status.data?.result !== "dirty")
|
||||
}
|
||||
onClick={remove}
|
||||
>
|
||||
{language.t("workspace.delete.button")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -152,12 +152,6 @@ export async function bootstrapGlobal(input: {
|
||||
queryClient: QueryClient
|
||||
}) {
|
||||
const slow = [
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK, input.protocol)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
|
||||
),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
||||
@@ -524,17 +518,6 @@ export async function bootstrapDirectory(input: {
|
||||
input.queryClient.fetchQuery(
|
||||
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
|
||||
)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
|
||||
.catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
}),
|
||||
].filter(Boolean) as (() => Promise<any>)[]
|
||||
|
||||
await waitForPaint()
|
||||
|
||||
@@ -263,10 +263,13 @@ describe("createChildStoreManager", () => {
|
||||
manager.child("/project")
|
||||
expect(queries[0]?.().enabled).toBe(true)
|
||||
expect(queries[3]?.().enabled).toBe(true)
|
||||
expect(queries[4]?.().enabled).toBe(true)
|
||||
expect(queries[4]?.().enabled).toBe(false)
|
||||
expect(queries[5]?.().enabled).toBe(true)
|
||||
expect(bootstraps).toEqual(["/project"])
|
||||
|
||||
manager.enableProviders("/project")
|
||||
expect(queries[4]?.().enabled).toBe(true)
|
||||
|
||||
manager.child("/project", { bootstrap: false })
|
||||
expect(queries[0]?.().enabled).toBe(true)
|
||||
} finally {
|
||||
|
||||
@@ -47,6 +47,7 @@ export function createChildStoreManager(input: {
|
||||
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
||||
const activeDirectories = new Set<string>()
|
||||
const activationToggles = new Map<string, (enabled: boolean) => void>()
|
||||
const providerToggles = new Map<string, (enabled: boolean) => void>()
|
||||
|
||||
const markKey = (key: DirectoryKey) => {
|
||||
if (!key) return
|
||||
@@ -122,6 +123,7 @@ export function createChildStoreManager(input: {
|
||||
mcpToggles.delete(key)
|
||||
activeDirectories.delete(key)
|
||||
activationToggles.delete(key)
|
||||
providerToggles.delete(key)
|
||||
const dispose = disposers.get(key)
|
||||
if (dispose) {
|
||||
dispose()
|
||||
@@ -187,6 +189,7 @@ export function createChildStoreManager(input: {
|
||||
const initialIcon = icon[0].value
|
||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
||||
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
||||
const [providerEnabled, setProviderEnabled] = createSignal(false)
|
||||
|
||||
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
|
||||
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
||||
@@ -194,7 +197,7 @@ export function createChildStoreManager(input: {
|
||||
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
|
||||
const providerQuery = useQuery(() => ({
|
||||
...input.queryOptions.providers(key),
|
||||
enabled: instanceQueriesEnabled(),
|
||||
enabled: providerEnabled(),
|
||||
}))
|
||||
const referenceQuery = useQuery(() => ({
|
||||
...input.queryOptions.references(key),
|
||||
@@ -206,7 +209,7 @@ export function createChildStoreManager(input: {
|
||||
projectMeta: initialMeta,
|
||||
icon: initialIcon,
|
||||
get provider_ready() {
|
||||
return instanceQueriesEnabled() && !providerQuery.isLoading
|
||||
return providerEnabled() && !providerQuery.isLoading
|
||||
},
|
||||
get provider() {
|
||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||
@@ -263,6 +266,7 @@ export function createChildStoreManager(input: {
|
||||
disposers.set(key, dispose)
|
||||
mcpToggles.set(key, setMcpEnabled)
|
||||
activationToggles.set(key, setInstanceQueriesEnabled)
|
||||
providerToggles.set(key, setProviderEnabled)
|
||||
|
||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||
if (!(init instanceof Promise)) return
|
||||
@@ -329,6 +333,12 @@ export function createChildStoreManager(input: {
|
||||
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
||||
}
|
||||
|
||||
function enableProviders(directory: string) {
|
||||
const key = directoryKey(directory)
|
||||
ensureChild(directory)
|
||||
providerToggles.get(key)?.(true)
|
||||
}
|
||||
|
||||
// Passive Home/project metadata reads must not initialize the directory.
|
||||
// A real directory access enables these queries once for the store lifetime.
|
||||
// TODO(v2): After Home switches to v2.project.list and root-filtered,
|
||||
@@ -387,6 +397,7 @@ export function createChildStoreManager(input: {
|
||||
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
|
||||
active: (directory: string) => activeDirectories.has(directoryKey(directory)),
|
||||
disableMcp,
|
||||
enableProviders,
|
||||
disposeDirectory,
|
||||
runEviction,
|
||||
vcsCache,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const homeSessionIndexKey = (server: string) => ["home", "session-index",
|
||||
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
|
||||
|
||||
type HomeSessionPage = { data?: V2SessionListResponse }
|
||||
type ProjectedHomeSessionPage = { data?: { data: Session[]; cursor: { next?: string } } }
|
||||
|
||||
export async function loadHomeSessionIndex(
|
||||
list: (
|
||||
@@ -31,7 +32,30 @@ export async function loadHomeSessionIndex(
|
||||
eventSequence = 0,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const data: SessionV2Info[] = []
|
||||
return loadHomeSessionPages(list, parseHomeSessionIndex, eventSequence, signal)
|
||||
}
|
||||
|
||||
export async function loadProjectedHomeSessionIndex(
|
||||
list: (
|
||||
input: { limit: number; order: "desc"; cursor?: string },
|
||||
options: { signal?: AbortSignal },
|
||||
) => Promise<ProjectedHomeSessionPage>,
|
||||
eventSequence = 0,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return loadHomeSessionPages(list, (sessions) => sessions, eventSequence, signal)
|
||||
}
|
||||
|
||||
async function loadHomeSessionPages<T>(
|
||||
list: (
|
||||
input: { limit: number; order: "desc"; cursor?: string },
|
||||
options: { signal?: AbortSignal },
|
||||
) => Promise<{ data?: { data: T[]; cursor: { next?: string } } }>,
|
||||
project: (sessions: T[]) => Session[],
|
||||
eventSequence: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const data: T[] = []
|
||||
let cursor: string | undefined
|
||||
|
||||
for (;;) {
|
||||
@@ -46,7 +70,7 @@ export async function loadHomeSessionIndex(
|
||||
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 }
|
||||
return { sessions: project(data), eventSequence }
|
||||
cursor = page.cursor.next
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ import * as i18n from "@solid-primitives/i18n"
|
||||
import { createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
|
||||
import {
|
||||
pluralCategory,
|
||||
type UiI18nPluralLookupKey,
|
||||
type UiI18nPluralKey,
|
||||
type UiPluralCategory,
|
||||
} from "@opencode-ai/ui/context/i18n"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||
@@ -25,11 +30,13 @@ function localeDirection(locale: Locale): Direction {
|
||||
|
||||
type RawDictionary = typeof en & typeof uiEn
|
||||
type Dictionary = i18n.Flatten<RawDictionary>
|
||||
type PluralKey =
|
||||
| UiI18nPluralKey
|
||||
| "session.question.pending"
|
||||
| "session.followupDock.summary"
|
||||
| "session.revertDock.summary"
|
||||
type AppI18nKey = Extract<keyof typeof en, string>
|
||||
type AppI18nPluralKey = {
|
||||
[Key in AppI18nKey]: Key extends `${infer Base}.other` ? (`${Base}.one` extends AppI18nKey ? Base : never) : never
|
||||
}[AppI18nKey]
|
||||
type PluralKey = AppI18nPluralKey | UiI18nPluralKey
|
||||
type AppI18nPluralLookupKey = `${AppI18nPluralKey}.${UiPluralCategory}`
|
||||
type TranslationKey<Key extends string> = Key extends AppI18nPluralLookupKey | UiI18nPluralLookupKey ? never : Key
|
||||
type Source = { dict: Record<string, string> }
|
||||
|
||||
function cookie(locale: Locale) {
|
||||
@@ -69,7 +76,7 @@ const INTL: Record<Locale, string> = {
|
||||
sv: "sv-SE",
|
||||
}
|
||||
|
||||
const LABEL_KEY: Partial<Record<Locale, keyof Dictionary>> = {
|
||||
const LABEL_KEY = {
|
||||
en: "language.en",
|
||||
zh: "language.zh",
|
||||
zht: "language.zht",
|
||||
@@ -88,7 +95,7 @@ const LABEL_KEY: Partial<Record<Locale, keyof Dictionary>> = {
|
||||
th: "language.th",
|
||||
bs: "language.bs",
|
||||
tr: "language.tr",
|
||||
}
|
||||
} as const satisfies Partial<Record<Locale, AppI18nKey>>
|
||||
|
||||
const LABEL: Partial<Record<Locale, string>> = {
|
||||
hi: "हिन्दी",
|
||||
@@ -259,21 +266,26 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
initialValue: dicts.get(initial) ?? base,
|
||||
})
|
||||
|
||||
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
|
||||
key: keyof Dictionary,
|
||||
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as <Key extends string>(
|
||||
key: TranslationKey<Key>,
|
||||
params?: Record<string, string | number | boolean>,
|
||||
) => string
|
||||
|
||||
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => {
|
||||
const category = pluralCategory(intl(), count)
|
||||
const pluralForm = (
|
||||
key: PluralKey,
|
||||
category: UiPluralCategory,
|
||||
params?: Record<string, string | number | boolean>,
|
||||
) => {
|
||||
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
|
||||
const candidate = `${key}.${category}`
|
||||
const fallback = `${key}.other`
|
||||
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count })
|
||||
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, params)
|
||||
}
|
||||
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) =>
|
||||
pluralForm(key, pluralCategory(intl(), count), { ...params, count })
|
||||
|
||||
const label = (value: Locale) => {
|
||||
const key = LABEL_KEY[value]
|
||||
const key = LABEL_KEY[value as keyof typeof LABEL_KEY]
|
||||
if (key) return t(key)
|
||||
return LABEL[value] ?? value
|
||||
}
|
||||
@@ -305,6 +317,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
label,
|
||||
t,
|
||||
plural,
|
||||
pluralForm,
|
||||
setLocale(next: Locale) {
|
||||
setStore("locale", normalizeLocale(next))
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk"
|
||||
import { useSettings } from "./settings"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useTabs, type Tab } from "./tabs"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import {
|
||||
createPromptReady,
|
||||
createPromptSession,
|
||||
@@ -104,11 +105,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
||||
const scope = (): PromptScope =>
|
||||
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
||||
const load = (scope: PromptScope) => {
|
||||
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
|
||||
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
||||
const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => {
|
||||
const current = settings.general.newLayoutDesigns()
|
||||
? selectPromptTab(tabs.store, scope, target?.server ?? serverKey())
|
||||
: undefined
|
||||
if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK().scope, scope)
|
||||
|
||||
const key = scopeKey(scope)
|
||||
const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope)
|
||||
const existing = cache.get(key)
|
||||
if (existing) {
|
||||
cache.delete(key)
|
||||
@@ -118,7 +121,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
|
||||
const entry = createRoot(
|
||||
(dispose) => ({
|
||||
value: createPromptSession(serverSDK().scope, scope),
|
||||
value: createPromptSession(target?.scope ?? serverSDK().scope, scope),
|
||||
dispose,
|
||||
}),
|
||||
owner,
|
||||
@@ -130,7 +133,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
const session = createMemo(() => load(scope()))
|
||||
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
|
||||
const pick = (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||
scope ? load(scope, target) : session()
|
||||
const ready = createPromptReady(session)
|
||||
|
||||
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
||||
@@ -146,7 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
|
||||
return {
|
||||
ready,
|
||||
capture: (scope?: PromptScope) => pick(scope).capture(),
|
||||
capture: (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||
pick(scope, target).capture(),
|
||||
current: withSuspense(() => session().current()),
|
||||
cursor: withSuspense(() => session().cursor()),
|
||||
dirty: withSuspense(() => session().dirty()),
|
||||
|
||||
@@ -1,7 +1,89 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import {
|
||||
adaptServerEvent,
|
||||
adaptWorktreeCompatibilityEvent,
|
||||
applyWorkspaceOperationEvent,
|
||||
applyWorktreeEvent,
|
||||
coalesceServerEvents,
|
||||
enqueueServerEvent,
|
||||
resumeStreamAfterPageShow,
|
||||
} from "./server-sdk"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { Worktree } from "@/utils/worktree"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
|
||||
describe("applyWorktreeEvent", () => {
|
||||
test("adapts global readiness with the created worktree directory", () => {
|
||||
const directory = "/repo/worktree-compatible"
|
||||
const event = adaptWorktreeCompatibilityEvent({
|
||||
directory,
|
||||
payload: { id: "ready", type: "worktree.ready", properties: { name: "compatible" } } as Event,
|
||||
})
|
||||
if (!event) throw new Error("expected worktree event")
|
||||
|
||||
Worktree.pending(ServerScope.local, directory)
|
||||
applyWorktreeEvent(ServerScope.local, event, "failed")
|
||||
expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "ready" })
|
||||
expect(
|
||||
adaptWorktreeCompatibilityEvent({
|
||||
directory: "/repo",
|
||||
payload: {
|
||||
id: "status",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "idle" } },
|
||||
} as Event,
|
||||
}),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves the server failure message", () => {
|
||||
const directory = "/repo/worktree-failed"
|
||||
Worktree.pending(ServerScope.local, directory)
|
||||
|
||||
applyWorktreeEvent(
|
||||
ServerScope.local,
|
||||
{
|
||||
directory,
|
||||
payload: {
|
||||
id: "failed",
|
||||
type: "worktree.failed",
|
||||
properties: { name: "failed", message: "bootstrap failed" },
|
||||
} as Event,
|
||||
},
|
||||
"fallback",
|
||||
)
|
||||
expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "failed", message: "bootstrap failed" })
|
||||
})
|
||||
})
|
||||
|
||||
test("legacy and current moved events complete matching operations", () => {
|
||||
const events = [
|
||||
{
|
||||
id: "legacy",
|
||||
payload: {
|
||||
id: "moved",
|
||||
type: "session.next.moved",
|
||||
properties: { timestamp: Date.now(), sessionID: "legacy", location: { directory: "/workspace" } },
|
||||
} as Event,
|
||||
},
|
||||
{
|
||||
id: "current",
|
||||
payload: adaptServerEvent({
|
||||
id: "moved-current",
|
||||
created: Date.now(),
|
||||
type: "session.moved",
|
||||
data: { sessionID: "current", location: { directory: "/workspace" } },
|
||||
} as OpenCodeEvent),
|
||||
},
|
||||
]
|
||||
events.forEach((event) => {
|
||||
WorkspaceOperation.start(ServerScope.local, event.id, "move", "/workspace")
|
||||
applyWorkspaceOperationEvent(ServerScope.local, { directory: "/workspace", payload: event.payload })
|
||||
expect(WorkspaceOperation.get(ServerScope.local, event.id)?.status).toBe("complete")
|
||||
})
|
||||
})
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
test("restarts a stream only after a back-forward cache restore", () => {
|
||||
|
||||
@@ -13,6 +13,10 @@ import { useGlobal } from "./global"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
|
||||
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
|
||||
import { Worktree } from "@/utils/worktree"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { decodeVcsDiff } from "@/utils/vcs-diff-decoder"
|
||||
import { decodeSessionList } from "./session-message-decoder"
|
||||
|
||||
const isAbortError = (error: unknown) =>
|
||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
@@ -56,6 +60,11 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||
}
|
||||
|
||||
export function adaptWorktreeCompatibilityEvent(event: { directory?: string; payload: Event }) {
|
||||
if (event.payload.type !== "worktree.ready" && event.payload.type !== "worktree.failed") return
|
||||
return { directory: event.directory ?? "global", payload: event.payload }
|
||||
}
|
||||
|
||||
const coalescedKey = (event: QueuedServerEvent) => {
|
||||
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
||||
if (event.payload.type === "message.part.updated") {
|
||||
@@ -138,6 +147,31 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
return output
|
||||
}
|
||||
|
||||
export function applyWorktreeEvent(scope: ServerScope, event: QueuedServerEvent, fallback: string) {
|
||||
if (event.payload.type === "worktree.ready") {
|
||||
Worktree.ready(scope, event.directory)
|
||||
return true
|
||||
}
|
||||
if (event.payload.type !== "worktree.failed") return false
|
||||
const message = event.payload.properties.message ?? fallback
|
||||
Worktree.failed(scope, event.directory, message)
|
||||
return true
|
||||
}
|
||||
|
||||
export function applyWorkspaceOperationEvent(scope: ServerScope, event: QueuedServerEvent) {
|
||||
if (event.payload.current?.type === "session.moved") {
|
||||
WorkspaceOperation.complete(
|
||||
scope,
|
||||
event.payload.current.data.sessionID,
|
||||
event.payload.current.data.location.directory,
|
||||
)
|
||||
return true
|
||||
}
|
||||
if (event.payload.type !== "session.next.moved") return false
|
||||
WorkspaceOperation.complete(scope, event.payload.properties.sessionID, event.payload.properties.location.directory)
|
||||
return true
|
||||
}
|
||||
|
||||
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
||||
if (
|
||||
event?.type === "session.text.delta" ||
|
||||
@@ -186,6 +220,7 @@ type ServerSDKBase = {
|
||||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const abort = new AbortController()
|
||||
|
||||
const eventFetch = (() => {
|
||||
@@ -238,7 +273,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
last = Date.now()
|
||||
const output = coalesceServerEvents(events)
|
||||
batch(() => {
|
||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
||||
output.forEach((event) => {
|
||||
applyWorktreeEvent(scope, event, language.t("common.requestFailed"))
|
||||
applyWorkspaceOperationEvent(scope, event)
|
||||
emitter.emit(event.directory, event.payload)
|
||||
})
|
||||
})
|
||||
|
||||
buffer.length = 0
|
||||
@@ -251,12 +290,57 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
}
|
||||
|
||||
let streamErrorLogged = false
|
||||
let worktreeStreamErrorLogged = false
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
let attempt: AbortController | undefined
|
||||
let worktreeAttempt: AbortController | undefined
|
||||
let run: Promise<void> | undefined
|
||||
let started = false
|
||||
let generation = 0
|
||||
|
||||
const consumeWorktreeEvents = async (active: number) => {
|
||||
// Current worktree lifecycle events are still emitted only on the global compatibility stream.
|
||||
while (!abort.signal.aborted && started && generation === active) {
|
||||
const controller = new AbortController()
|
||||
worktreeAttempt = controller
|
||||
const onAbort = () => controller.abort()
|
||||
abort.signal.addEventListener("abort", onAbort)
|
||||
try {
|
||||
const events = (await eventSdk.global.event({ signal: controller.signal })).stream
|
||||
let yielded = Date.now()
|
||||
for await (const event of events) {
|
||||
const queued = adaptWorktreeCompatibilityEvent({
|
||||
directory: event.directory,
|
||||
payload: event.payload as Event,
|
||||
})
|
||||
if (queued) {
|
||||
worktreeStreamErrorLogged = false
|
||||
if (enqueueServerEvent(queue, queued)) schedule()
|
||||
}
|
||||
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
yielded = Date.now()
|
||||
await wait(0)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isStreamClosed(error, controller.signal) && !worktreeStreamErrorLogged) {
|
||||
worktreeStreamErrorLogged = true
|
||||
console.error("[global-sdk] worktree event stream failed", {
|
||||
url: server.http.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
error,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
abort.signal.removeEventListener("abort", onAbort)
|
||||
if (worktreeAttempt === controller) worktreeAttempt = undefined
|
||||
}
|
||||
|
||||
if (abort.signal.aborted || !started || generation !== active) return
|
||||
await wait(RECONNECT_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (started) return run
|
||||
started = true
|
||||
@@ -264,6 +348,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
const previous = run
|
||||
const current = (async () => {
|
||||
if (previous) await previous
|
||||
const kind = await protocol
|
||||
if (kind === "v2") void consumeWorktreeEvents(active)
|
||||
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
|
||||
while (!abort.signal.aborted && started && generation === active) {
|
||||
attempt = new AbortController()
|
||||
@@ -272,7 +358,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
}
|
||||
abort.signal.addEventListener("abort", onAbort)
|
||||
try {
|
||||
const kind = await protocol
|
||||
const events =
|
||||
kind === "v1"
|
||||
? (await eventSdk.global.event({ signal: attempt.signal })).stream
|
||||
@@ -320,6 +405,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
started = false
|
||||
generation++
|
||||
attempt?.abort()
|
||||
worktreeAttempt?.abort()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -346,7 +432,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
throwOnError: true,
|
||||
directory,
|
||||
})
|
||||
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
|
||||
const api = createCompatibleApi({ protocol, current: currentApi, legacy, decodeVcsDiff, decodeSessionList })
|
||||
|
||||
return {
|
||||
server,
|
||||
@@ -432,7 +518,19 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||
current: serverSDK.currentApi,
|
||||
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
||||
directory,
|
||||
decodeVcsDiff,
|
||||
decodeSessionList,
|
||||
}),
|
||||
createApi(next: string) {
|
||||
return createCompatibleApi({
|
||||
protocol: serverSDK.protocol,
|
||||
current: serverSDK.currentApi,
|
||||
legacy: (target) => serverSDK.createClient({ directory: target ?? next, throwOnError: true }),
|
||||
directory: next,
|
||||
decodeVcsDiff,
|
||||
decodeSessionList,
|
||||
})
|
||||
},
|
||||
event: emitter,
|
||||
get url() {
|
||||
return serverSDK.url
|
||||
|
||||
@@ -223,6 +223,23 @@ describe("server session", () => {
|
||||
expect(ctx.store.lineage.peek("child")).toEqual(result)
|
||||
})
|
||||
|
||||
test("applies moved session locations without evicting cached state", () => {
|
||||
const current = { ...session("child"), directory: "/repo/worktree", path: undefined }
|
||||
const ctx = setup({ child: current })
|
||||
ctx.store.remember(current)
|
||||
|
||||
ctx.store.apply({
|
||||
type: "session.next.moved",
|
||||
properties: {
|
||||
sessionID: "child",
|
||||
location: { directory: "/repo" },
|
||||
subdirectory: "packages/app",
|
||||
},
|
||||
})
|
||||
|
||||
expect(ctx.store.get("child")).toMatchObject({ directory: "/repo", path: "packages/app" })
|
||||
})
|
||||
|
||||
test("loads session content through the server client", async () => {
|
||||
const ctx = setup({ root: session("root") })
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { normalizeSessionMessages } from "@/utils/session-message"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
import type { DecodedLegacyMessagePage } from "./session-message-decode"
|
||||
|
||||
type MessageApi = ServerApi["message"]
|
||||
|
||||
@@ -29,10 +30,16 @@ const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const initialMessagePageSize = 20
|
||||
const historyMessagePageSize = 200
|
||||
const historyMessagePageSize = 50
|
||||
const sessionInfoLimit = 2_048
|
||||
const emptyIDs: ReadonlySet<string> = new Set()
|
||||
|
||||
function yieldToMain() {
|
||||
const scheduler = (globalThis as { scheduler?: { yield: () => Promise<void> } }).scheduler
|
||||
if (scheduler) return scheduler.yield()
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
||||
const boundary = source.find(
|
||||
(message) =>
|
||||
@@ -183,7 +190,11 @@ function reconcileFetched<T extends { id: string }>(
|
||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
|
||||
type ServerSessionOptions = {
|
||||
retry?: typeof retry
|
||||
protocol?: Promise<"v1" | "v2">
|
||||
decodeMessages?: (buffer: ArrayBuffer) => Promise<DecodedLegacyMessagePage>
|
||||
}
|
||||
|
||||
export function createServerSession(
|
||||
client: OpencodeClient,
|
||||
@@ -301,22 +312,24 @@ export function createServerSession(
|
||||
return session
|
||||
}
|
||||
|
||||
const resolve = (sessionID: string, options?: { force?: boolean }) => {
|
||||
const resolve = (sessionID: string, options?: { force?: boolean; signal?: AbortSignal }) => {
|
||||
const cached = data.info[sessionID]
|
||||
if (cached && !options?.force) return Promise.resolve(cached)
|
||||
const pending = requests.get(sessionID)
|
||||
const pending = options?.signal ? undefined : requests.get(sessionID)
|
||||
if (pending) return pending
|
||||
const active = generation(sessionID)
|
||||
const request = sessionApi
|
||||
? sessionApi.get({ sessionID }).then(normalizeSessionInfo)
|
||||
: client.session.get({ sessionID }).then((result) => {
|
||||
? sessionApi.get({ sessionID }, { signal: options?.signal }).then(normalizeSessionInfo)
|
||||
: client.session.get({ sessionID }, { signal: options?.signal }).then((result) => {
|
||||
if (!result.data) throw sessionNotFoundError(sessionID)
|
||||
return result.data
|
||||
})
|
||||
const resolved = request.then((result) => {
|
||||
if (options?.signal?.aborted) return result
|
||||
if (generations.get(sessionID) !== active) return result
|
||||
return remember(result)
|
||||
})
|
||||
if (options?.signal) return resolved
|
||||
requests.set(sessionID, resolved)
|
||||
const cleanup = () => {
|
||||
if (requests.get(sessionID) === resolved) requests.delete(sessionID)
|
||||
@@ -552,6 +565,7 @@ export function createServerSession(
|
||||
if (!response.data.length) break
|
||||
}
|
||||
const response = pages.at(-1)!
|
||||
await yieldToMain()
|
||||
const source = pages.flatMap((page) => page.data).toReversed()
|
||||
const normalized = normalizeSessionMessages(sessionID, source)
|
||||
return {
|
||||
@@ -566,10 +580,21 @@ export function createServerSession(
|
||||
complete: response.data.length === 0,
|
||||
}
|
||||
}
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
const response = await (options?.retry ?? retry)(async () => {
|
||||
onAttempt?.()
|
||||
return client.session.messages({ sessionID, limit, before })
|
||||
if (!options?.decodeMessages) return client.session.messages({ sessionID, limit, before })
|
||||
const response = await client.session.messages({ sessionID, limit, before }, { parseAs: "arrayBuffer" })
|
||||
if (!(response.data instanceof ArrayBuffer)) throw new Error("Session messages response is not an ArrayBuffer")
|
||||
return { response, decoded: await options.decodeMessages(response.data) }
|
||||
})
|
||||
await yieldToMain()
|
||||
if ("decoded" in response)
|
||||
return {
|
||||
...response.decoded,
|
||||
sourceMode: before ? ("older" as const) : ("latest" as const),
|
||||
cursor: response.response.response.headers.get("x-next-cursor") ?? undefined,
|
||||
complete: !response.response.response.headers.get("x-next-cursor"),
|
||||
}
|
||||
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
||||
return {
|
||||
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
||||
@@ -1018,6 +1043,27 @@ export function createServerSession(
|
||||
evict([sessionID])
|
||||
return
|
||||
}
|
||||
case "session.next.moved": {
|
||||
const props = event.properties as {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
location: { directory: string; workspaceID?: string }
|
||||
subdirectory?: string
|
||||
}
|
||||
const current = data.info[props.sessionID]
|
||||
if (!current) {
|
||||
void resolve(props.sessionID, { force: true }).catch(() => {})
|
||||
return
|
||||
}
|
||||
remember({
|
||||
...current,
|
||||
directory: props.location.directory,
|
||||
path: props.subdirectory,
|
||||
workspaceID: props.location.workspaceID,
|
||||
time: { ...current.time, updated: props.timestamp },
|
||||
})
|
||||
return
|
||||
}
|
||||
case "todo.updated": {
|
||||
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||
@@ -1341,6 +1387,7 @@ export function createServerSession(
|
||||
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
||||
if (!items)
|
||||
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
||||
indexLegacyMessage(input.message)
|
||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
|
||||
setData(
|
||||
"part_text_accum_delta",
|
||||
@@ -1374,6 +1421,9 @@ export function createServerSession(
|
||||
)
|
||||
return
|
||||
}
|
||||
setData("session_message", input.sessionID, (messages) =>
|
||||
messages?.filter((message) => message.id !== input.messageID),
|
||||
)
|
||||
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
||||
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||
},
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Event, OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
McpListInput,
|
||||
McpResourceCatalogInput,
|
||||
SessionApi,
|
||||
SessionInfo,
|
||||
SessionListInput,
|
||||
OpenCodeEvent,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
|
||||
import {
|
||||
captureSessionMove,
|
||||
loadActiveSessionsQuery,
|
||||
loadMcpQuery,
|
||||
loadMcpResourcesQuery,
|
||||
seedActiveSessionStatuses,
|
||||
} from "./server-sync"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { createServerSession } from "./server-session"
|
||||
import { adaptServerEvent } from "./server-sdk"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
|
||||
type McpApi = ServerApi["mcp"]
|
||||
@@ -102,6 +110,52 @@ describe("active session query", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("session move normalization", () => {
|
||||
test("captures and applies current moves from the source placement", () => {
|
||||
const session = createServerSession({} as OpencodeClient)
|
||||
session.remember(sessionAt("/source"))
|
||||
const current = {
|
||||
id: "event-current-move",
|
||||
created: 10,
|
||||
type: "session.moved",
|
||||
data: { sessionID: "session", location: { directory: "/destination" } },
|
||||
} as OpenCodeEvent
|
||||
const event = adaptServerEvent(current)
|
||||
|
||||
expect(captureSessionMove(event, session.get)).toEqual({
|
||||
sessionID: "session",
|
||||
from: "/source",
|
||||
refresh: "session.next.moved",
|
||||
})
|
||||
session.applyV2(current)
|
||||
session.apply(event)
|
||||
expect(session.get("session")?.directory).toBe("/destination")
|
||||
})
|
||||
|
||||
test("captures and applies V1 moves from the source placement", () => {
|
||||
const session = createServerSession({} as OpencodeClient)
|
||||
session.remember(sessionAt("/source"))
|
||||
const event = {
|
||||
type: "session.next.moved",
|
||||
properties: {
|
||||
timestamp: 10,
|
||||
sessionID: "session",
|
||||
location: { directory: "/destination" },
|
||||
subdirectory: "packages/app",
|
||||
},
|
||||
} as Event
|
||||
|
||||
expect(captureSessionMove(event, session.get)).toEqual({
|
||||
sessionID: "session",
|
||||
from: "/source",
|
||||
refresh: "session.next.moved",
|
||||
})
|
||||
session.apply(event)
|
||||
expect(session.get("session")).toMatchObject({ directory: "/destination", path: "packages/app" })
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe("pickDirectoriesToEvict", () => {
|
||||
test("keeps pinned stores and evicts idle stores", () => {
|
||||
const now = 5_000
|
||||
@@ -174,6 +228,18 @@ function sessionInfo(id: string) {
|
||||
} as SessionInfo
|
||||
}
|
||||
|
||||
function sessionAt(directory: string): Session {
|
||||
return {
|
||||
id: "session",
|
||||
slug: "session",
|
||||
projectID: "project",
|
||||
directory,
|
||||
title: "Session",
|
||||
version: "",
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
describe("estimateRootSessionTotal", () => {
|
||||
test("keeps exact total for full fetches", () => {
|
||||
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
||||
|
||||
@@ -8,11 +8,11 @@ import type {
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||
import { type Accessor, batch, createMemo, createSignal, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { InitError } from "../pages/error"
|
||||
import { ServerSDK } from "./server-sdk"
|
||||
import { ServerSDK, type ServerEvent } from "./server-sdk"
|
||||
import {
|
||||
bootstrapDirectory,
|
||||
bootstrapGlobal,
|
||||
@@ -46,6 +46,32 @@ import { ServerConnection, useServer } from "./server"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
|
||||
|
||||
export function captureSessionMove(event: ServerEvent, get: (sessionID: string) => { directory: string } | undefined) {
|
||||
const sessionID =
|
||||
event.current?.type === "session.moved"
|
||||
? event.current.data.sessionID
|
||||
: event.type === "session.next.moved"
|
||||
? event.properties.sessionID
|
||||
: undefined
|
||||
if (!sessionID) return
|
||||
return { sessionID, from: get(sessionID)?.directory, refresh: "session.next.moved" as const }
|
||||
}
|
||||
|
||||
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
||||
const type: string = event.type
|
||||
const current: string | undefined = event.current?.type
|
||||
return (
|
||||
type === "session.created" ||
|
||||
type === "session.updated" ||
|
||||
type === "session.deleted" ||
|
||||
type === "session.next.moved" ||
|
||||
current === "session.moved" ||
|
||||
current === "session.renamed" ||
|
||||
current === "session.archived" ||
|
||||
current === "session.forked"
|
||||
)
|
||||
}
|
||||
import { persisted } from "@/utils/persist"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
import type {
|
||||
@@ -59,6 +85,7 @@ import type {
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createServerSession, type ServerSession } from "./server-session"
|
||||
import { decodeSessionMessages } from "./session-message-decoder"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -226,6 +253,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
|
||||
const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
|
||||
protocol: serverSDK.protocol,
|
||||
decodeMessages: decodeSessionMessages,
|
||||
})
|
||||
const queryOptionsApi = makeQueryOptionsApi(
|
||||
serverSDK.scope,
|
||||
@@ -235,31 +263,40 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
serverSDK.protocol,
|
||||
)
|
||||
|
||||
const [providersEnabled, setProvidersEnabled] = createSignal(false)
|
||||
const [backgroundEnabled, setBackgroundEnabled] = createSignal(false)
|
||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
||||
queries: [
|
||||
{ ...queryOptionsApi.globalConfig(), enabled: backgroundEnabled() },
|
||||
{ ...queryOptionsApi.providers(null), enabled: providersEnabled() },
|
||||
{ ...queryOptionsApi.path(null), enabled: backgroundEnabled() },
|
||||
],
|
||||
}))
|
||||
const activeSessionsQuery = useQuery(() =>
|
||||
loadActiveSessionsQuery(serverSDK.scope, {
|
||||
active: async () => {
|
||||
if ((await serverSDK.protocol) === "v1") {
|
||||
const statuses = (await serverSDK.client.session.status()).data ?? {}
|
||||
seedActiveSessionStatuses(session, statuses)
|
||||
for (const sessionID of Object.keys(statuses)) {
|
||||
({
|
||||
...loadActiveSessionsQuery(serverSDK.scope, {
|
||||
active: async () => {
|
||||
if ((await serverSDK.protocol) === "v1") {
|
||||
const statuses = (await serverSDK.client.session.status()).data ?? {}
|
||||
seedActiveSessionStatuses(session, statuses)
|
||||
for (const sessionID of Object.keys(statuses)) {
|
||||
void session.resolve(sessionID).catch(() => undefined)
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(statuses).flatMap(([sessionID, status]) =>
|
||||
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
|
||||
),
|
||||
)
|
||||
}
|
||||
const active = await serverSDK.api.session.active()
|
||||
seedActiveSessionStatuses(session, active)
|
||||
for (const sessionID of Object.keys(active)) {
|
||||
void session.resolve(sessionID).catch(() => undefined)
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(statuses).flatMap(([sessionID, status]) =>
|
||||
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
|
||||
),
|
||||
)
|
||||
}
|
||||
const active = await serverSDK.api.session.active()
|
||||
seedActiveSessionStatuses(session, active)
|
||||
for (const sessionID of Object.keys(active)) {
|
||||
void session.resolve(sessionID).catch(() => undefined)
|
||||
}
|
||||
return active
|
||||
},
|
||||
return active
|
||||
},
|
||||
}),
|
||||
enabled: backgroundEnabled(),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -299,10 +336,36 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
let bootingRoot = false
|
||||
let eventFrame: number | undefined
|
||||
let eventTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let providerFrame: number | undefined
|
||||
let providerIdle: number | undefined
|
||||
let providerTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
onMount(() => {
|
||||
providerFrame = requestAnimationFrame(() => {
|
||||
providerFrame = requestAnimationFrame(() => {
|
||||
providerFrame = undefined
|
||||
providerTimer = setTimeout(() => {
|
||||
providerTimer = undefined
|
||||
if ("requestIdleCallback" in window) {
|
||||
providerIdle = requestIdleCallback(() => {
|
||||
setProvidersEnabled(true)
|
||||
setBackgroundEnabled(true)
|
||||
}, { timeout: 5_000 })
|
||||
return
|
||||
}
|
||||
setProvidersEnabled(true)
|
||||
setBackgroundEnabled(true)
|
||||
}, 10_000)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
|
||||
if (eventTimer !== undefined) clearTimeout(eventTimer)
|
||||
if (providerFrame !== undefined) cancelAnimationFrame(providerFrame)
|
||||
if (providerIdle !== undefined) cancelIdleCallback(providerIdle)
|
||||
if (providerTimer !== undefined) clearTimeout(providerTimer)
|
||||
})
|
||||
|
||||
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
|
||||
@@ -528,19 +591,55 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
})
|
||||
}
|
||||
|
||||
const reindexSession = (sessionID: string, from?: string) => {
|
||||
const next = session.get(sessionID)
|
||||
if (!next) return
|
||||
indexSession(next)
|
||||
if (!from) return
|
||||
const source = children.children[directoryKey(from)]
|
||||
if (!source) return
|
||||
applyDirectoryEvent({
|
||||
event: {
|
||||
type: "session.moved",
|
||||
properties: {
|
||||
sessionID,
|
||||
projectID: next.projectID,
|
||||
location: { directory: next.directory, workspaceID: next.workspaceID },
|
||||
subpath: next.path,
|
||||
},
|
||||
},
|
||||
directory: from,
|
||||
store: source[0],
|
||||
setStore: source[1],
|
||||
push: queue.push,
|
||||
retainedLimit: sessionMeta.get(directoryKey(from))?.limit,
|
||||
sessionContent: false,
|
||||
permission: session.data.permission,
|
||||
loadLsp() {},
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const key = directoryKey(directory)
|
||||
const event = e.details
|
||||
const eventType: string = event.type
|
||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||
const moved = captureSessionMove(event, session.get)
|
||||
|
||||
if (event.current) session.applyV2(event.current)
|
||||
session.apply(event)
|
||||
if (moved) reindexSession(moved.sessionID, moved.from)
|
||||
if (shouldRefreshWorkspaceSessions(event)) {
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "settings-workspace-sessions",
|
||||
})
|
||||
}
|
||||
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
||||
homeSessions.apply(event)
|
||||
}
|
||||
homeSessions.refresh(event.type)
|
||||
homeSessions.refresh(moved?.refresh ?? event.type)
|
||||
if (eventType === "integration.connection.updated") void refreshProviders()
|
||||
|
||||
if (directory === "global") {
|
||||
@@ -572,10 +671,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.current?.type === "session.moved") {
|
||||
const info = session.get(event.current.data.sessionID)
|
||||
if (info) indexSession(info)
|
||||
}
|
||||
if (event.current?.type === "session.forked")
|
||||
void session
|
||||
.resolve(event.current.data.sessionID, { force: true })
|
||||
@@ -682,12 +777,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
child: children.child,
|
||||
peek: children.peek,
|
||||
disableMcp: children.disableMcp,
|
||||
enableProviders: children.enableProviders,
|
||||
queryOptions: queryOptionsApi,
|
||||
loadProviders: () => setProvidersEnabled(true),
|
||||
refreshProviders,
|
||||
// bootstrap,
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
project: projectApi,
|
||||
session,
|
||||
reindexSession,
|
||||
homeSessions,
|
||||
mcp: {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { Message, Part, Session, SessionV2Info } from "@opencode-ai/sdk/v2/client"
|
||||
import { decodeHomeSessionPage, decodeLegacyMessagePage, decodeLegacySessionList } from "./session-message-decode"
|
||||
|
||||
test("decodes and projects a legacy message page", () => {
|
||||
const info = {
|
||||
id: "message",
|
||||
sessionID: "session",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
} as Message
|
||||
const part = {
|
||||
id: "part",
|
||||
sessionID: "session",
|
||||
messageID: info.id,
|
||||
type: "text",
|
||||
text: "hello",
|
||||
} as Part
|
||||
const result = decodeLegacyMessagePage(new TextEncoder().encode(JSON.stringify([{ info, parts: [part] }])).buffer)
|
||||
|
||||
expect(result.session).toEqual([info])
|
||||
expect(result.part).toEqual([{ id: info.id, part: [part] }])
|
||||
expect(result.source).toEqual([{ id: info.id, type: "user", text: "hello", time: info.time }])
|
||||
})
|
||||
|
||||
test("decodes and projects a legacy session list", () => {
|
||||
const session = {
|
||||
id: "session",
|
||||
projectID: "project",
|
||||
directory: "/repo",
|
||||
title: "Session",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 1 },
|
||||
} as Session
|
||||
const result = decodeLegacySessionList(new TextEncoder().encode(JSON.stringify([session])).buffer)
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({ id: session.id, title: session.title, location: { directory: "/repo" } }),
|
||||
])
|
||||
})
|
||||
|
||||
test("bounds Home sessions by directory before returning from the decoder", () => {
|
||||
const session = (id: string, directory: string, updated: number) =>
|
||||
({
|
||||
id,
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
subpath: "",
|
||||
title: id,
|
||||
time: { created: updated, updated },
|
||||
}) as SessionV2Info
|
||||
const page = {
|
||||
data: [session("old", "/repo", 1), session("new", "/repo", 2), session("other", "/other", 3)],
|
||||
cursor: {},
|
||||
}
|
||||
const result = decodeHomeSessionPage(new TextEncoder().encode(JSON.stringify(page)).buffer, {
|
||||
directories: ["/repo"],
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
expect(result.data.map((item) => item.id)).toEqual(["new"])
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { Message, Part, Session, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import { message as cleanMessage } from "@/utils/diffs"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { parseHomeSessionIndex } from "./global-sync/home-session-index"
|
||||
import { takeRecentSessions } from "./global-sync/session-trim"
|
||||
|
||||
export type DecodedLegacyMessagePage = {
|
||||
session: Message[]
|
||||
part: { id: string; part: Part[] }[]
|
||||
source: SessionMessageInfo[]
|
||||
}
|
||||
|
||||
export function decodeLegacyMessagePage(buffer: ArrayBuffer): DecodedLegacyMessagePage {
|
||||
const text = new TextDecoder().decode(buffer)
|
||||
const items = (text ? (JSON.parse(text) as { info?: Message; parts?: Part[] }[]) : []).filter(
|
||||
(item): item is { info: Message; parts: Part[] } => !!item.info?.id && Array.isArray(item.parts),
|
||||
)
|
||||
return {
|
||||
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => compare(a.id, b.id)),
|
||||
part: items.map((item) => ({
|
||||
id: item.info.id,
|
||||
part: item.parts.filter((part) => !!part?.id).sort((a, b) => compare(a.id, b.id)),
|
||||
})),
|
||||
source: items
|
||||
.slice()
|
||||
.sort((a, b) => compare(a.info.id, b.info.id))
|
||||
.map((item) =>
|
||||
item.info.role === "user"
|
||||
? {
|
||||
id: item.info.id,
|
||||
type: "user" as const,
|
||||
text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||
time: item.info.time,
|
||||
}
|
||||
: {
|
||||
id: item.info.id,
|
||||
type: "assistant" as const,
|
||||
agent: item.info.agent ?? item.info.mode,
|
||||
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
|
||||
content: [],
|
||||
time: item.info.time,
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeLegacySessionList(buffer: ArrayBuffer) {
|
||||
const text = new TextDecoder().decode(buffer)
|
||||
return (text ? (JSON.parse(text) as Session[]) : []).map(legacySessionInfo)
|
||||
}
|
||||
|
||||
export function decodeHomeSessionPage(buffer: ArrayBuffer, options?: { directories: string[]; limit: number }) {
|
||||
const text = new TextDecoder().decode(buffer)
|
||||
const page = (text ? JSON.parse(text) : { data: [], cursor: {} }) as V2SessionListResponse
|
||||
const sessions = parseHomeSessionIndex(page.data)
|
||||
if (!options) return { data: sessions, cursor: page.cursor }
|
||||
const directories = new Set(options.directories.map(pathKey))
|
||||
return {
|
||||
data: [...Map.groupBy(sessions, (session) => pathKey(session.directory))]
|
||||
.filter(([directory]) => directories.has(directory))
|
||||
.flatMap(([, items]) => takeRecentSessions(items, options.limit, Number.NEGATIVE_INFINITY)),
|
||||
cursor: page.cursor,
|
||||
}
|
||||
}
|
||||
|
||||
export function legacySessionInfo(session: Session): SessionInfo {
|
||||
return {
|
||||
id: session.id,
|
||||
parentID: session.parentID,
|
||||
projectID: session.projectID,
|
||||
agent: session.agent,
|
||||
model: session.model && {
|
||||
id: session.model.id,
|
||||
providerID: session.model.providerID,
|
||||
variant: session.model.variant,
|
||||
},
|
||||
cost: session.cost ?? 0,
|
||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: session.time,
|
||||
title: session.title,
|
||||
location: { directory: session.directory, workspaceID: session.workspaceID },
|
||||
subpath: session.path,
|
||||
revert: session.revert && {
|
||||
messageID: session.revert.messageID,
|
||||
partID: session.revert.partID,
|
||||
snapshot: session.revert.snapshot,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function compare(a: string, b: string) {
|
||||
return a < b ? -1 : a > b ? 1 : 0
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { DecodedLegacyMessagePage } from "./session-message-decode"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
type Response = { id: number; data?: unknown; error?: string }
|
||||
|
||||
let worker: Worker | undefined
|
||||
let nextID = 0
|
||||
const pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>()
|
||||
|
||||
export function decodeSessionMessages(buffer: ArrayBuffer) {
|
||||
return decode<DecodedLegacyMessagePage>("messages", buffer)
|
||||
}
|
||||
|
||||
export function decodeSessionList(buffer: ArrayBuffer) {
|
||||
return decode<SessionInfo[]>("sessions", buffer)
|
||||
}
|
||||
|
||||
export function decodeHomeSessionPage(buffer: ArrayBuffer, options: { directories: string[]; limit: number }) {
|
||||
return decode<{ data: Session[]; cursor: { next?: string } }>("homeSessions", buffer, options)
|
||||
}
|
||||
|
||||
function decode<T>(
|
||||
type: "messages" | "sessions" | "homeSessions",
|
||||
buffer: ArrayBuffer,
|
||||
options?: { directories: string[]; limit: number },
|
||||
) {
|
||||
const id = ++nextID
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
pending.set(id, { resolve: (value) => resolve(value as T), reject })
|
||||
getWorker().postMessage({ id, type, buffer, options }, [buffer])
|
||||
})
|
||||
}
|
||||
|
||||
function getWorker() {
|
||||
if (worker) return worker
|
||||
worker = new Worker(new URL("./session-message-decoder.worker.ts", import.meta.url), { type: "module" })
|
||||
worker.onmessage = (event: MessageEvent<Response>) => {
|
||||
const request = pending.get(event.data.id)
|
||||
if (!request) return
|
||||
pending.delete(event.data.id)
|
||||
if (event.data.error) {
|
||||
request.reject(new Error(event.data.error))
|
||||
return
|
||||
}
|
||||
request.resolve(event.data.data)
|
||||
}
|
||||
worker.onerror = (event) => {
|
||||
const error = new Error(event.message)
|
||||
pending.forEach((request) => request.reject(error))
|
||||
pending.clear()
|
||||
worker?.terminate()
|
||||
worker = undefined
|
||||
}
|
||||
return worker
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { decodeHomeSessionPage, decodeLegacyMessagePage, decodeLegacySessionList } from "./session-message-decode"
|
||||
|
||||
type DecoderRequest = {
|
||||
id: number
|
||||
type: "messages" | "sessions" | "homeSessions"
|
||||
buffer: ArrayBuffer
|
||||
options?: { directories: string[]; limit: number }
|
||||
}
|
||||
|
||||
self.onmessage = (event: MessageEvent<DecoderRequest>) => {
|
||||
try {
|
||||
self.postMessage({
|
||||
id: event.data.id,
|
||||
data: (() => {
|
||||
if (event.data.type === "messages") return decodeLegacyMessagePage(event.data.buffer)
|
||||
if (event.data.type === "sessions") return decodeLegacySessionList(event.data.buffer)
|
||||
return decodeHomeSessionPage(event.data.buffer, event.data.options)
|
||||
})(),
|
||||
})
|
||||
} catch (error) {
|
||||
self.postMessage({ id: event.data.id, error: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -3,6 +3,10 @@ import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
|
||||
export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
|
||||
export type WorkspaceLastUsed = "local" | "workspace"
|
||||
|
||||
export interface NotificationSettings {
|
||||
agent: boolean
|
||||
@@ -50,6 +54,10 @@ export interface Settings {
|
||||
permissions: {
|
||||
autoApprove: boolean
|
||||
}
|
||||
workspaces: {
|
||||
defaultDestination: WorkspaceDefaultDestination
|
||||
lastUsed: Record<string, WorkspaceLastUsed>
|
||||
}
|
||||
notifications: NotificationSettings
|
||||
sounds: SoundSettings
|
||||
}
|
||||
@@ -206,6 +214,10 @@ const defaultSettings: Settings = {
|
||||
permissions: {
|
||||
autoApprove: false,
|
||||
},
|
||||
workspaces: {
|
||||
defaultDestination: "last-used",
|
||||
lastUsed: {},
|
||||
},
|
||||
notifications: {
|
||||
agent: true,
|
||||
permissions: true,
|
||||
@@ -355,6 +367,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setStore("general", "followup", "steer")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !newLayoutDesigns() || store.general?.showFileTree !== true) return
|
||||
setStore("general", "showFileTree", false)
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
get current() {
|
||||
@@ -453,7 +470,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
fileTree: visible(showFileTree),
|
||||
fileTree: createMemo(() => !newLayoutDesigns()),
|
||||
search: visible(showSearch),
|
||||
status: visible(showStatus),
|
||||
customAgents: visible(showCustomAgents),
|
||||
@@ -499,6 +516,29 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setStore("permissions", "autoApprove", value)
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
defaultDestination: withFallback(
|
||||
() => store.workspaces?.defaultDestination,
|
||||
defaultSettings.workspaces.defaultDestination,
|
||||
),
|
||||
setDefaultDestination(value: WorkspaceDefaultDestination) {
|
||||
setStore("workspaces", (current) => ({
|
||||
...defaultSettings.workspaces,
|
||||
...current,
|
||||
defaultDestination: value,
|
||||
}))
|
||||
},
|
||||
lastUsed(scope: ServerScope, projectID: string) {
|
||||
return store.workspaces?.lastUsed?.[ScopedKey.from(scope, projectID)]
|
||||
},
|
||||
setLastUsed(scope: ServerScope, projectID: string, value: WorkspaceLastUsed) {
|
||||
setStore("workspaces", (current) => ({
|
||||
...defaultSettings.workspaces,
|
||||
...current,
|
||||
lastUsed: { ...current?.lastUsed, [ScopedKey.from(scope, projectID)]: value },
|
||||
}))
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
|
||||
setAgent(value: boolean) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
|
||||
|
||||
function history(): TabHistory {
|
||||
return { stack: [], index: -1 }
|
||||
}
|
||||
|
||||
describe("tab history", () => {
|
||||
test("moves backward and forward through selected tabs", () => {
|
||||
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||
const available = new Set(selected.stack)
|
||||
|
||||
const previous = previousTab(selected, available)
|
||||
expect(previous?.key).toBe("b")
|
||||
|
||||
const first = previousTab(previous!.state, available)
|
||||
expect(first?.key).toBe("a")
|
||||
|
||||
const next = nextTab(first!.state, available)
|
||||
expect(next?.key).toBe("b")
|
||||
})
|
||||
|
||||
test("replaces forward history after a new selection", () => {
|
||||
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||
const previous = previousTab(selected, new Set(selected.stack))
|
||||
const next = rememberTab(previous!.state, "d")
|
||||
|
||||
expect(next).toEqual({ stack: ["a", "b", "d"], index: 2 })
|
||||
expect(nextTab(next, new Set(next.stack))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("skips tabs that are no longer open", () => {
|
||||
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||
|
||||
expect(previousTab(selected, new Set(["a", "c"]))?.key).toBe("a")
|
||||
})
|
||||
|
||||
test("skips a repeated current tab after closing the previous selection", () => {
|
||||
const selected = ["a", "b", "c", "b"].reduce(rememberTab, history())
|
||||
|
||||
expect(previousTab(selected, new Set(["a", "b"]))?.key).toBe("a")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
const MAX_TAB_HISTORY = 100
|
||||
|
||||
export type TabHistory = {
|
||||
stack: string[]
|
||||
index: number
|
||||
}
|
||||
|
||||
export function rememberTab(state: TabHistory, key: string): TabHistory {
|
||||
if (state.stack[state.index] === key) return state
|
||||
const stack = state.stack.slice(0, state.index + 1).concat(key).slice(-MAX_TAB_HISTORY)
|
||||
return { stack, index: stack.length - 1 }
|
||||
}
|
||||
|
||||
export function previousTab(state: TabHistory, available: Set<string>) {
|
||||
return move(state, -1, available)
|
||||
}
|
||||
|
||||
export function nextTab(state: TabHistory, available: Set<string>) {
|
||||
return move(state, 1, available)
|
||||
}
|
||||
|
||||
function move(state: TabHistory, offset: -1 | 1, available: Set<string>) {
|
||||
const current = state.stack[state.index]
|
||||
for (let index = state.index + offset; index >= 0 && index < state.stack.length; index += offset) {
|
||||
const key = state.stack[index]
|
||||
if (key && key !== current && available.has(key)) return { state: { ...state, index }, key }
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { createTabMemory } from "./tab-memory"
|
||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
|
||||
import { migrateTabs } from "./tab-migration"
|
||||
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
|
||||
|
||||
export type SessionTab = {
|
||||
type: "session"
|
||||
@@ -74,6 +75,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const memory = createTabMemory(getOwner())
|
||||
|
||||
const closing = new Set<string>()
|
||||
let history: TabHistory = { stack: [], index: -1 }
|
||||
let recentWrite = 0
|
||||
let recentValue: string | undefined
|
||||
|
||||
@@ -148,10 +150,21 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
|
||||
const navigateTab = (tab: Tab) => {
|
||||
const href = tabHref(tab)
|
||||
history = rememberTab(history, tabKey(tab))
|
||||
setRecentKey(tabKey(tab))
|
||||
navigate(href)
|
||||
}
|
||||
|
||||
const moveHistory = (direction: "previous" | "next") => {
|
||||
const available = new Set(store.map(tabKey))
|
||||
const result = direction === "previous" ? previousTab(history, available) : nextTab(history, available)
|
||||
if (!result) return
|
||||
const tab = store.find((item) => tabKey(item) === result.key)
|
||||
if (!tab) return
|
||||
history = result.state
|
||||
navigateTab(tab)
|
||||
}
|
||||
|
||||
const removeTab = (index: number) => {
|
||||
const tab = store[index]
|
||||
if (!tab) return
|
||||
@@ -177,6 +190,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const actions = {
|
||||
active() {
|
||||
if (location.pathname === "/") return
|
||||
const key = recentKey()
|
||||
return store.find((tab) => tabKey(tab) === key)
|
||||
},
|
||||
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
||||
const next = { type: "session" as const, ...tab }
|
||||
const existing = store.find((item) => tabKey(item) === tabKey(next))
|
||||
@@ -357,8 +375,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
select: navigateTab,
|
||||
remember(tab: Tab) {
|
||||
const key = tabKey(tab)
|
||||
history = rememberTab(history, key)
|
||||
if (recentKey() !== key) setRecentKey(key)
|
||||
},
|
||||
previous: () => moveHistory("previous"),
|
||||
next: () => moveHistory("next"),
|
||||
toggleHome(input: { home: boolean; current?: Tab }) {
|
||||
if (input.home) {
|
||||
const tab = store.find((tab) => tabKey(tab) === recentKey())
|
||||
|
||||
@@ -2,6 +2,28 @@ import { describe, expect, test } from "bun:test"
|
||||
import { DESKTOP_MENU } from "./desktop-menu"
|
||||
|
||||
describe("desktop menu", () => {
|
||||
test("navigates between tabs", () => {
|
||||
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||
(item) =>
|
||||
item.type === "item" && (item.labelKey === "desktop.menu.previousTab" || item.labelKey === "desktop.menu.nextTab"),
|
||||
)
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.previousTab",
|
||||
command: "tab.prev",
|
||||
accelerator: { macos: "Cmd+Alt+Left" },
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.nextTab",
|
||||
command: "tab.next",
|
||||
accelerator: { macos: "Cmd+Alt+Right" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("exports logs through the desktop command registry", () => {
|
||||
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||
(item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs",
|
||||
|
||||
@@ -237,6 +237,19 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
||||
{ type: "item", labelKey: "desktop.menu.back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
|
||||
{ type: "item", labelKey: "desktop.menu.forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.previousTab",
|
||||
command: "tab.prev",
|
||||
accelerator: { macos: "Cmd+Alt+Left" },
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.nextTab",
|
||||
command: "tab.next",
|
||||
accelerator: { macos: "Cmd+Alt+Right" },
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.previousSession",
|
||||
|
||||
@@ -21,6 +21,12 @@ export function useProviders(directory: Accessor<string | undefined>) {
|
||||
const serverSync = useServerSync()
|
||||
const params = useParams()
|
||||
const dir = () => (directory ? directory() : decode64(params.dir))
|
||||
createEffect(() => {
|
||||
const value = dir()
|
||||
if (value) {
|
||||
serverSync().enableProviders(value)
|
||||
}
|
||||
})
|
||||
const providers = () => {
|
||||
const value = dir()
|
||||
const projectStore = value ? serverSync().child(value)[0] : undefined
|
||||
|
||||
@@ -66,6 +66,8 @@ export const DESKTOP_NATIVE_ENGLISH = {
|
||||
"desktop.menu.toggleFullScreen": "Toggle Full Screen",
|
||||
"desktop.menu.back": "Back",
|
||||
"desktop.menu.forward": "Forward",
|
||||
"desktop.menu.previousTab": "Previous Tab",
|
||||
"desktop.menu.nextTab": "Next Tab",
|
||||
"desktop.menu.previousSession": "Previous Session",
|
||||
"desktop.menu.nextSession": "Next Session",
|
||||
"desktop.menu.previousProject": "Previous Project",
|
||||
|
||||
@@ -95,6 +95,8 @@ export const dict = {
|
||||
"command.session.share.description": "Share this session and copy the URL to clipboard",
|
||||
"command.session.unshare": "Unshare session",
|
||||
"command.session.unshare.description": "Stop sharing this session",
|
||||
"command.session.export": "Export session",
|
||||
"command.session.export.description": "Export the full session transcript as JSON",
|
||||
|
||||
"palette.search.placeholder": "Search files, commands, and sessions",
|
||||
"palette.search.placeholder.home": "Search commands and sessions",
|
||||
@@ -489,6 +491,7 @@ export const dict = {
|
||||
|
||||
"context.systemPrompt.title": "System Prompt",
|
||||
"context.rawMessages.title": "Raw messages",
|
||||
"context.export.session": "Export session",
|
||||
|
||||
"context.stats.session": "Session",
|
||||
"context.stats.messages": "Messages",
|
||||
@@ -568,6 +571,11 @@ export const dict = {
|
||||
"toast.session.unshare.failed.title": "Failed to unshare session",
|
||||
"toast.session.unshare.failed.description": "An error occurred while unsharing the session",
|
||||
|
||||
"toast.session.export.success.title": "Session exported",
|
||||
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||
"toast.session.export.failed.title": "Failed to export session",
|
||||
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||
|
||||
"toast.session.listFailed.title": "Failed to load sessions for {{project}}",
|
||||
"toast.project.reloadFailed.title": "Failed to reload {{project}}",
|
||||
|
||||
@@ -802,6 +810,7 @@ export const dict = {
|
||||
"common.moreOptions": "More options",
|
||||
"common.learnMore": "Learn more",
|
||||
"common.rename": "Rename",
|
||||
"common.export": "Export",
|
||||
"common.reset": "Reset",
|
||||
"common.archive": "Archive",
|
||||
"common.delete": "Delete",
|
||||
@@ -1113,6 +1122,46 @@ export const dict = {
|
||||
"session.delete.button": "Delete session",
|
||||
|
||||
"workspace.new": "New workspace",
|
||||
"common.viewAll": "View all",
|
||||
"session.new.workspace.local.tooltip": "Use current checkout",
|
||||
"session.new.workspace.new.tooltip": "Create isolated checkout",
|
||||
"session.new.workspace.fromBranch": "from {{branch}}",
|
||||
"session.new.workspace.trigger.tooltip": "Select where to run session",
|
||||
"session.new.workspace.search.placeholder": "Search workspaces",
|
||||
"settings.tab.workspaces": "Workspaces",
|
||||
"settings.workspaces.filter.all": "All projects",
|
||||
"settings.workspaces.empty": "No workspaces",
|
||||
"settings.workspaces.count.one": "{{count}} workspace",
|
||||
"settings.workspaces.count.other": "{{count}} workspaces",
|
||||
"settings.workspaces.sessions.one": "{{count}} session in {{project}}",
|
||||
"settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
|
||||
"settings.workspaces.lastActiveSession": "Last active session",
|
||||
"settings.workspaces.deleteAll": "Delete all workspaces",
|
||||
"settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?",
|
||||
"settings.workspaces.delete.warning":
|
||||
"The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.",
|
||||
"settings.workspaces.deleteAll.warning":
|
||||
"The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
|
||||
"settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.",
|
||||
"settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.",
|
||||
"settings.workspaces.default.title": "Default environment",
|
||||
"settings.workspaces.default.description": "Choose where new sessions start",
|
||||
"settings.workspaces.default.lastUsed": "Last used per project",
|
||||
"settings.workspaces.default.local": "Local directory",
|
||||
"settings.workspaces.default.new": "New workspace",
|
||||
"workspace.move.title": "Move to workspace",
|
||||
"workspace.move.menu.title": "Move session to",
|
||||
"workspace.move.failed": "Failed to move session",
|
||||
"workspace.lifecycle.creating": "Creating workspace",
|
||||
"workspace.lifecycle.created": "Workspace created",
|
||||
"workspace.lifecycle.starting": "Starting session",
|
||||
"workspace.onboarding.title": "Isolate sessions with workspaces",
|
||||
"workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
|
||||
"workspace.lifecycle.moving": "Moving to workspace",
|
||||
"workspace.lifecycle.set": "Workspace set",
|
||||
"session.summary.title": "Session details",
|
||||
"session.summary.noBranch": "No branch",
|
||||
"session.summary.basedOn": "Based on {{branch}}",
|
||||
"workspace.type.local": "local",
|
||||
"workspace.type.sandbox": "sandbox",
|
||||
"workspace.create.failed.title": "Failed to create workspace",
|
||||
|
||||
@@ -30,17 +30,7 @@ const appLocales = [
|
||||
"sv",
|
||||
] as const
|
||||
const desktopLocales = appLocales
|
||||
const pluralCategories: Partial<Record<(typeof appLocales)[number], readonly string[]>> = {
|
||||
ar: ["zero", "two", "few", "many"],
|
||||
br: ["many"],
|
||||
bs: ["few"],
|
||||
es: ["many"],
|
||||
fr: ["many"],
|
||||
it: ["many"],
|
||||
pl: ["few", "many"],
|
||||
ru: ["few", "many"],
|
||||
uk: ["few", "many"],
|
||||
}
|
||||
const pluralCategories = new Set(["zero", "one", "two", "few", "many", "other"])
|
||||
|
||||
const domains = [
|
||||
{
|
||||
@@ -63,24 +53,20 @@ const domains = [
|
||||
},
|
||||
] as const
|
||||
|
||||
describe.skipIf(!!process.env.CI)("i18n parity", () => {
|
||||
test("non-English locales have every English key and required plural variants", async () => {
|
||||
describe("i18n parity", () => {
|
||||
test("non-English locales contain only English keys and their plural variants", async () => {
|
||||
for (const domain of domains) {
|
||||
const source = await dictionary(domain.source)
|
||||
const families = new Set(pluralFamilies(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))
|
||||
.filter((key) => !Object.hasOwn(source, key) && !isPluralVariant(key, families))
|
||||
.sort()
|
||||
const expected = pluralFamilies(source)
|
||||
.flatMap((key) => (pluralCategories[locale] ?? []).map((category) => `${key}.${category}`))
|
||||
.sort()
|
||||
expect({ domain: domain.name, locale, missing, extra }).toEqual({
|
||||
expect({ domain: domain.name, locale, extra }).toEqual({
|
||||
domain: domain.name,
|
||||
locale,
|
||||
missing: [],
|
||||
extra: expected,
|
||||
extra: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -94,11 +80,11 @@ describe.skipIf(!!process.env.CI)("i18n parity", () => {
|
||||
const mismatched = Object.keys(source).filter(
|
||||
(key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(),
|
||||
)
|
||||
const pluralMismatched = pluralFamilies(source).flatMap((key) =>
|
||||
(pluralCategories[locale] ?? [])
|
||||
.map((category) => `${key}.${category}`)
|
||||
.filter((variant) => placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join()),
|
||||
)
|
||||
const pluralMismatched = Object.keys(target).filter((key) => {
|
||||
const family = pluralFamily(key)
|
||||
if (!family || !Object.hasOwn(source, `${family}.other`)) return false
|
||||
return placeholders(source[`${family}.other`]).join() !== placeholders(target[key]).join()
|
||||
})
|
||||
expect({ domain: domain.name, locale, mismatched, pluralMismatched }).toEqual({
|
||||
domain: domain.name,
|
||||
locale,
|
||||
@@ -136,38 +122,6 @@ describe.skipIf(!!process.env.CI)("i18n parity", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("i18n plural parity", () => {
|
||||
test("locale-specific categories exist and preserve count placeholders", async () => {
|
||||
for (const domain of domains.slice(0, 2)) {
|
||||
const source = await dictionary(domain.source)
|
||||
const families = pluralFamilies(source)
|
||||
for (const locale of domain.locales) {
|
||||
const target = await dictionary(domain.target(locale))
|
||||
const missing = families.flatMap((key) =>
|
||||
(pluralCategories[locale] ?? [])
|
||||
.map((category) => `${key}.${category}`)
|
||||
.filter((variant) => !Object.hasOwn(target, variant)),
|
||||
)
|
||||
const mismatched = families.flatMap((key) =>
|
||||
(pluralCategories[locale] ?? [])
|
||||
.map((category) => `${key}.${category}`)
|
||||
.filter(
|
||||
(variant) =>
|
||||
Object.hasOwn(target, variant) &&
|
||||
placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join(),
|
||||
),
|
||||
)
|
||||
expect({ domain: domain.name, locale, missing, mismatched }).toEqual({
|
||||
domain: domain.name,
|
||||
locale,
|
||||
missing: [],
|
||||
mismatched: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function dictionary(file: string) {
|
||||
const module: unknown = await import(file)
|
||||
if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) {
|
||||
@@ -187,11 +141,17 @@ function placeholders(value: string) {
|
||||
|
||||
function pluralFamilies(dictionary: Record<string, string>) {
|
||||
return Object.keys(dictionary)
|
||||
.filter(
|
||||
(key) =>
|
||||
key.endsWith(".one") &&
|
||||
dictionary[key].includes("{{count}}") &&
|
||||
dictionary[`${key.slice(0, -4)}.other`]?.includes("{{count}}"),
|
||||
)
|
||||
.filter((key) => key.endsWith(".one") && Object.hasOwn(dictionary, `${key.slice(0, -4)}.other`))
|
||||
.map((key) => key.slice(0, -4))
|
||||
}
|
||||
|
||||
function pluralFamily(key: string) {
|
||||
const split = key.lastIndexOf(".")
|
||||
if (split === -1 || !pluralCategories.has(key.slice(split + 1))) return
|
||||
return key.slice(0, split)
|
||||
}
|
||||
|
||||
function isPluralVariant(key: string, families: Set<string>) {
|
||||
const family = pluralFamily(key)
|
||||
return family !== undefined && families.has(family)
|
||||
}
|
||||
|
||||
@@ -327,4 +327,9 @@
|
||||
animation-range: 0 0.1px;
|
||||
}
|
||||
}
|
||||
|
||||
body[data-new-layout] [data-slot="session-turn-diffs-header"] {
|
||||
height: 24px;
|
||||
padding-block: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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 { useQuery } from "@tanstack/solid-query"
|
||||
import { DateTime } from "luxon"
|
||||
import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js"
|
||||
import { type Accessor, createEffect, createMemo, createSignal, onCleanup, type JSX, startTransition } from "solid-js"
|
||||
import { produce } from "solid-js/store"
|
||||
import { useCommand } from "@/context/command"
|
||||
import {
|
||||
loadHomeSessionIndex,
|
||||
retainHomeSessions,
|
||||
loadProjectedHomeSessionIndex,
|
||||
type HomeSessionEvents,
|
||||
} from "@/context/global-sync/home-session-index"
|
||||
import { takeRecentSessions } from "@/context/global-sync/session-trim"
|
||||
import { decodeHomeSessionPage } from "@/context/session-message-decoder"
|
||||
import type { LocalProject } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
@@ -19,11 +19,13 @@ import { displayName, errorMessage, projectForSession } from "@/pages/layout/hel
|
||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
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
|
||||
const HOME_SESSION_RENDER_BATCH = 4
|
||||
export type HomeSessionRecord = {
|
||||
session: Session
|
||||
project: LocalProject
|
||||
@@ -66,8 +68,17 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
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),
|
||||
const index = await loadProjectedHomeSessionIndex(
|
||||
async (input, options) => {
|
||||
const response = await ctx.sdk.client.v2.session.list(input, { ...options, parseAs: "arrayBuffer" })
|
||||
if (!(response.data instanceof ArrayBuffer)) throw new Error("Home session response is not an ArrayBuffer")
|
||||
return {
|
||||
data: await decodeHomeSessionPage(response.data, {
|
||||
directories: projectDirectories(),
|
||||
limit: HOME_SESSION_LIMIT,
|
||||
}),
|
||||
}
|
||||
},
|
||||
eventSequence,
|
||||
signal,
|
||||
)
|
||||
@@ -79,13 +90,16 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
}))
|
||||
const indexedSessions = createMemo(() =>
|
||||
retainHomeSessions(
|
||||
homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
|
||||
const indexedSessions = createMemo(() => {
|
||||
const directories = new Set(projectDirectories().map(pathKey))
|
||||
return takeRecentSessions(
|
||||
homeSessions()
|
||||
.sessions(sessionLoad.data, sessionEventLoad.data)
|
||||
.filter((session) => directories.has(pathKey(session.directory))),
|
||||
HOME_SESSION_LIMIT,
|
||||
Date.now(),
|
||||
),
|
||||
)
|
||||
Number.NEGATIVE_INFINITY,
|
||||
)
|
||||
})
|
||||
const allRecords = createMemo(() =>
|
||||
buildHomeSessionRecords({
|
||||
sessions: indexedSessions,
|
||||
@@ -94,43 +108,21 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
projectByID,
|
||||
}),
|
||||
)
|
||||
const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
|
||||
const groups = createMemo(() => groupSessions(records(), language))
|
||||
const prefetched = new Set<string>()
|
||||
|
||||
const [visible, setVisible] = createSignal(HOME_SESSION_RENDER_BATCH)
|
||||
let revealFrame: number | undefined
|
||||
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)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(dispose)
|
||||
} catch {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
const count = Math.min(allRecords().length, HOME_SESSION_LIMIT)
|
||||
if (visible() >= count || revealFrame !== undefined) return
|
||||
revealFrame = requestAnimationFrame(() => {
|
||||
revealFrame = undefined
|
||||
setVisible((current) => Math.min(current + HOME_SESSION_RENDER_BATCH, count))
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (revealFrame !== undefined) cancelAnimationFrame(revealFrame)
|
||||
})
|
||||
const records = createMemo(() => allRecords().slice(0, visible()))
|
||||
const groups = createMemo(() => groupSessions(records(), language))
|
||||
command.register("home.palette", () => [
|
||||
{
|
||||
id: "command.palette",
|
||||
@@ -208,6 +200,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const conn = home.server.focused()
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!conn || !ctx) return
|
||||
if (WorkspaceOperation.get(ctx.sdk.scope, session.id)?.status === "pending") return
|
||||
const [, setStore] = ctx.sync.child(session.directory)
|
||||
if ((await ctx.sdk.protocol) !== "v1") return
|
||||
await archiveHomeSession({
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||
import { createEffect, lazy, Suspense, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { TabsInfoPopup } from "@/components/help-button"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import type { TitlebarUpdate } from "@/components/titlebar"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||
|
||||
const Titlebar = lazy(() => import("@/components/titlebar").then((module) => ({ default: module.Titlebar })))
|
||||
const TabsInfoPopup = lazy(() =>
|
||||
import("@/components/help-button").then((module) => ({ default: module.TabsInfoPopup })),
|
||||
)
|
||||
|
||||
export default function NewLayout(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
const [state, setState] = createStore({ debugTools: true })
|
||||
@@ -30,19 +34,23 @@ export default function NewLayout(props: ParentProps) {
|
||||
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
|
||||
}}
|
||||
>
|
||||
<Titlebar
|
||||
update={update}
|
||||
debugTools={
|
||||
import.meta.env.DEV
|
||||
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Suspense fallback={<div class="h-10 shrink-0" />}>
|
||||
<Titlebar
|
||||
update={update}
|
||||
debugTools={
|
||||
import.meta.env.DEV
|
||||
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</main>
|
||||
{import.meta.env.DEV && state.debugTools && <DebugBar inline />}
|
||||
<TabsInfoPopup />
|
||||
<Suspense>
|
||||
<TabsInfoPopup />
|
||||
</Suspense>
|
||||
<ToastRegion v2 />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -60,6 +60,7 @@ import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useLanguage, type Locale } from "@/context/language"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import {
|
||||
displayName,
|
||||
effectiveWorkspaceOrder,
|
||||
@@ -634,7 +635,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
running: number
|
||||
}
|
||||
|
||||
const prefetchChunk = 200
|
||||
const prefetchChunk = 50
|
||||
const prefetchConcurrency = 2
|
||||
const prefetchPendingLimit = 10
|
||||
const span = 4
|
||||
@@ -777,18 +778,11 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (params.id) return
|
||||
const sessions = currentSessions()
|
||||
if (sessions.length === 0) return
|
||||
|
||||
const index = params.id ? sessions.findIndex((s) => s.id === params.id) : 0
|
||||
if (index === -1) return
|
||||
|
||||
if (!params.id) {
|
||||
const first = sessions[index]
|
||||
if (first) prefetchSession(first, "high")
|
||||
}
|
||||
|
||||
warm(sessions, index)
|
||||
const first = sessions[0]
|
||||
if (first) prefetchSession(first, "high")
|
||||
})
|
||||
|
||||
function navigateSessionByOffset(offset: number) {
|
||||
@@ -869,6 +863,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
}
|
||||
|
||||
async function archiveSession(session: Session) {
|
||||
if (WorkspaceOperation.get(serverSDK().scope, session.id)?.status === "pending") return
|
||||
if ((await serverSDK().protocol) !== "v1") return
|
||||
const [store, setStore] = serverSync().child(session.directory)
|
||||
const sessions = store.session ?? []
|
||||
@@ -1117,7 +1112,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
: import("@/components/dialog-settings")
|
||||
void module.then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSettings />)
|
||||
dialog.show(() => <x.DialogSettings sessionID={params.id} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createPromptProjectController } from "@/components/prompt-project-selector"
|
||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { createEffect, createResource } from "solid-js"
|
||||
import { useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createNewSessionDraftController } from "./new-session/new-session-draft-controller"
|
||||
import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view"
|
||||
import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller"
|
||||
@@ -11,10 +14,23 @@ import { useNewSessionCommands } from "./new-session/use-new-session-commands"
|
||||
export default function NewSessionPage() {
|
||||
const settings = useSettings()
|
||||
const rightMount = useTitlebarRightMount()
|
||||
const workspace = createNewSessionWorkspaceController()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
const openWorkspaces = useSettingsDialog("workspaces")
|
||||
const draftTab = createMemo(() =>
|
||||
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
|
||||
)
|
||||
const workspace = createNewSessionWorkspaceController({
|
||||
selected: () => draftTab()?.worktree,
|
||||
setSelected: (worktree) => {
|
||||
if (search.draftId) tabs.updateDraft(search.draftId, { worktree })
|
||||
},
|
||||
onViewAll: openWorkspaces,
|
||||
})
|
||||
const draft = createNewSessionDraftController({
|
||||
worktree: workspace.selection.value,
|
||||
resetWorktree: workspace.selection.reset,
|
||||
onSubmit: workspace.selection.remember,
|
||||
})
|
||||
const project = createPromptProjectController({
|
||||
controls: draft.project.controls,
|
||||
|
||||
@@ -10,7 +10,11 @@ import { createPromptModelSelection } from "@/pages/session/composer/prompt-mode
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
|
||||
export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) {
|
||||
export function createNewSessionDraftController(workspace: {
|
||||
worktree: () => string
|
||||
resetWorktree: () => void
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
const prompt = usePrompt()
|
||||
const serverSync = useServerSync()
|
||||
const comments = useComments()
|
||||
@@ -36,7 +40,10 @@ export function createNewSessionDraftController(workspace: { worktree: () => str
|
||||
return workspace.worktree()
|
||||
},
|
||||
onNewSessionWorktreeReset: workspace.resetWorktree,
|
||||
onSubmit: comments.clear,
|
||||
onSubmit: () => {
|
||||
workspace.onSubmit()
|
||||
comments.clear()
|
||||
},
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
|
||||
import { Show, createMemo, createSignal, type Accessor } from "solid-js"
|
||||
@@ -31,6 +31,15 @@ export function NewSessionView(props: {
|
||||
project: PromptProjectController
|
||||
workspace: NewSessionWorkspaceController
|
||||
}) {
|
||||
const [onboarding, setOnboarding, , onboardingReady] = persisted(
|
||||
Persist.global("workspace-onboarding"),
|
||||
createStore({ used: false }),
|
||||
)
|
||||
const select = (value: string) => {
|
||||
props.workspace.selection.set(value)
|
||||
if (value !== "main") setOnboarding("used", true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
||||
<div
|
||||
@@ -59,8 +68,10 @@ export function NewSessionView(props: {
|
||||
projectRoot={props.workspace.project.root()}
|
||||
workspaces={props.workspace.project.workspaces()}
|
||||
branch={props.workspace.bar.branch()}
|
||||
onChange={props.workspace.selection.set}
|
||||
onboarding={onboardingReady() && !onboarding.used}
|
||||
onChange={select}
|
||||
onDone={props.input.restoreFocus}
|
||||
onViewAll={props.workspace.project.openAll}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -137,13 +148,13 @@ function ProviderTip() {
|
||||
>
|
||||
<span class="truncate">{language.t("home.providerTip")}</span>
|
||||
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
|
||||
<IconV2 name="chevron-down" size="small" class="-rotate-90" />
|
||||
<Icon name="chevron-down" size="small" class="-rotate-90" />
|
||||
</span>
|
||||
</button>
|
||||
<TooltipV2
|
||||
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
|
||||
placement="top"
|
||||
openDelay={1000}
|
||||
delay="intent"
|
||||
value={language.t("common.dismiss")}
|
||||
>
|
||||
<button
|
||||
@@ -152,7 +163,7 @@ function ProviderTip() {
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={() => setPersistedState("dismissedAt", Date.now())}
|
||||
>
|
||||
<IconV2 name="xmark-small" />
|
||||
<Icon name="xmark-small" />
|
||||
</button>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { createMemo } from "solid-js"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
|
||||
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { isWorkspaceSelection, workspaceDefaultSelection, workspaceDirectories } from "@/utils/workspace"
|
||||
|
||||
export function resolveNewSessionWorktree(input: {
|
||||
enabled: boolean
|
||||
selected?: string
|
||||
directory: string
|
||||
projectWorktree?: string
|
||||
fallback?: string
|
||||
}) {
|
||||
if (!input.enabled) return "main"
|
||||
if (input.selected) return input.selected
|
||||
if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory
|
||||
return "main"
|
||||
return input.fallback ?? "main"
|
||||
}
|
||||
|
||||
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
|
||||
@@ -31,18 +34,38 @@ export function resolveNewSessionBranch(input: {
|
||||
return input.worktreeBranch(input.worktree) ?? input.local
|
||||
}
|
||||
|
||||
export function createNewSessionWorkspaceController() {
|
||||
export function createNewSessionWorkspaceController(input: {
|
||||
selected: () => string | undefined
|
||||
setSelected: (worktree: string | undefined) => void
|
||||
onViewAll: () => void
|
||||
}) {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const [worktree, setWorktree] = createSignal<string>()
|
||||
const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
|
||||
const settings = useSettings()
|
||||
const visible = createMemo(() => sync().project?.vcs === "git")
|
||||
const selected = createMemo(() => {
|
||||
const project = sync().project
|
||||
const worktree = input.selected()
|
||||
if (!project || !worktree) return
|
||||
return isWorkspaceSelection(project, worktree) ? worktree : undefined
|
||||
})
|
||||
const fallback = createMemo(() => {
|
||||
const project = sync().project
|
||||
if (!project) return "main"
|
||||
return workspaceDefaultSelection(
|
||||
settings.workspaces.defaultDestination(),
|
||||
settings.workspaces.lastUsed(serverSDK().scope, project.id),
|
||||
)
|
||||
})
|
||||
const value = createMemo(() =>
|
||||
resolveNewSessionWorktree({
|
||||
enabled: visible(),
|
||||
selected: worktree(),
|
||||
selected: selected(),
|
||||
directory: sdk().directory,
|
||||
projectWorktree: sync().project?.worktree,
|
||||
fallback: fallback(),
|
||||
}),
|
||||
)
|
||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||
@@ -54,18 +77,31 @@ export function createNewSessionWorkspaceController() {
|
||||
worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch,
|
||||
}),
|
||||
)
|
||||
const remember = (worktree = value()) => {
|
||||
const project = sync().project
|
||||
if (!project) return
|
||||
const local = worktree === "main" || pathKey(worktree) === pathKey(project.worktree)
|
||||
settings.workspaces.setLastUsed(serverSDK().scope, project.id, local ? "local" : "workspace")
|
||||
}
|
||||
|
||||
return {
|
||||
selection: {
|
||||
value,
|
||||
reset: () => setWorktree(),
|
||||
set: (worktree: string) =>
|
||||
setWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)),
|
||||
reset: () => input.setSelected(undefined),
|
||||
remember,
|
||||
set: (worktree: string) => {
|
||||
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree))
|
||||
remember(worktree)
|
||||
},
|
||||
},
|
||||
project: {
|
||||
root: projectRoot,
|
||||
workspaces: () => sync().project?.sandboxes ?? [],
|
||||
workspaces: () => {
|
||||
const project = sync().project
|
||||
return project ? workspaceDirectories(project) : []
|
||||
},
|
||||
git: () => sync().project?.vcs === "git",
|
||||
openAll: input.onViewAll,
|
||||
},
|
||||
bar: {
|
||||
visible,
|
||||
|
||||
@@ -100,6 +100,7 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { canMoveSessionToWorkspace, WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||
import { createSessionOwnership } from "./session/session-ownership"
|
||||
import { createSessionLineage } from "./session/session-lineage"
|
||||
@@ -600,6 +601,7 @@ export default function Page() {
|
||||
const [store, setStore] = createStore({
|
||||
...sessionViewState(),
|
||||
newSessionWorktree: "main",
|
||||
sessionDetailsOpen: false,
|
||||
deferRender: false,
|
||||
})
|
||||
|
||||
@@ -690,8 +692,12 @@ export default function Page() {
|
||||
queryFn: mode
|
||||
? () =>
|
||||
sdk()
|
||||
.api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode })
|
||||
.then((result) => result.data)
|
||||
.api.vcs.diff({
|
||||
location: { directory: sdk().directory },
|
||||
mode: mode === "git" ? "working" : mode,
|
||||
context: 0,
|
||||
})
|
||||
.then((result) => result.data.map((diff) => ({ ...diff, patch: "" })))
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||
return []
|
||||
@@ -699,6 +705,19 @@ export default function Page() {
|
||||
: skipToken,
|
||||
}
|
||||
})
|
||||
const sessionDetailsQuery = createQuery(() => ({
|
||||
queryKey: [...vcsKey(), "git"] as const,
|
||||
enabled: store.sessionDetailsOpen && sync().project?.vcs === "git",
|
||||
queryFn: () =>
|
||||
sdk()
|
||||
.api.vcs.diff({ location: { directory: sdk().directory }, mode: "working" })
|
||||
.then((result) => result.data)
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to load session details diff", { error })
|
||||
return []
|
||||
}),
|
||||
}))
|
||||
const sessionDetailsDiffs = () => (sessionDetailsQuery.isFetched ? (sessionDetailsQuery.data ?? []) : [])
|
||||
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
|
||||
const reviewDiffs = () => {
|
||||
if (reviewMode() === "git" || reviewMode() === "branch")
|
||||
@@ -1701,6 +1720,8 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const busy = (sessionID: string) => sync().data.session_working(sessionID)
|
||||
const workspaceOperationPending = (sessionID: string) =>
|
||||
WorkspaceOperation.get(serverSDK().scope, sessionID)?.status === "pending"
|
||||
|
||||
const queuedFollowups = createMemo(() => {
|
||||
const id = params.id
|
||||
@@ -1714,8 +1735,20 @@ export default function Page() {
|
||||
return followup.edit[id]
|
||||
})
|
||||
|
||||
const workspaceMoveEligible = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return canMoveSessionToWorkspace({
|
||||
queued: followup.items[id]?.length ?? 0,
|
||||
failed: !!followup.failed[id],
|
||||
paused: !!followup.paused[id],
|
||||
editing: !!followup.edit[id],
|
||||
})
|
||||
})
|
||||
|
||||
const followupMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
||||
if (workspaceOperationPending(input.sessionID)) return
|
||||
const owner = sessionOwnership.capture()
|
||||
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
|
||||
if (!item) return
|
||||
@@ -1725,6 +1758,7 @@ export default function Page() {
|
||||
|
||||
const ok = await sendFollowupDraft({
|
||||
api: sdk().api.session,
|
||||
scope: serverSDK().scope,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft: item,
|
||||
@@ -1787,6 +1821,7 @@ export default function Page() {
|
||||
|
||||
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
||||
if (sync().session.get(sessionID)?.parentID) return Promise.resolve()
|
||||
if (workspaceOperationPending(sessionID)) return Promise.resolve()
|
||||
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
||||
if (!item) return Promise.resolve()
|
||||
if (followupBusy(sessionID)) return Promise.resolve()
|
||||
@@ -1826,6 +1861,7 @@ export default function Page() {
|
||||
|
||||
const revertMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||
if (workspaceOperationPending(input.sessionID)) return
|
||||
const session = sdk().api.session
|
||||
const target = sync()
|
||||
const last = target.session.get(input.sessionID)?.revert
|
||||
@@ -1848,6 +1884,7 @@ export default function Page() {
|
||||
mutationFn: async (id: string) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
|
||||
const session = sdk().api.session
|
||||
const target = sync()
|
||||
@@ -1875,7 +1912,10 @@ export default function Page() {
|
||||
},
|
||||
}))
|
||||
|
||||
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
|
||||
const reverting = createMemo(() => {
|
||||
const id = params.id
|
||||
return revertMutation.isPending || restoreMutation.isPending || (!!id && workspaceOperationPending(id))
|
||||
})
|
||||
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
|
||||
|
||||
const revert = (input: { sessionID: string; messageID: string }) => {
|
||||
@@ -1933,6 +1973,7 @@ export default function Page() {
|
||||
if (isChildSession()) return
|
||||
if (composer.blocked()) return
|
||||
if (busy(sessionID)) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
|
||||
void sendFollowup(sessionID, item.id)
|
||||
})
|
||||
@@ -2040,7 +2081,7 @@ export default function Page() {
|
||||
>
|
||||
{hasReview()
|
||||
? language.t("session.review.filesChanged", { count: reviewCount() })
|
||||
: language.t("session.review.change.other")}
|
||||
: language.plural("session.review.change", 0)}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
@@ -2103,6 +2144,9 @@ export default function Page() {
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
userMessages={visibleUserMessages()}
|
||||
diffs={sessionDetailsDiffs}
|
||||
workspaceMoveEligible={workspaceMoveEligible()}
|
||||
onSummaryOpenChange={(open) => setStore("sessionDetailsOpen", open)}
|
||||
setHistoryAnchor={(handlers) => {
|
||||
captureHistoryAnchor = handlers.capture
|
||||
restoreHistoryAnchor = handlers.restore
|
||||
|
||||
@@ -91,7 +91,7 @@ export function createPromptProjectControls() {
|
||||
const target = global.ensureServerCtx(conn)
|
||||
target.projects.open(worktree)
|
||||
target.projects.touch(worktree)
|
||||
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
|
||||
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree, worktree: undefined })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -783,10 +783,7 @@ export function SessionSidePanel(props: {
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={
|
||||
<>
|
||||
{props.reviewCount()}{" "}
|
||||
{language.t(
|
||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||
)}
|
||||
{props.reviewCount()} {language.plural("session.review.change", props.reviewCount())}
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type UserActions,
|
||||
} from "@opencode-ai/session-ui/message-part"
|
||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { DiffChanges as DiffChangesV2 } from "@opencode-ai/ui/v2/diff-changes-v2"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -35,6 +36,8 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
@@ -49,10 +52,12 @@ import type {
|
||||
AssistantMessage,
|
||||
Message as MessageType,
|
||||
Part as PartType,
|
||||
Project,
|
||||
ToolPart,
|
||||
UserMessage,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||
import { normalize } from "@opencode-ai/session-ui/session-diff"
|
||||
@@ -63,6 +68,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
@@ -76,6 +82,12 @@ import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||
import { filterVirtualIndexes } from "./virtual-items"
|
||||
import { isWorkspaceDirectory } from "@/utils/workspace"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { SessionWorkspaceMenu } from "@/components/session-workspace-menu"
|
||||
import { getProjectAvatarVariant } from "@/context/layout"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
const emptyParts: PartType[] = []
|
||||
@@ -141,7 +153,7 @@ function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSu
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
|
||||
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[]; action?: JSX.Element }) {
|
||||
const language = useLanguage()
|
||||
const maxFiles = 10
|
||||
const [state, setState] = createStore({
|
||||
@@ -169,6 +181,7 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
|
||||
{showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")}
|
||||
</span>
|
||||
</Show>
|
||||
{props.action}
|
||||
</div>
|
||||
<div data-component="session-turn-diffs-content">
|
||||
<Accordion
|
||||
@@ -223,6 +236,178 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceLocationLoader() {
|
||||
const dots = ["left-0 top-0", "right-0 top-0", "left-0 bottom-0", "right-0 bottom-0"]
|
||||
return (
|
||||
<span data-component="workspace-location-loader" class="relative block size-4" aria-hidden="true">
|
||||
<span class="absolute left-[7px] top-[7px] size-0.5 bg-current" />
|
||||
<For each={dots}>
|
||||
{(position, index) => (
|
||||
<span
|
||||
class={`absolute size-1 bg-current ${position} animate-pulse`}
|
||||
style={{ "animation-delay": `${index() * -180}ms` }}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceMoveAction(props: {
|
||||
variant: "inline" | "panel"
|
||||
eligible: boolean
|
||||
sessionID: string
|
||||
project: Project
|
||||
directory: string
|
||||
messageID?: string
|
||||
dismissed: boolean
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const inline = () => props.variant === "inline"
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"group/workspace-move relative shrink-0": true,
|
||||
"ml-auto h-5 w-[167px]": inline(),
|
||||
"-mt-2.5 h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03 transition-colors":
|
||||
!inline(),
|
||||
invisible: props.dismissed,
|
||||
}}
|
||||
>
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.eligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
messageID={props.messageID}
|
||||
placement={inline() ? "bottom-end" : "left-start"}
|
||||
gutter={inline() ? 4 : -22}
|
||||
contentClass={inline() ? undefined : "relative top-3.5"}
|
||||
class={
|
||||
inline()
|
||||
? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pr-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
|
||||
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pr-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
|
||||
}
|
||||
>
|
||||
<IconV2 name="workspace-new" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
|
||||
</SessionWorkspaceMenu>
|
||||
<button
|
||||
type="button"
|
||||
class={`absolute flex size-5 -translate-y-1/2 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none ${
|
||||
inline()
|
||||
? "right-0 top-1/2"
|
||||
: "hover-reveal right-3 top-[calc(50%+5px)] group-hover/workspace-move:opacity-100 group-focus-within/workspace-move:opacity-100"
|
||||
}`}
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onDismiss()
|
||||
}}
|
||||
>
|
||||
<IconV2 name="xmark-small" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionSummaryPanel(props: {
|
||||
project: Project
|
||||
directory: string
|
||||
local: boolean
|
||||
branch?: string
|
||||
baseBranch?: string
|
||||
diffs: { additions: number; deletions: number }[]
|
||||
sessionID: string
|
||||
moveEligible: boolean
|
||||
messageID?: string
|
||||
moveDismissed: boolean
|
||||
onMoveDismiss: () => void
|
||||
onReview: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const location = () => (props.local ? language.t("session.new.workspace.local") : getFilename(props.directory))
|
||||
const branch = () => props.branch ?? props.baseBranch
|
||||
const row =
|
||||
"flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
|
||||
|
||||
return (
|
||||
<div data-component="session-summary-panel" class="w-[280px]">
|
||||
<div class="relative z-10 flex flex-col gap-1 overflow-hidden rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
|
||||
<div class={row}>
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-v2-text-text-muted">{displayName(props.project)}</span>
|
||||
</div>
|
||||
<SessionWorkspaceMenu
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
messageID={props.messageID}
|
||||
placement="left-start"
|
||||
gutter={-22}
|
||||
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
|
||||
>
|
||||
<IconV2 name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 flex-1 truncate text-left">{location()}</span>
|
||||
<IconV2 name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</SessionWorkspaceMenu>
|
||||
<div class={row}>
|
||||
<IconV2 name="branch" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Show
|
||||
when={props.branch}
|
||||
fallback={
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<span>{language.t("session.summary.noBranch")}</span>
|
||||
<Show when={props.baseBranch}>
|
||||
{(base) => (
|
||||
<>
|
||||
<span class="text-v2-text-text-muted">·</span>
|
||||
<span class="truncate text-v2-text-text-faint">
|
||||
{language.t("session.summary.basedOn", { branch: base() })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span class="min-w-0 truncate">{branch()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none`}
|
||||
onClick={props.onReview}
|
||||
>
|
||||
<IconV2 name="review" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Show when={props.diffs.length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
|
||||
<span>{language.plural("ui.sessionTurn.diffs.changed", props.diffs.length)}</span>
|
||||
<span class="text-v2-text-text-muted">·</span>
|
||||
<DiffChangesV2 changes={props.diffs} />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
<Show when={props.local && props.diffs.length > 0 && props.moveEligible}>
|
||||
<WorkspaceMoveAction
|
||||
variant="panel"
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
messageID={props.messageID}
|
||||
dismissed={props.moveDismissed}
|
||||
onDismiss={props.onMoveDismiss}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineDiffView(props: { diff: SummaryDiff }) {
|
||||
const fileComponent = useFileComponent()
|
||||
const view = normalize(props.diff)
|
||||
@@ -250,6 +435,9 @@ export function MessageTimeline(props: {
|
||||
centered: boolean
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
userMessages: UserMessage[]
|
||||
diffs: Accessor<{ additions: number; deletions: number }[]>
|
||||
workspaceMoveEligible: boolean
|
||||
onSummaryOpenChange: (open: boolean) => void
|
||||
anchor: (id: string) => string
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
@@ -259,12 +447,14 @@ export function MessageTimeline(props: {
|
||||
|
||||
const navigate = useNavigate()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const { params, sessionKey } = useSessionKey()
|
||||
const ownerSessionKey = sessionKey()
|
||||
const cached = timelineCache.get(ownerSessionKey)
|
||||
@@ -295,6 +485,43 @@ export function MessageTimeline(props: {
|
||||
})
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||
const sessionDirectory = createMemo(() => info()?.directory ?? sdk().directory)
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory()))
|
||||
const [workspaceSuggestionDismissed, setWorkspaceSuggestionDismissed] = createSignal(false)
|
||||
const [summaryOpen, setSummaryOpen] = createSignal(false)
|
||||
const setSummary = (open: boolean) => {
|
||||
setSummaryOpen(open)
|
||||
props.onSummaryOpenChange(open)
|
||||
}
|
||||
const sessionDiffs = createMemo(props.diffs)
|
||||
createEffect(
|
||||
on(sessionID, () => {
|
||||
setSummary(false)
|
||||
setWorkspaceSuggestionDismissed(false)
|
||||
}),
|
||||
)
|
||||
const turnPadding = () => "px-4 md:px-5"
|
||||
const workspaceOperation = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
return WorkspaceOperation.get(serverSDK().scope, id)
|
||||
})
|
||||
const lifecycleTitle = createMemo(() => {
|
||||
const operation = workspaceOperation()
|
||||
if (operation?.status === "pending") {
|
||||
return {
|
||||
kind: "pending" as const,
|
||||
text: language.t(operation.type === "create" ? "workspace.lifecycle.creating" : "workspace.lifecycle.moving"),
|
||||
}
|
||||
}
|
||||
if (operation?.type === "create" && titleValue()?.startsWith("New session"))
|
||||
return { kind: "created" as const, text: language.t("workspace.lifecycle.created") }
|
||||
if (titleValue()?.startsWith("New session"))
|
||||
return { kind: "starting" as const, text: language.t("workspace.lifecycle.starting") }
|
||||
return
|
||||
})
|
||||
const workspaceOperationPending = (sessionID: string) =>
|
||||
WorkspaceOperation.get(serverSDK().scope, sessionID)?.status === "pending"
|
||||
const shareUrl = createMemo(() => info()?.share?.url)
|
||||
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
@@ -326,7 +553,7 @@ export function MessageTimeline(props: {
|
||||
if (value) return value
|
||||
return language.t("command.session.new")
|
||||
})
|
||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||
const showHeader = createMemo(() => !!(titleValue() || parentID() || workspaceSession()))
|
||||
const projection = createTimelineProjection({
|
||||
messages: sessionMessages,
|
||||
userMessages: () => props.userMessages,
|
||||
@@ -335,6 +562,19 @@ export function MessageTimeline(props: {
|
||||
status: sessionStatus,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
inlineComments: settings.general.newLayoutDesigns,
|
||||
extensionRevision: workspaceOperation,
|
||||
afterUser: (message) => {
|
||||
const operation = workspaceOperation()
|
||||
if (!operation) return []
|
||||
if (operation.messageID !== message.id && (operation.messageID || message.id !== props.userMessages.at(-1)?.id))
|
||||
return []
|
||||
return [
|
||||
new TimelineRow.WorkspaceLifecycle({
|
||||
userMessageID: message.id,
|
||||
notice: { type: "operation", operation },
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
const activeMessageID = projection.activeMessageID
|
||||
const assistantMessagesByParent = projection.assistantMessagesByParent
|
||||
@@ -806,7 +1046,31 @@ export function MessageTimeline(props: {
|
||||
navigate(`/${params.dir}/session`)
|
||||
}
|
||||
|
||||
const exportSession = async (sessionID: string) => {
|
||||
try {
|
||||
const data = await fetchSessionExport({
|
||||
sessionID,
|
||||
client: sdk().client,
|
||||
})
|
||||
const filename = sessionExportFilename(data.info)
|
||||
downloadSessionExport(filename, data)
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("toast.session.export.success.title"),
|
||||
description: language.t("toast.session.export.success.description", { filename }),
|
||||
})
|
||||
} catch (err) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.export.failed.title"),
|
||||
description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const archiveSession = async (sessionID: string) => {
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
const session = sync().session.get(sessionID)
|
||||
if (!session) return
|
||||
if ((await sdk().protocol) !== "v1") return
|
||||
@@ -837,6 +1101,7 @@ export function MessageTimeline(props: {
|
||||
}
|
||||
|
||||
const deleteSession = async (sessionID: string) => {
|
||||
if (workspaceOperationPending(sessionID)) return false
|
||||
const session = sync().session.get(sessionID)
|
||||
if (!session) return false
|
||||
|
||||
@@ -930,7 +1195,7 @@ export function MessageTimeline(props: {
|
||||
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 variant="danger" onClick={handleDelete}>
|
||||
<ButtonV2 variant="danger" disabled={workspaceOperationPending(props.sessionID)} onClick={handleDelete}>
|
||||
{language.t("session.delete.button")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
@@ -949,7 +1214,12 @@ export function MessageTimeline(props: {
|
||||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={handleDelete}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
disabled={workspaceOperationPending(props.sessionID)}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{language.t("session.delete.button")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1102,7 +1372,7 @@ export function MessageTimeline(props: {
|
||||
)
|
||||
return (
|
||||
<TimelineRowFrame row={commentStripRow}>
|
||||
<div class="w-full px-4 md:px-5 pb-2">
|
||||
<div class={`w-full pb-2 ${turnPadding()}`}>
|
||||
<div class="ms-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
<Index each={comments()}>
|
||||
@@ -1153,7 +1423,7 @@ export function MessageTimeline(props: {
|
||||
<TimelineRowFrame row={userMessageRow}>
|
||||
<Show when={message()}>
|
||||
{(message) => (
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<div data-slot="session-turn-message-content" aria-live="off">
|
||||
<Message
|
||||
message={message()}
|
||||
@@ -1169,11 +1439,55 @@ export function MessageTimeline(props: {
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "WorkspaceLifecycle": {
|
||||
const workspaceRow = row as Accessor<TimelineRowByTag<"WorkspaceLifecycle">>
|
||||
const operation = () => workspaceRow().notice.operation
|
||||
const pending = () => operation().status === "pending"
|
||||
const status = () => {
|
||||
if (operation().status === "failed") return language.t("workspace.move.failed")
|
||||
if (operation().type === "create")
|
||||
return language.t(pending() ? "workspace.lifecycle.creating" : "workspace.lifecycle.created")
|
||||
return language.t(pending() ? "workspace.lifecycle.moving" : "workspace.lifecycle.set")
|
||||
}
|
||||
const directory = () => getFilename(operation().directory)
|
||||
return (
|
||||
<TimelineRowFrame row={workspaceRow}>
|
||||
<div class={`w-full ${turnPadding()}`} aria-live="polite">
|
||||
<div class="flex h-7 items-center py-1 text-[13px] font-[440] leading-none tracking-[-0.04px]">
|
||||
<Show
|
||||
when={!pending()}
|
||||
fallback={
|
||||
<div class="flex items-center gap-1.5">
|
||||
<TextShimmer text={status()} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"flex items-center gap-1.5": true,
|
||||
"text-v2-state-fg-danger": operation().status === "failed",
|
||||
}}
|
||||
>
|
||||
<span class={operation().status === "failed" ? "" : "text-v2-text-text-base"}>{status()}</span>
|
||||
<Show when={operation().status !== "failed"}>
|
||||
<span class="text-[11px] font-[530] italic text-v2-text-text-muted">·</span>
|
||||
<IconV2 name="workspace-isolated" class="shrink-0 text-v2-icon-icon-accent" />
|
||||
<Show when={directory()}>
|
||||
<span class="max-w-[240px] truncate text-v2-text-text-base">{directory()}</span>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "TurnDivider": {
|
||||
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
|
||||
return (
|
||||
<TimelineRowFrame row={turnDividerRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<div data-slot="session-turn-compaction">
|
||||
<MessageDivider
|
||||
label={language.t(
|
||||
@@ -1189,7 +1503,7 @@ export function MessageTimeline(props: {
|
||||
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
|
||||
return (
|
||||
<TimelineRowFrame row={assistantPartRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<div
|
||||
data-slot="session-turn-assistant-content"
|
||||
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
|
||||
@@ -1204,7 +1518,7 @@ export function MessageTimeline(props: {
|
||||
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
|
||||
return (
|
||||
<TimelineRowFrame row={thinkingRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<TimelineThinkingRow
|
||||
reasoningHeading={thinkingRow().reasoningHeading}
|
||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||
@@ -1217,7 +1531,7 @@ export function MessageTimeline(props: {
|
||||
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
|
||||
return (
|
||||
<TimelineRowFrame row={retryRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
@@ -1225,10 +1539,35 @@ export function MessageTimeline(props: {
|
||||
}
|
||||
case "DiffSummary": {
|
||||
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
|
||||
const canMove = () =>
|
||||
settings.general.newLayoutDesigns() &&
|
||||
diffSummaryRow().userMessageID === props.userMessages.at(-1)?.id &&
|
||||
!workspaceSession() &&
|
||||
props.workspaceMoveEligible &&
|
||||
sync().project?.vcs === "git" &&
|
||||
sessionStatus().type === "idle"
|
||||
return (
|
||||
<TimelineRowFrame row={diffSummaryRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<TimelineDiffSummaryRow
|
||||
diffs={diffSummaryRow().diffs}
|
||||
action={
|
||||
<Show when={canMove() && sync().project}>
|
||||
{(project) => (
|
||||
<WorkspaceMoveAction
|
||||
variant="inline"
|
||||
eligible={props.workspaceMoveEligible}
|
||||
sessionID={sessionID()!}
|
||||
project={project()}
|
||||
directory={sessionDirectory()}
|
||||
messageID={diffSummaryRow().userMessageID}
|
||||
dismissed={workspaceSuggestionDismissed()}
|
||||
onDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
@@ -1237,7 +1576,7 @@ export function MessageTimeline(props: {
|
||||
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
|
||||
return (
|
||||
<TimelineRowFrame row={errorRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<Card variant="error" class="error-card">
|
||||
{errorRow().text}
|
||||
</Card>
|
||||
@@ -1319,7 +1658,7 @@ export function MessageTimeline(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative w-full h-full min-w-0">
|
||||
<div class="relative w-full h-full min-w-0" data-workspace-session={workspaceSession() ? "" : undefined}>
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
@@ -1414,6 +1753,39 @@ export function MessageTimeline(props: {
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center min-w-0 flex-1 w-full">
|
||||
<Show when={settings.general.newLayoutDesigns()}>
|
||||
<Show
|
||||
when={workspaceOperation()?.status !== "pending"}
|
||||
fallback={
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<WorkspaceLocationLoader />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={workspaceSession()}
|
||||
fallback={
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<IconV2 name="monitor" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<TooltipV2
|
||||
placement="bottom-start"
|
||||
value={sessionDirectory()}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-label={sessionDirectory()}
|
||||
class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-accent"
|
||||
>
|
||||
<IconV2 name="workspace-isolated" />
|
||||
</span>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1431,57 +1803,73 @@ export function MessageTimeline(props: {
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={childTitle() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
classList={{
|
||||
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
|
||||
settings.general.newLayoutDesigns(),
|
||||
"grow-1 min-w-0": !settings.general.newLayoutDesigns(),
|
||||
<Show
|
||||
when={!lifecycleTitle()}
|
||||
fallback={
|
||||
<span
|
||||
class="px-2 text-[13px] font-[530] leading-4 tracking-[-0.04px]"
|
||||
classList={{ "text-v2-text-text-base": lifecycleTitle()?.kind === "created" }}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Show when={lifecycleTitle()?.kind !== "created"} fallback={lifecycleTitle()?.text}>
|
||||
<TextShimmer text={lifecycleTitle()!.text} />
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Show when={childTitle() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
classList={{
|
||||
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
|
||||
settings.general.newLayoutDesigns(),
|
||||
"grow-1 min-w-0": !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
onClick={openTitleEditor}
|
||||
>
|
||||
{childTitle()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
onClick={openTitleEditor}
|
||||
>
|
||||
{childTitle()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
data-slot="session-title-child"
|
||||
value={title.draft}
|
||||
disabled={titleMutation.isPending}
|
||||
classList={{
|
||||
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !settings.general.newLayoutDesigns(),
|
||||
"field-sizing-content self-start rounded-[6px] px-2 py-1 ":
|
||||
settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
style={{
|
||||
"--inline-input-shadow": settings.general.newLayoutDesigns()
|
||||
? "none"
|
||||
: "var(--shadow-xs-border-select)",
|
||||
}}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
data-slot="session-title-child"
|
||||
value={title.draft}
|
||||
disabled={titleMutation.isPending}
|
||||
classList={{
|
||||
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]":
|
||||
!settings.general.newLayoutDesigns(),
|
||||
"field-sizing-content self-start rounded-[6px] px-2 py-1 ":
|
||||
settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
style={{
|
||||
"--inline-input-shadow": settings.general.newLayoutDesigns()
|
||||
? "none"
|
||||
: "var(--shadow-xs-border-select)",
|
||||
}}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -1499,6 +1887,47 @@ export function MessageTimeline(props: {
|
||||
placement="bottom"
|
||||
buttonAppearance={settings.general.newLayoutDesigns() ? "v2" : "default"}
|
||||
/>
|
||||
<Show when={settings.general.newLayoutDesigns() && !parentID() && sync().project}>
|
||||
{(project) => (
|
||||
<KobaltePopover
|
||||
open={summaryOpen()}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onOpenChange={setSummary}
|
||||
>
|
||||
<KobaltePopover.Trigger
|
||||
as={IconButtonV2}
|
||||
icon={<IconV2 name="window-analytics" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
state={summaryOpen() ? "pressed" : undefined}
|
||||
aria-label={language.t("session.summary.title")}
|
||||
aria-expanded={summaryOpen()}
|
||||
/>
|
||||
<KobaltePopover.Portal>
|
||||
<KobaltePopover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
|
||||
<SessionSummaryPanel
|
||||
project={project()}
|
||||
directory={sessionDirectory()}
|
||||
local={!workspaceSession()}
|
||||
branch={sync().data.vcs?.branch}
|
||||
baseBranch={serverSync().child(project().worktree)[0].vcs?.branch}
|
||||
diffs={sessionDiffs()}
|
||||
sessionID={id}
|
||||
moveEligible={props.workspaceMoveEligible}
|
||||
messageID={props.userMessages.at(-1)?.id}
|
||||
moveDismissed={workspaceSuggestionDismissed()}
|
||||
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||
onReview={() => {
|
||||
setSummary(false)
|
||||
command.trigger("review.toggle")
|
||||
}}
|
||||
/>
|
||||
</KobaltePopover.Content>
|
||||
</KobaltePopover.Portal>
|
||||
</KobaltePopover>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!parentID()}>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
@@ -1564,11 +1993,21 @@ export function MessageTimeline(props: {
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||
<DropdownMenu.Item
|
||||
disabled={workspaceOperationPending(id)}
|
||||
onSelect={() => exportSession(id)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.export")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled={workspaceOperationPending(id)}
|
||||
onSelect={() => void archiveSession(id)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
disabled={workspaceOperationPending(id)}
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
@@ -1635,11 +2074,20 @@ export function MessageTimeline(props: {
|
||||
{language.t("session.share.action.share")}...
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
||||
<MenuV2.Item disabled={workspaceOperationPending(id)} onSelect={() => exportSession(id)}>
|
||||
{language.t("common.export")}...
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item
|
||||
disabled={workspaceOperationPending(id)}
|
||||
onSelect={() => void archiveSession(id)}
|
||||
>
|
||||
{language.t("common.archive")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
|
||||
<MenuV2.Item
|
||||
disabled={workspaceOperationPending(id)}
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||
>
|
||||
{language.t("common.delete")}...
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { reuseTimelineRows } from "./row-reconciliation"
|
||||
import { insertAfterUserMessage, reuseTimelineRows } from "./row-reconciliation"
|
||||
import { TimelineRow } from "./timeline-row"
|
||||
|
||||
const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
|
||||
@@ -94,3 +94,20 @@ describe("reuseTimelineRows", () => {
|
||||
reused.forEach(([resultIndex, previousIndex]) => expect(result[resultIndex]).toBe(previous[previousIndex]))
|
||||
})
|
||||
})
|
||||
|
||||
test("inserts lifecycle extensions immediately after the user message", () => {
|
||||
const rows: TimelineRow.TimelineRow[] = [user(), new TimelineRow.DiffSummary({ userMessageID: "user-1", diffs: [] })]
|
||||
const lifecycle = new TimelineRow.WorkspaceLifecycle({
|
||||
userMessageID: "user-1",
|
||||
notice: {
|
||||
type: "operation",
|
||||
operation: { type: "move", status: "complete", directory: "/workspace", messageID: "user-1" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(insertAfterUserMessage(rows, [lifecycle]).map((row) => row._tag)).toEqual([
|
||||
"UserMessage",
|
||||
"WorkspaceLifecycle",
|
||||
"DiffSummary",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -14,6 +14,8 @@ export function createTimelineProjection(input: {
|
||||
status: Accessor<SessionStatus>
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
inlineComments: Accessor<boolean>
|
||||
extensionRevision?: Accessor<unknown>
|
||||
afterUser?: (message: UserMessage) => TimelineRow.TimelineRow[]
|
||||
}) {
|
||||
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
|
||||
const assistantMessagesByParent = createMemo(() => {
|
||||
@@ -29,8 +31,9 @@ export function createTimelineProjection(input: {
|
||||
})
|
||||
return result
|
||||
})
|
||||
const projection = createMemo(() =>
|
||||
Timeline.constructSessionMessageRows(
|
||||
const projection = createMemo(() => {
|
||||
const extension = input.extensionRevision?.()
|
||||
return Timeline.constructSessionMessageRows(
|
||||
input.sessionMessages(),
|
||||
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
|
||||
input.parts,
|
||||
@@ -38,8 +41,9 @@ export function createTimelineProjection(input: {
|
||||
input.status().type,
|
||||
input.inlineComments(),
|
||||
input.userMessages(),
|
||||
),
|
||||
)
|
||||
input.extensionRevision && extension === undefined ? undefined : input.afterUser,
|
||||
)
|
||||
})
|
||||
const activeMessageID = createMemo(() => projection().activeMessageID)
|
||||
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
||||
reuseTimelineRows(previous, projection().rows),
|
||||
|
||||
@@ -3,6 +3,12 @@ import { TimelineRow } from "./timeline-row"
|
||||
type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
|
||||
type PriorContext = { index: number; row: ContextRow }
|
||||
|
||||
export function insertAfterUserMessage(rows: TimelineRow.TimelineRow[], extensions: TimelineRow.TimelineRow[]) {
|
||||
const index = rows.findIndex((row) => row._tag === "UserMessage")
|
||||
rows.splice(index + 1, 0, ...extensions)
|
||||
return rows
|
||||
}
|
||||
|
||||
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
|
||||
if (!previous?.length) return rows
|
||||
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
|
||||
|
||||
@@ -13,6 +13,14 @@ mock.module("@opencode-ai/session-ui/message-part", () => ({
|
||||
}))
|
||||
|
||||
const { Timeline, TimelineRow } = await import("./rows")
|
||||
const lifecycle = (userMessageID: string) =>
|
||||
new TimelineRow.WorkspaceLifecycle({
|
||||
userMessageID,
|
||||
notice: {
|
||||
type: "operation",
|
||||
operation: { type: "create", status: "complete", directory: "/workspace", messageID: userMessageID },
|
||||
},
|
||||
})
|
||||
|
||||
describe("current session timeline rows", () => {
|
||||
test("derives turns and tagged rows from chronological current messages", () => {
|
||||
@@ -47,6 +55,7 @@ describe("current session timeline rows", () => {
|
||||
"busy",
|
||||
true,
|
||||
normalized.messages.filter((message) => message.role === "user"),
|
||||
(message) => (message.id === "msg_3" ? [lifecycle(message.id)] : []),
|
||||
)
|
||||
|
||||
expect(result.activeMessageID).toBe("msg_3")
|
||||
@@ -55,6 +64,7 @@ describe("current session timeline rows", () => {
|
||||
"assistant-part:msg_1:msg_2:text:0",
|
||||
"turn-gap:msg_3",
|
||||
"user-message:msg_3",
|
||||
"workspace-lifecycle:msg_3:operation",
|
||||
"assistant-part:msg_3:msg_4:reasoning:0",
|
||||
])
|
||||
})
|
||||
@@ -83,11 +93,13 @@ describe("current session timeline rows", () => {
|
||||
"idle",
|
||||
true,
|
||||
normalized.messages.filter((message) => message.role === "user"),
|
||||
(message) => [lifecycle(message.id)],
|
||||
)
|
||||
|
||||
expect(result.activeMessageID).toBe("msg_shell")
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_shell",
|
||||
"workspace-lifecycle:msg_shell:operation",
|
||||
"assistant-part:msg_shell:msg_shell:tool",
|
||||
])
|
||||
})
|
||||
@@ -157,6 +169,7 @@ describe("current session timeline rows", () => {
|
||||
"busy",
|
||||
true,
|
||||
[...normalized.messages.filter((message) => message.role === "user"), optimistic],
|
||||
(message) => (message.id === optimistic.id ? [lifecycle(message.id)] : []),
|
||||
)
|
||||
|
||||
expect(result.activeMessageID).toBe(optimistic.id)
|
||||
@@ -164,6 +177,7 @@ describe("current session timeline rows", () => {
|
||||
"user-message:msg_1",
|
||||
"turn-gap:msg_2",
|
||||
"user-message:msg_2",
|
||||
"workspace-lifecycle:msg_2:operation",
|
||||
"thinking:msg_2",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai
|
||||
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||
import { uniqueSummaryDiffs } from "./summary-diffs"
|
||||
import { insertAfterUserMessage } from "./row-reconciliation"
|
||||
|
||||
export { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||
|
||||
@@ -27,6 +28,10 @@ export type TimelineRowMap = {
|
||||
}
|
||||
Thinking: { userMessageID: string; reasoningHeading?: string }
|
||||
Retry: { userMessageID: string }
|
||||
WorkspaceLifecycle: {
|
||||
userMessageID: string
|
||||
notice: TimelineRow.WorkspaceLifecycle["notice"]
|
||||
}
|
||||
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
|
||||
Error: { userMessageID: string; text: string }
|
||||
}
|
||||
@@ -40,6 +45,7 @@ export namespace Timeline {
|
||||
status: SessionStatus["type"],
|
||||
inlineComments: boolean,
|
||||
projectedUserMessages: UserMessage[],
|
||||
afterUser?: (message: UserMessage) => TimelineRow.TimelineRow[],
|
||||
) {
|
||||
const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = []
|
||||
const turnByUserID = new Map<string, (typeof turns)[number]>()
|
||||
@@ -82,8 +88,8 @@ export namespace Timeline {
|
||||
const activeMessageID = turns.at(-1)?.user.id
|
||||
return {
|
||||
activeMessageID,
|
||||
rows: turns.flatMap((turn, index) =>
|
||||
constructMessageRows(
|
||||
rows: turns.flatMap((turn, index) => {
|
||||
const rows = constructMessageRows(
|
||||
turn.user,
|
||||
getMessageParts,
|
||||
turn.assistants,
|
||||
@@ -92,8 +98,10 @@ export namespace Timeline {
|
||||
status,
|
||||
turn.user.id === activeMessageID,
|
||||
inlineComments,
|
||||
),
|
||||
),
|
||||
)
|
||||
if (!afterUser) return rows
|
||||
return insertAfterUserMessage(rows, afterUser(turn.user))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { Data, Equal } from "effect"
|
||||
import type { WorkspaceOperationState } from "@/utils/workspace-operation"
|
||||
|
||||
export type SummaryDiff = SnapshotFileDiff & { file: string }
|
||||
|
||||
@@ -39,6 +40,10 @@ export namespace TimelineRow {
|
||||
export class Retry extends Data.TaggedClass("Retry")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
export class WorkspaceLifecycle extends Data.TaggedClass("WorkspaceLifecycle")<{
|
||||
userMessageID: string
|
||||
notice: { type: "operation"; operation: WorkspaceOperationState }
|
||||
}> {}
|
||||
|
||||
export type TimelineRow =
|
||||
| TurnGap
|
||||
@@ -50,6 +55,7 @@ export namespace TimelineRow {
|
||||
| DiffSummary
|
||||
| Error
|
||||
| Retry
|
||||
| WorkspaceLifecycle
|
||||
|
||||
export const key = (row: TimelineRow) => {
|
||||
switch (row._tag) {
|
||||
@@ -71,6 +77,8 @@ export namespace TimelineRow {
|
||||
return `error:${row.userMessageID}`
|
||||
case "Retry":
|
||||
return `retry:${row.userMessageID}`
|
||||
case "WorkspaceLifecycle":
|
||||
return `workspace-lifecycle:${row.userMessageID}:${row.notice.type}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,13 +12,15 @@ import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import { findLast } from "@opencode-ai/core/util/array"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Message, Part, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionOwnership } from "./session-ownership"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
|
||||
export type SessionCommandContext = {
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
@@ -73,6 +75,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
if (!id) return
|
||||
return sync().session.get(id)
|
||||
}
|
||||
const workspaceOperationPending = (sessionID: string) =>
|
||||
WorkspaceOperation.get(sdk().scope, sessionID)?.status === "pending"
|
||||
const hasReview = () => !!params.id
|
||||
const normalizeTab = (tab: string) => {
|
||||
if (!tab.startsWith("file://")) return tab
|
||||
@@ -231,6 +235,31 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
)
|
||||
}
|
||||
|
||||
const exportSession = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
try {
|
||||
const data = await fetchSessionExport({
|
||||
sessionID,
|
||||
client: sdk().client,
|
||||
})
|
||||
const filename = sessionExportFilename(data.info)
|
||||
downloadSessionExport(filename, data)
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("toast.session.export.success.title"),
|
||||
description: language.t("toast.session.export.success.description", { filename }),
|
||||
})
|
||||
} catch (err) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.export.failed.title"),
|
||||
description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const openFile = () => {
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-select-file"),
|
||||
@@ -305,6 +334,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const undo = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
const owner = sessionOwnership.capture()
|
||||
const session = sdk().api.session
|
||||
const directory = sdk().directory
|
||||
@@ -333,6 +363,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const redo = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
const owner = sessionOwnership.capture()
|
||||
const session = sdk().api.session
|
||||
const messages = userMessages()
|
||||
@@ -365,6 +396,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const compact = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
|
||||
const model = local.model.current()
|
||||
if (!model) {
|
||||
@@ -382,6 +414,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}
|
||||
|
||||
const fork = () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-fork"),
|
||||
(x) => dialog.show(() => <x.DialogFork />),
|
||||
@@ -431,7 +466,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.session.undo"),
|
||||
description: language.t("command.session.undo.description"),
|
||||
slash: "undo",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
disabled: !params.id || visibleUserMessages().length === 0 || workspaceOperationPending(params.id),
|
||||
onSelect: undo,
|
||||
}),
|
||||
sessionCommand({
|
||||
@@ -439,7 +474,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.session.redo"),
|
||||
description: language.t("command.session.redo.description"),
|
||||
slash: "redo",
|
||||
disabled: !params.id || !info()?.revert?.messageID,
|
||||
disabled: !params.id || !info()?.revert?.messageID || workspaceOperationPending(params.id),
|
||||
onSelect: redo,
|
||||
}),
|
||||
sessionCommand({
|
||||
@@ -447,7 +482,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.session.compact"),
|
||||
description: language.t("command.session.compact.description"),
|
||||
slash: "compact",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
disabled: !params.id || visibleUserMessages().length === 0 || workspaceOperationPending(params.id),
|
||||
onSelect: compact,
|
||||
}),
|
||||
sessionCommand({
|
||||
@@ -455,9 +490,17 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.session.fork"),
|
||||
description: language.t("command.session.fork.description"),
|
||||
slash: "fork",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
disabled: !params.id || visibleUserMessages().length === 0 || workspaceOperationPending(params.id),
|
||||
onSelect: fork,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.export",
|
||||
title: language.t("command.session.export"),
|
||||
description: language.t("command.session.export.description"),
|
||||
slash: "export",
|
||||
disabled: !params.id,
|
||||
onSelect: exportSession,
|
||||
}),
|
||||
]
|
||||
|
||||
const fileCmds = () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createApiForServer, createSdkForServer } from "./server"
|
||||
import { createCompatibleApi } from "./server-compat"
|
||||
import { decodeVcsDiffData } from "./vcs-diff-data"
|
||||
import { decodeLegacySessionList } from "@/context/session-message-decode"
|
||||
|
||||
function setup(
|
||||
protocol: "v1" | "v2" | Promise<"v1" | "v2">,
|
||||
@@ -48,6 +50,8 @@ function setup(
|
||||
current: createApiForServer({ server, fetch: fetcher }),
|
||||
legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }),
|
||||
directory: "/repo",
|
||||
decodeVcsDiff: async (buffer) => decodeVcsDiffData(buffer),
|
||||
decodeSessionList: async (buffer) => decodeLegacySessionList(buffer),
|
||||
})
|
||||
return { api, requests }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ServerApi } from "./server"
|
||||
import type { ServerProtocol } from "./server-protocol"
|
||||
import type { AgentPartInput, FilePartInput, OpencodeClient, Session, TextPartInput } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AgentPartInput, FilePartInput, OpencodeClient, TextPartInput } from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
FileDiffInfo,
|
||||
Project,
|
||||
ProjectCurrent,
|
||||
SessionApi,
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
SessionShellInput,
|
||||
SessionShellOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { legacySessionInfo } from "@/context/session-message-decode"
|
||||
|
||||
type LegacyClient = OpencodeClient
|
||||
type LegacyFor = (directory?: string) => LegacyClient
|
||||
@@ -51,6 +53,8 @@ type CompatibleInput = {
|
||||
current: ServerApi
|
||||
legacy: LegacyFor
|
||||
directory?: string
|
||||
decodeVcsDiff: (buffer: ArrayBuffer) => Promise<FileDiffInfo[]>
|
||||
decodeSessionList: (buffer: ArrayBuffer) => Promise<SessionInfo[]>
|
||||
}
|
||||
|
||||
function mime(uri: string) {
|
||||
@@ -58,31 +62,6 @@ function mime(uri: string) {
|
||||
return match?.[1] ?? "application/octet-stream"
|
||||
}
|
||||
|
||||
function sessionInfo(session: Session): SessionInfo {
|
||||
return {
|
||||
id: session.id,
|
||||
parentID: session.parentID,
|
||||
projectID: session.projectID,
|
||||
agent: session.agent,
|
||||
model: session.model && {
|
||||
id: session.model.id,
|
||||
providerID: session.model.providerID,
|
||||
variant: session.model.variant,
|
||||
},
|
||||
cost: session.cost ?? 0,
|
||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: session.time,
|
||||
title: session.title,
|
||||
location: { directory: session.directory, workspaceID: session.workspaceID },
|
||||
subpath: session.path,
|
||||
revert: session.revert && {
|
||||
messageID: session.revert.messageID,
|
||||
partID: session.revert.partID,
|
||||
snapshot: session.revert.snapshot,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createCompatibleApi(input: CompatibleInput): CompatibleApi {
|
||||
const v1 = createV1Api(input)
|
||||
return lazyApi(
|
||||
@@ -148,29 +127,34 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
search: value.search,
|
||||
limit: value.limit,
|
||||
},
|
||||
options,
|
||||
{ ...options, parseAs: "arrayBuffer" },
|
||||
)
|
||||
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
|
||||
if (!(result.data instanceof ArrayBuffer)) throw new Error("Session list response is not an ArrayBuffer")
|
||||
return { data: await input.decodeSessionList(result.data), cursor: {} }
|
||||
}
|
||||
const result = await legacy({ directory: value?.directory }).session.list({
|
||||
directory: value?.directory,
|
||||
roots: value?.parentID === null ? true : undefined,
|
||||
search: value?.search,
|
||||
limit: value?.limit,
|
||||
})
|
||||
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
|
||||
const result = await legacy({ directory: value?.directory }).session.list(
|
||||
{
|
||||
directory: value?.directory,
|
||||
roots: value?.parentID === null ? true : undefined,
|
||||
search: value?.search,
|
||||
limit: value?.limit,
|
||||
},
|
||||
{ parseAs: "arrayBuffer" },
|
||||
)
|
||||
if (!(result.data instanceof ArrayBuffer)) throw new Error("Session list response is not an ArrayBuffer")
|
||||
return { data: await input.decodeSessionList(result.data), cursor: {} }
|
||||
},
|
||||
async create(value?: Parameters<ServerApi["session"]["create"]>[0]) {
|
||||
const result = await legacy(value?.location ?? undefined).session.create({
|
||||
directory: directory(value?.location ?? undefined),
|
||||
})
|
||||
if (!result.data) throw new Error("Failed to create session")
|
||||
return sessionInfo(result.data)
|
||||
return legacySessionInfo(result.data)
|
||||
},
|
||||
async get(value: Parameters<ServerApi["session"]["get"]>[0]) {
|
||||
const result = await legacy().session.get(value)
|
||||
if (!result.data) throw new Error(`Session not found: ${value.sessionID}`)
|
||||
return sessionInfo(result.data)
|
||||
return legacySessionInfo(result.data)
|
||||
},
|
||||
async active() {
|
||||
const result = await legacy().session.status()
|
||||
@@ -192,7 +176,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
async fork(value: Parameters<ServerApi["session"]["fork"]>[0]) {
|
||||
const result = await legacy().session.fork(value)
|
||||
if (!result.data) throw new Error("Failed to fork session")
|
||||
return sessionInfo(result.data)
|
||||
return legacySessionInfo(result.data)
|
||||
},
|
||||
async interrupt(value: Parameters<ServerApi["session"]["interrupt"]>[0]) {
|
||||
await legacy().session.abort(value)
|
||||
@@ -341,20 +325,15 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
return located(result.data ?? [], value?.location)
|
||||
},
|
||||
async diff(value: Parameters<ServerApi["vcs"]["diff"]>[0]) {
|
||||
const result = await legacy(value.location).vcs.diff({
|
||||
mode: value.mode === "working" ? "git" : value.mode,
|
||||
context: value.context,
|
||||
})
|
||||
return located(
|
||||
(result.data ?? []).map((file) => ({
|
||||
file: file.file,
|
||||
patch: file.patch ?? "",
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
status: file.status ?? "modified",
|
||||
})),
|
||||
value.location,
|
||||
const result = await legacy(value.location).vcs.diff(
|
||||
{
|
||||
mode: value.mode === "working" ? "git" : value.mode,
|
||||
context: value.context,
|
||||
},
|
||||
{ parseAs: "arrayBuffer" },
|
||||
)
|
||||
if (!(result.data instanceof ArrayBuffer)) throw new Error("VCS diff response is not an ArrayBuffer")
|
||||
return located(await input.decodeVcsDiff(result.data), value.location)
|
||||
},
|
||||
},
|
||||
file: {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { fetchSessionExport, sessionExportFilename } from "./session-export"
|
||||
import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
describe("sessionExportFilename", () => {
|
||||
test("generates filename from title", () => {
|
||||
expect(sessionExportFilename({ id: "ses_123", title: "Clone PR in worktree from fork" })).toBe(
|
||||
"clone-pr-in-worktree-from-fork.json",
|
||||
)
|
||||
})
|
||||
|
||||
test("generates filename from slug when title missing", () => {
|
||||
expect(sessionExportFilename({ id: "ses_123", slug: "my-session-slug" })).toBe("my-session-slug.json")
|
||||
})
|
||||
|
||||
test("falls back to id when title and slug are empty", () => {
|
||||
expect(sessionExportFilename({ id: "ses_123" })).toBe("ses_123.json")
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchSessionExport", () => {
|
||||
test("fetches full transcript from client", async () => {
|
||||
const session = { id: "ses_1", title: "Test Session" } as Session
|
||||
const msg = { id: "msg_1", role: "user" } as Message
|
||||
const part = { id: "prt_1", type: "text", text: "hello" } as Part
|
||||
const messages = [{ info: msg, parts: [part] }]
|
||||
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: session }),
|
||||
messages: async () => ({ data: messages }),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await fetchSessionExport({
|
||||
sessionID: "ses_1",
|
||||
client,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
info: session,
|
||||
messages,
|
||||
})
|
||||
})
|
||||
|
||||
test("throws when session not found", async () => {
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: null }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
expect(
|
||||
fetchSessionExport({
|
||||
sessionID: "ses_missing",
|
||||
client,
|
||||
}),
|
||||
).rejects.toThrow("Session not found: ses_missing")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
// Matches the exact `{ info, messages: [{ info, parts }] }` structure produced by `opencode export` CLI
|
||||
export type SessionExportData = {
|
||||
info: Session
|
||||
messages: {
|
||||
info: Message
|
||||
parts: Part[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export type SessionExportClient = {
|
||||
session: {
|
||||
get: (input: { sessionID: string }) => Promise<{ data?: Session | null }>
|
||||
messages: (input: { sessionID: string }) => Promise<{ data?: SessionExportData["messages"] | null }>
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSessionExport(input: {
|
||||
sessionID: string
|
||||
client: SessionExportClient
|
||||
}): Promise<SessionExportData> {
|
||||
const [sessionRes, messagesRes] = await Promise.all([
|
||||
input.client.session.get({ sessionID: input.sessionID }),
|
||||
input.client.session.messages({ sessionID: input.sessionID }),
|
||||
])
|
||||
|
||||
if (!sessionRes?.data) {
|
||||
throw new Error(`Session not found: ${input.sessionID}`)
|
||||
}
|
||||
if (!messagesRes?.data) {
|
||||
throw new Error(`Failed to load messages for session: ${input.sessionID}`)
|
||||
}
|
||||
|
||||
return {
|
||||
info: sessionRes.data,
|
||||
messages: messagesRes.data,
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionExportFilename(session: { id: string; title?: string; slug?: string }) {
|
||||
const name = session.title || session.slug || session.id
|
||||
const clean = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/gi, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
return `${clean || session.id}.json`
|
||||
}
|
||||
|
||||
export function downloadSessionExport(filename: string, data: unknown) {
|
||||
const json = JSON.stringify(data, null, 2)
|
||||
const blob = new Blob([json], { type: "application/json" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
export function decodeVcsDiffData(buffer: ArrayBuffer): FileDiffInfo[] {
|
||||
const text = new TextDecoder().decode(buffer)
|
||||
return (text ? JSON.parse(text) : []).map(
|
||||
(file: {
|
||||
file: string
|
||||
patch?: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
}) => ({
|
||||
file: file.file,
|
||||
patch: file.patch ?? "",
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
status: file.status ?? "modified",
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
type Response = { id: number; data?: FileDiffInfo[]; error?: string }
|
||||
|
||||
let worker: Worker | undefined
|
||||
let nextID = 0
|
||||
const pending = new Map<number, { resolve: (value: FileDiffInfo[]) => void; reject: (error: Error) => void }>()
|
||||
let lastInput = 0
|
||||
document.addEventListener(
|
||||
"beforeinput",
|
||||
() => {
|
||||
lastInput = performance.now()
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
|
||||
export function decodeVcsDiff(buffer: ArrayBuffer) {
|
||||
const id = ++nextID
|
||||
return new Promise<FileDiffInfo[]>((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject })
|
||||
getWorker().postMessage({ id, buffer }, [buffer])
|
||||
})
|
||||
}
|
||||
|
||||
function getWorker() {
|
||||
if (worker) return worker
|
||||
worker = new Worker(new URL("./vcs-diff-decoder.worker.ts", import.meta.url), { type: "module" })
|
||||
worker.onmessage = (event: MessageEvent<Response>) => {
|
||||
const request = pending.get(event.data.id)
|
||||
if (!request) return
|
||||
pending.delete(event.data.id)
|
||||
if (event.data.error) {
|
||||
request.reject(new Error(event.data.error))
|
||||
return
|
||||
}
|
||||
resolveWhenInputIdle(request.resolve, event.data.data ?? [])
|
||||
}
|
||||
worker.onerror = (event) => {
|
||||
const error = new Error(event.message)
|
||||
pending.forEach((request) => request.reject(error))
|
||||
pending.clear()
|
||||
worker?.terminate()
|
||||
worker = undefined
|
||||
}
|
||||
return worker
|
||||
}
|
||||
|
||||
function resolveWhenInputIdle(resolve: (value: FileDiffInfo[]) => void, value: FileDiffInfo[], initial = true) {
|
||||
const active = document.activeElement
|
||||
const editing =
|
||||
active instanceof HTMLInputElement ||
|
||||
active instanceof HTMLTextAreaElement ||
|
||||
(active instanceof HTMLElement && active.isContentEditable)
|
||||
const delay = Math.max(lastInput + 100 - performance.now(), initial && editing ? 100 : 0)
|
||||
if (delay <= 0) {
|
||||
resolve(value)
|
||||
return
|
||||
}
|
||||
setTimeout(() => resolveWhenInputIdle(resolve, value, false), delay)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { decodeVcsDiffData } from "./vcs-diff-data"
|
||||
|
||||
type Request = { id: number; buffer: ArrayBuffer }
|
||||
|
||||
self.onmessage = (event: MessageEvent<Request>) => {
|
||||
try {
|
||||
self.postMessage({ id: event.data.id, data: decodeVcsDiffData(event.data.buffer) })
|
||||
} catch (error) {
|
||||
self.postMessage({ id: event.data.id, error: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ServerScope } from "./server-scope"
|
||||
import { canMoveSessionToWorkspace, WorkspaceOperation } from "./workspace-operation"
|
||||
|
||||
test("workspace moves require settled followup state", () => {
|
||||
expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: false, editing: false })).toBe(true)
|
||||
expect(canMoveSessionToWorkspace({ queued: 1, failed: false, paused: false, editing: false })).toBe(false)
|
||||
expect(canMoveSessionToWorkspace({ queued: 0, failed: true, paused: false, editing: false })).toBe(false)
|
||||
expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: true, editing: false })).toBe(false)
|
||||
expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: false, editing: true })).toBe(false)
|
||||
})
|
||||
|
||||
describe("WorkspaceOperation", () => {
|
||||
test("settles only the matching pending operation", () => {
|
||||
WorkspaceOperation.start(ServerScope.local, "session", "move", "/workspace")
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("pending")
|
||||
WorkspaceOperation.complete(ServerScope.local, "session", "/other")
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("pending")
|
||||
WorkspaceOperation.complete(ServerScope.local, "session", "/workspace")
|
||||
WorkspaceOperation.fail(ServerScope.local, "session")
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("complete")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
export type WorkspaceOperationType = "create" | "move"
|
||||
export type WorkspaceOperationState = {
|
||||
type: WorkspaceOperationType
|
||||
status: "pending" | "complete" | "failed"
|
||||
directory: string
|
||||
messageID?: string
|
||||
}
|
||||
|
||||
export function canMoveSessionToWorkspace(input: {
|
||||
queued: number
|
||||
failed: boolean
|
||||
paused: boolean
|
||||
editing: boolean
|
||||
}) {
|
||||
return input.queued === 0 && !input.failed && !input.paused && !input.editing
|
||||
}
|
||||
|
||||
const state = new Map<string, WorkspaceOperationState>()
|
||||
const [version, setVersion] = createSignal(0)
|
||||
const key = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID)
|
||||
const write = (scope: ServerScope, sessionID: string, value: WorkspaceOperationState) => {
|
||||
if (!state.has(key(scope, sessionID)) && state.size >= 100) {
|
||||
const terminal = [...state].find(([, item]) => item.status !== "pending")?.[0] ?? state.keys().next().value
|
||||
if (terminal) state.delete(terminal)
|
||||
}
|
||||
state.set(key(scope, sessionID), value)
|
||||
setVersion((current) => current + 1)
|
||||
}
|
||||
export const WorkspaceOperation = {
|
||||
get(scope: ServerScope, sessionID: string) {
|
||||
version()
|
||||
return state.get(key(scope, sessionID))
|
||||
},
|
||||
start(scope: ServerScope, sessionID: string, type: WorkspaceOperationType, directory: string, messageID?: string) {
|
||||
write(scope, sessionID, { type, directory, messageID, status: "pending" })
|
||||
},
|
||||
complete(scope: ServerScope, sessionID: string, directory?: string) {
|
||||
const current = state.get(key(scope, sessionID))
|
||||
if (!current) return
|
||||
if (directory && pathKey(directory) !== pathKey(current.directory)) return
|
||||
write(scope, sessionID, { ...current, status: "complete" })
|
||||
},
|
||||
fail(scope: ServerScope, sessionID: string) {
|
||||
const current = state.get(key(scope, sessionID))
|
||||
if (!current || current.status === "complete") return
|
||||
write(scope, sessionID, { ...current, status: "failed" })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export const WORKSPACE_PREPARATION_TIMEOUT_MS = 5 * 60 * 1000
|
||||
export const WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS = 30_000
|
||||
|
||||
export async function workspaceRequestWithTimeout<T>(
|
||||
request: (signal: AbortSignal) => Promise<T>,
|
||||
message: string,
|
||||
timeoutMs: number,
|
||||
) {
|
||||
const controller = new AbortController()
|
||||
const timer = { id: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer.id = setTimeout(() => {
|
||||
controller.abort()
|
||||
reject(new Error(message))
|
||||
}, timeoutMs)
|
||||
})
|
||||
return Promise.race([request(controller.signal), timeout])
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) throw new Error(message)
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
if (timer.id !== undefined) clearTimeout(timer.id)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
filterWorkspaceInventory,
|
||||
inspectWorkspaceDeletion,
|
||||
isWorkspaceDirectory,
|
||||
isWorkspaceSelection,
|
||||
mergeWorkspaceSessionInventory,
|
||||
sessionsForWorkspace,
|
||||
workspaceInventory,
|
||||
} from "./workspace"
|
||||
|
||||
describe("isWorkspaceDirectory", () => {
|
||||
const project = {
|
||||
worktree: "C:\\repo\\",
|
||||
sandboxes: ["C:\\repo-workspaces\\feature\\", "C:\\repo-workspaces\\other"],
|
||||
}
|
||||
|
||||
test("distinguishes managed workspaces from the local repository", () => {
|
||||
expect(isWorkspaceDirectory(project, "C:\\repo")).toBe(false)
|
||||
expect(isWorkspaceDirectory(project, "C:\\repo-workspaces\\feature")).toBe(true)
|
||||
expect(isWorkspaceDirectory(project, "c:\\repo-workspaces\\feature\\packages\\app")).toBe(true)
|
||||
expect(
|
||||
isWorkspaceDirectory({ worktree: "/repo", sandboxes: ["/repo/.worktrees/feature"] }, "/repo/.worktrees/feature"),
|
||||
).toBe(true)
|
||||
expect(isWorkspaceDirectory(project, "C:\\other")).toBe(false)
|
||||
expect(isWorkspaceDirectory(undefined, "C:\\repo-workspaces\\feature")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isWorkspaceSelection", () => {
|
||||
const project = { worktree: "/repo", sandboxes: ["/workspaces/feature"] }
|
||||
|
||||
test("accepts local, new, and managed workspace selections", () => {
|
||||
expect(isWorkspaceSelection(project, "main")).toBe(true)
|
||||
expect(isWorkspaceSelection(project, "create")).toBe(true)
|
||||
expect(isWorkspaceSelection(project, "/repo/")).toBe(true)
|
||||
expect(isWorkspaceSelection(project, "/workspaces/feature/")).toBe(true)
|
||||
expect(isWorkspaceSelection({ worktree: "C:\\repo" }, "c:\\repo\\")).toBe(true)
|
||||
expect(isWorkspaceSelection(project, "/other/workspace")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("groups and filters workspace inventory by project", () => {
|
||||
const inventory = workspaceInventory([
|
||||
{ id: "a", worktree: "/a", sandboxes: ["/a", "/a/one", "/a/two"] },
|
||||
{ id: "b", worktree: "/b", sandboxes: ["/b/one"] },
|
||||
])
|
||||
|
||||
expect(inventory.map((item) => [item.project.id, item.directory])).toEqual([
|
||||
["a", "/a/one"],
|
||||
["a", "/a/two"],
|
||||
["b", "/b/one"],
|
||||
])
|
||||
expect(filterWorkspaceInventory(inventory, "a").map((item) => item.directory)).toEqual(["/a/one", "/a/two"])
|
||||
expect(filterWorkspaceInventory(inventory, "all")).toEqual(inventory)
|
||||
})
|
||||
|
||||
test("blocks unsafe workspace deletion", () => {
|
||||
const session = (directory: string) => ({ directory }) as Session
|
||||
expect(
|
||||
inspectWorkspaceDeletion({
|
||||
workspace: "/workspace",
|
||||
activeDirectory: "/workspace/app",
|
||||
sessions: [],
|
||||
status: "dirty",
|
||||
}),
|
||||
).toBe("active")
|
||||
expect(
|
||||
inspectWorkspaceDeletion({
|
||||
workspace: "/workspace",
|
||||
sessions: [session("/workspace/packages/app")],
|
||||
status: "dirty",
|
||||
}),
|
||||
).toBe("linked")
|
||||
expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "dirty" })).toBe("dirty")
|
||||
expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "clean" })).toBe("safe")
|
||||
expect(
|
||||
inspectWorkspaceDeletion({
|
||||
workspace: "/workspace",
|
||||
sessions: [{ directory: "/workspace", time: { created: 1, updated: 1, archived: 2 } } as Session],
|
||||
status: "clean",
|
||||
}),
|
||||
).toBe("safe")
|
||||
})
|
||||
|
||||
test("groups nested non-archived workspace sessions by latest activity", () => {
|
||||
const session = (id: string, directory: string, updated: number, archived?: number) =>
|
||||
({ id, directory, time: { created: 1, updated, archived } }) as Session
|
||||
const sessions = sessionsForWorkspace(
|
||||
[
|
||||
session("old", "/workspace", 2),
|
||||
session("nested", "/workspace/packages/app", 3),
|
||||
session("archived", "/workspace", 4, 5),
|
||||
session("other", "/other", 6),
|
||||
],
|
||||
"/workspace",
|
||||
)
|
||||
expect(sessions.map((item) => item.id)).toEqual(["nested", "old"])
|
||||
})
|
||||
|
||||
test("merges workspace placement by freshness with authoritative server ties", () => {
|
||||
const session = (directory: string, updated: number) =>
|
||||
({ id: "session", directory, time: { created: 1, updated } }) as Session
|
||||
|
||||
expect(mergeWorkspaceSessionInventory([session("/destination", 3)], [session("/source", 2)])[0]?.directory).toBe(
|
||||
"/destination",
|
||||
)
|
||||
expect(mergeWorkspaceSessionInventory([session("/destination", 3)], [session("/source", 3)])[0]?.directory).toBe(
|
||||
"/destination",
|
||||
)
|
||||
expect(mergeWorkspaceSessionInventory([session("/destination", 2)], [session("/source", 3)])[0]?.directory).toBe(
|
||||
"/source",
|
||||
)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user