mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 01:48:42 +00:00
Compare commits
120
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eea0054393 | ||
|
|
dbc1400067 | ||
|
|
eec53d8ea0 | ||
|
|
cccda661ca | ||
|
|
2891cc6667 | ||
|
|
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 | ||
|
|
dc67b7a3be | ||
|
|
de1bd36a47 | ||
|
|
6c97a46dae | ||
|
|
c387fe190b | ||
|
|
8721a2710b | ||
|
|
c75e3b5bae | ||
|
|
edc8d1d91e | ||
|
|
e85735028c | ||
|
|
6c3299103c | ||
|
|
60e23fee16 | ||
|
|
0c380f31ac | ||
|
|
26aefa68f5 | ||
|
|
638788f8d0 | ||
|
|
b005a15a4a | ||
|
|
0efa33cf33 | ||
|
|
7e94ddafe8 | ||
|
|
7fe993879f | ||
|
|
def14d96ef | ||
|
|
b0b1149233 | ||
|
|
7d6b8105f1 | ||
|
|
a5a09c17fa | ||
|
|
10c6040b38 | ||
|
|
7becbbf711 | ||
|
|
87481f2bb6 | ||
|
|
9535a8f929 | ||
|
|
e917c12c80 | ||
|
|
89130db6b0 | ||
|
|
9465a6c97d | ||
|
|
53fd02970d | ||
|
|
6ff0adef22 | ||
|
|
3307f99f1f | ||
|
|
8de7a3c739 | ||
|
|
28d0637406 | ||
|
|
ef7f712867 | ||
|
|
3016830e25 | ||
|
|
e9e7472456 | ||
|
|
9f9fbf815c | ||
|
|
c88facb2cc | ||
|
|
d4b85f8d83 | ||
|
|
1882c33827 | ||
|
|
01624c8c87 | ||
|
|
0891ecd8e0 | ||
|
|
32f278b48f | ||
|
|
bb6bec9070 | ||
|
|
0eac0964af | ||
|
|
f67e80c275 | ||
|
|
19231fce4b | ||
|
|
d0a7ca6d1b | ||
|
|
d80e86be9d | ||
|
|
e4bd9757a3 | ||
|
|
4087cf1a97 | ||
|
|
14f0bf64a1 | ||
|
|
2df47ee394 | ||
|
|
ad82616445 | ||
|
|
d4ad650f73 | ||
|
|
2039c90c06 | ||
|
|
da59457ca4 | ||
|
|
db4dbaa289 | ||
|
|
d3f30df180 | ||
|
|
7ea343cabd | ||
|
|
e024e2ef92 | ||
|
|
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 }}
|
||||
@@ -339,8 +340,7 @@ jobs:
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
CSC_KEYCHAIN: build.keychain
|
||||
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
|
||||
@@ -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/)
|
||||
@@ -252,33 +252,6 @@ These are not strictly enforced, they are just general guidelines:
|
||||
|
||||
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
|
||||
|
||||
## Trust & Vouch System
|
||||
|
||||
This project uses [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. The vouch list is maintained in [`.github/VOUCHED.td`](.github/VOUCHED.td).
|
||||
|
||||
### How it works
|
||||
|
||||
- **Vouched users** are explicitly trusted contributors.
|
||||
- **Denounced users** are explicitly blocked. Issues and pull requests from denounced users are automatically closed. If you have been denounced, you can request to be unvouched by reaching out to a maintainer on [Discord](https://opencode.ai/discord)
|
||||
- **Everyone else** can participate normally — you don't need to be vouched to open issues or PRs.
|
||||
|
||||
### For maintainers
|
||||
|
||||
Collaborators with write access can manage the vouch list by commenting on any issue:
|
||||
|
||||
- `vouch` — vouch for the issue author
|
||||
- `vouch @username` — vouch for a specific user
|
||||
- `denounce` — denounce the issue author
|
||||
- `denounce @username` — denounce a specific user
|
||||
- `denounce @username <reason>` — denounce with a reason
|
||||
- `unvouch` / `unvouch @username` — remove someone from the list
|
||||
|
||||
Changes are committed automatically to `.github/VOUCHED.td`.
|
||||
|
||||
### Denouncement policy
|
||||
|
||||
Denouncement is reserved for users who repeatedly submit low-quality AI-generated contributions, spam, or otherwise act in bad faith. It is not used for disagreements or honest mistakes.
|
||||
|
||||
## Issue Requirements
|
||||
|
||||
All issues **must** use one of our issue templates:
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.10",
|
||||
"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.10",
|
||||
"version": "1.18.14",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
@@ -144,7 +144,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "1.18.10",
|
||||
"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.10",
|
||||
"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.10",
|
||||
"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.10",
|
||||
"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.10",
|
||||
"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.10",
|
||||
"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.10",
|
||||
"version": "1.18.14",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -381,16 +381,16 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"electron-context-menu": "4.1.2",
|
||||
"electron-log": "^5",
|
||||
"electron-store": "11.0.2",
|
||||
"electron-updater": "6.8.9",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"marked": "^15",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "4.0.0",
|
||||
@@ -435,7 +435,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.18.10",
|
||||
"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.10",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -461,7 +461,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.18.10",
|
||||
"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.10",
|
||||
"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.10",
|
||||
"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.10",
|
||||
"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.10",
|
||||
"version": "1.18.14",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -690,7 +690,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.18.10",
|
||||
"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.10",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -781,7 +781,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.18.10",
|
||||
"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.10",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
|
||||
@@ -811,15 +811,11 @@
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"diff": "catalog:",
|
||||
"dompurify": "3.3.1",
|
||||
"fuzzysort": "catalog:",
|
||||
"katex": "0.16.27",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-katex-extension": "5.1.6",
|
||||
"marked-shiki": "catalog:",
|
||||
"morphdom": "2.7.8",
|
||||
"motion": "12.34.5",
|
||||
"remeda": "catalog:",
|
||||
@@ -832,7 +828,6 @@
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/katex": "0.16.7",
|
||||
"@types/luxon": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
@@ -841,7 +836,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -854,7 +849,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -888,7 +883,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -907,7 +902,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -949,7 +944,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -976,7 +971,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -992,7 +987,6 @@
|
||||
"katex": "0.16.27",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-katex-extension": "5.1.6",
|
||||
"marked-shiki": "catalog:",
|
||||
"morphdom": "2.7.8",
|
||||
"motion": "12.34.5",
|
||||
@@ -1028,7 +1022,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.14",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1084,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:",
|
||||
@@ -1138,7 +1133,7 @@
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
"luxon": "3.6.1",
|
||||
"marked": "17.0.6",
|
||||
"marked": "18.0.7",
|
||||
"marked-shiki": "1.2.1",
|
||||
"opentui-spinner": "0.0.7",
|
||||
"remeda": "2.26.0",
|
||||
@@ -4319,9 +4314,7 @@
|
||||
|
||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||
|
||||
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
|
||||
|
||||
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
|
||||
"marked": ["marked@18.0.7", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA=="],
|
||||
|
||||
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
|
||||
|
||||
@@ -6045,8 +6038,6 @@
|
||||
|
||||
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
|
||||
|
||||
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
|
||||
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||
|
||||
"@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-AD5kt1o035hdvYPyrvjiz2mcsXFJ7UJVEmbE9AJFDFs=",
|
||||
"aarch64-linux": "sha256-f1gVXUpIRWC/I8dxIigdGGvPsju04MXVjZhtq5EYL64=",
|
||||
"aarch64-darwin": "sha256-c+JRpjs2gsgQ2czyM6bTbYEdMbX/erOSQdX6TdrI59k=",
|
||||
"x86_64-darwin": "sha256-iB7l4PtQQ9Mx/XAdtyayiLe6pZKhuXB9lpmrbTr+uWc="
|
||||
"x86_64-linux": "sha256-uduwrM143NDSc+tXsi4lVVfoMll2a3BDHRUjuO7GB68=",
|
||||
"aarch64-linux": "sha256-6DUda78XdXY6DP86lIUkweSjys3iG4Y4mo1PiaNuXbg=",
|
||||
"aarch64-darwin": "sha256-AkJwfLULLZVwwz+XU1QcFUZoIS7oVPCn+n/MXEaxrqE=",
|
||||
"x86_64-darwin": "sha256-hAxKGdiITTxQ2uujQt6prNjo3NxGAMMeo+9HlMWK6GU="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -70,7 +70,7 @@
|
||||
"hono-openapi": "1.1.2",
|
||||
"fuzzysort": "3.1.0",
|
||||
"luxon": "3.6.1",
|
||||
"marked": "17.0.6",
|
||||
"marked": "18.0.7",
|
||||
"marked-shiki": "1.2.1",
|
||||
"remend": "1.3.0",
|
||||
"@playwright/test": "1.59.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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
|
||||
- Always prefer `createStore` over multiple `createSignal` calls
|
||||
|
||||
## 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.
|
||||
- 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`.
|
||||
- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels.
|
||||
|
||||
## Tool Calling
|
||||
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
|
||||
const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)
|
||||
|
||||
// The sixth project sits outside the five-item recent cap, so it is only reachable if the
|
||||
// dialog hands every recent project to the list filter instead of a pre-truncated slice.
|
||||
const OUTSIDE_CAP = "foxtrot-docs"
|
||||
|
||||
// Dialog rows carry data-directory-path; the sidebar project list does not, so this
|
||||
// scopes assertions to the picker instead of matching the sidebar entry of the same name.
|
||||
const rows = (page: Page) => page.locator("[data-directory-path]")
|
||||
const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)
|
||||
|
||||
async function openProjectDialog(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
fileList: () => [],
|
||||
findFiles: () => [],
|
||||
})
|
||||
await page.addInitScript((dirs) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
|
||||
lastProject: {},
|
||||
}),
|
||||
)
|
||||
}, worktrees)
|
||||
await page.goto("/")
|
||||
const add = page.getByRole("button", { name: "Add project" }).first()
|
||||
await expectAppVisible(add)
|
||||
await add.click()
|
||||
await expect(rows(page)).toHaveCount(5)
|
||||
return page.getByRole("textbox").last()
|
||||
}
|
||||
|
||||
test("searches every recent project, not just the five most recent", async ({ page }) => {
|
||||
const search = await openProjectDialog(page)
|
||||
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
|
||||
|
||||
await search.fill("foxtrot")
|
||||
|
||||
await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("still caps the idle recent list at five projects", async ({ page }) => {
|
||||
await openProjectDialog(page)
|
||||
|
||||
await expect(row(page, NAMES[4])).toHaveCount(1)
|
||||
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
|
||||
})
|
||||
@@ -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`,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const profile of [
|
||||
{ locale: "de", label: "Erkundet" },
|
||||
{ locale: "de", label: "Erkundung abgeschlossen" },
|
||||
{ locale: "ar", label: "تم الاستكشاف" },
|
||||
] as const) {
|
||||
test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
|
||||
|
||||
@@ -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,20 +84,21 @@ 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 timeline = await setupTimeline(page, { protocol: "v2" })
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||
id: "timeline-event-7",
|
||||
})
|
||||
@@ -112,5 +120,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,777 @@
|
||||
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 local, new, and existing workspaces from the ready-ish 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"]'))
|
||||
|
||||
const trigger = page.getByRole("button", { name: /^local$/i })
|
||||
const intro = page.locator('[data-component="workspace-onboarding-card"]')
|
||||
const dot = page.locator('[data-slot="workspace-onboarding-dot"]')
|
||||
await expect(trigger).toBeVisible()
|
||||
await expect(intro).toContainText("Isolate sessions with workspaces")
|
||||
await expect(dot).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [triggerBox, introBox] = await Promise.all([trigger.boundingBox(), intro.boundingBox()])
|
||||
if (!triggerBox || !introBox) return
|
||||
return {
|
||||
right: Math.round(introBox.x + introBox.width - triggerBox.x - triggerBox.width),
|
||||
top: Math.round(introBox.y - triggerBox.y - triggerBox.height),
|
||||
}
|
||||
})
|
||||
.toEqual({ right: 0, top: 0 })
|
||||
await trigger.hover()
|
||||
await expect(page.getByRole("tooltip")).toContainText("Select where to run session")
|
||||
await trigger.click()
|
||||
await expect(intro).toBeHidden()
|
||||
await expect(page.getByRole("menuitem", { name: /Local repository/ })).toBeVisible()
|
||||
await page.getByRole("menuitem", { name: /Local repository/ }).click()
|
||||
await expect(dot).toBeVisible()
|
||||
|
||||
await trigger.click()
|
||||
const newWorkspace = page.getByRole("menuitem", { name: /New workspace/ })
|
||||
await expect(newWorkspace).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: /Workspace/ })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "View all" })).toBeVisible()
|
||||
|
||||
await newWorkspace.click()
|
||||
await expect(dot).toBeHidden()
|
||||
await expect(page.getByRole("button", { name: /New workspace/ })).toBeVisible()
|
||||
await expect(page.getByText("from main", { exact: true })).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: /New workspace/ }).click()
|
||||
await page.getByRole("menuitem", { name: /Workspace/ }).hover()
|
||||
await page.getByRole("menuitem", { name: "feature" }).click()
|
||||
await expect(page.getByRole("button", { name: /feature/ })).toBeVisible()
|
||||
})
|
||||
|
||||
test("searches long workspace lists within the available viewport", async ({ page }) => {
|
||||
const draftID = "draft_workspace_search"
|
||||
const workspaces = Array.from({ length: 10 }, (_, index) => `${workspace}-${index}`)
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: root,
|
||||
project: { ...project, sandboxes: workspaces },
|
||||
provider,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await init(page, { type: "draft", draftID, directory: root })
|
||||
await page.setViewportSize({ width: 600, height: 360 })
|
||||
|
||||
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/ }).focus()
|
||||
await page.keyboard.press("ArrowRight")
|
||||
|
||||
const submenu = page
|
||||
.locator('[data-component="menu-v2-content"]')
|
||||
.filter({ has: page.getByPlaceholder("Search workspaces") })
|
||||
const search = submenu.getByPlaceholder("Search workspaces")
|
||||
await expect(search).toBeFocused()
|
||||
const box = await submenu.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.y).toBeGreaterThanOrEqual(0)
|
||||
expect(box!.y + box!.height).toBeLessThanOrEqual(360)
|
||||
|
||||
await search.fill("feature-9")
|
||||
await expect(submenu.getByRole("menuitem", { name: "feature-9" })).toBeVisible()
|
||||
await expect(submenu.getByRole("menuitem", { name: "feature-0" })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("lists and manually deletes workspaces from settings", async ({ page }) => {
|
||||
const draftID = "draft_workspace_settings"
|
||||
const cleanWorkspace = `${workspace}-clean`
|
||||
const inventory = { ...project, sandboxes: [workspace, cleanWorkspace] }
|
||||
const session = {
|
||||
id: "ses_workspace_settings",
|
||||
slug: "workspace-settings",
|
||||
projectID: project.id,
|
||||
directory: workspace,
|
||||
title: "Workspace settings session",
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
let removal: { directory: string | null; body: unknown } | undefined
|
||||
let statusRequests = 0
|
||||
let sessionListRequests = 0
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path === "/vcs/status" || path === "/api/vcs/status") statusRequests++
|
||||
if (path === "/session" || path === "/api/session") sessionListRequests++
|
||||
})
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: root,
|
||||
project: inventory,
|
||||
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": "DELETE" },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (route.request().method() !== "DELETE") return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
removal = { directory: url.searchParams.get("directory"), body: route.request().postDataJSON() }
|
||||
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 expectAppVisible(page.locator('[data-component="prompt-input"]'))
|
||||
await page.getByRole("button", { name: /^local$/i }).click()
|
||||
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(workspace, { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText(cleanWorkspace, { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Workspace settings session", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Automatically delete old workspaces")).toHaveCount(0)
|
||||
const firstSessionInventory = sessionListRequests
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(settings).toHaveCount(0)
|
||||
await page.getByRole("button", { name: /^local$/i }).click()
|
||||
await page.getByRole("menuitem", { name: "View all" }).click()
|
||||
await expect(settings.getByText("Workspace settings session", { exact: true })).toBeVisible()
|
||||
expect(sessionListRequests).toBeGreaterThan(firstSessionInventory)
|
||||
await page.setViewportSize({ width: 375, height: 700 })
|
||||
expect(await settings.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
|
||||
await settings.getByRole("button", { name: "More options" }).click()
|
||||
await page.getByRole("menuitem", { name: "Delete all workspaces" }).click()
|
||||
const bulkConfirmation = page.locator('[data-dialog-layer="1"]')
|
||||
await expect(bulkConfirmation.getByText("Delete all 2 workspaces?")).toBeVisible()
|
||||
await expect(bulkConfirmation).toContainText("All projects")
|
||||
await bulkConfirmation.getByRole("button", { name: "Cancel" }).click()
|
||||
|
||||
await settings.getByRole("button", { name: 'Delete workspace "feature"?' }).click()
|
||||
let confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature"?' })
|
||||
await expect(confirmation).toContainText("linked sessions")
|
||||
await expect(confirmation.getByRole("button", { name: "Delete workspace" })).toBeDisabled()
|
||||
const firstInspection = statusRequests
|
||||
await confirmation.getByRole("button", { name: "Cancel" }).click()
|
||||
|
||||
await settings.getByRole("button", { name: 'Delete workspace "feature"?' }).click()
|
||||
confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature"?' })
|
||||
await expect(confirmation).toContainText("linked sessions")
|
||||
expect(statusRequests).toBeGreaterThan(firstInspection)
|
||||
await confirmation.getByRole("button", { name: "Cancel" }).click()
|
||||
|
||||
await settings.getByRole("button", { name: 'Delete workspace "feature-clean"?' }).click()
|
||||
confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature-clean"?' })
|
||||
await expect(confirmation).toContainText("permanently removed")
|
||||
const beforeRemoval = statusRequests
|
||||
await confirmation.getByRole("button", { name: "Delete workspace" }).click()
|
||||
await expect.poll(() => removal).toEqual({ directory: root, body: { directory: cleanWorkspace } })
|
||||
expect(statusRequests).toBeGreaterThan(beforeRemoval)
|
||||
await expect(settings.getByText(cleanWorkspace, { exact: true })).toHaveCount(0)
|
||||
await expect(settings.getByText(workspace, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("blocks deletion of the currently active workspace", async ({ page }) => {
|
||||
const draftID = "draft_workspace_active"
|
||||
let removed = false
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: workspace,
|
||||
project,
|
||||
provider,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/experimental/worktree**", async (route) => {
|
||||
if (route.request().method() !== "DELETE") return route.fallback()
|
||||
removed = true
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: "true" })
|
||||
})
|
||||
await init(page, { type: "draft", draftID, directory: workspace })
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await page.getByRole("button", { name: /feature/ }).click()
|
||||
await page.getByRole("menuitem", { name: "View all" }).click()
|
||||
const settings = page.locator(".settings-v2-dialog")
|
||||
await settings.getByRole("button", { name: 'Delete workspace "feature"?' }).click()
|
||||
const confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature"?' })
|
||||
await expect(confirmation).toContainText("active workspace")
|
||||
await expect(confirmation.getByRole("button", { name: "Delete workspace" })).toBeDisabled()
|
||||
expect(removed).toBe(false)
|
||||
})
|
||||
|
||||
test("wraps the workspace toolbar for long project filters on mobile", async ({ page }) => {
|
||||
const draftID = "draft_workspace_mobile_filter"
|
||||
const other = {
|
||||
...project,
|
||||
id: "proj_workspaces_other",
|
||||
name: "A second project with a deliberately long workspace filter label",
|
||||
worktree: "C:/OpenCode/AnotherWorkspaceProject",
|
||||
sandboxes: ["C:/OpenCode/worktree/another/feature-with-a-long-name"],
|
||||
}
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: root,
|
||||
project,
|
||||
provider,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/project**", async (route) => {
|
||||
if (new URL(route.request().url()).pathname !== "/project") return route.fallback()
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify([project, other]),
|
||||
})
|
||||
})
|
||||
await init(page, { type: "draft", draftID, directory: root })
|
||||
await page.setViewportSize({ width: 375, height: 700 })
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
const dismissTabs = page.getByRole("button", { name: "Dismiss Tabs information" })
|
||||
if (await dismissTabs.isVisible()) await dismissTabs.click()
|
||||
await page.getByRole("button", { name: /^local$/i }).click()
|
||||
await page.getByRole("menuitem", { name: "View all" }).click()
|
||||
const settings = page.locator(".settings-v2-dialog")
|
||||
await expect(settings.getByRole("button", { name: "All projects" })).toBeVisible()
|
||||
expect(await settings.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
})
|
||||
|
||||
test("bulk deletion snapshots inventory and skips dirty or unknown workspaces", async ({ page }) => {
|
||||
const draftID = "draft_workspace_delete_all"
|
||||
const unknown = `${workspace}-unknown`
|
||||
const clean = `${workspace}-clean`
|
||||
const later = `${workspace}-later`
|
||||
const inventory = { ...project, sandboxes: [workspace, unknown, clean] }
|
||||
const removals: string[] = []
|
||||
let release = () => {}
|
||||
const deleteGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
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("**/vcs/status**", async (route) => {
|
||||
const directory = new URL(route.request().url()).searchParams.get("directory")
|
||||
if (directory === workspace) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify([{ path: "dirty.ts", status: "modified" }]),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (directory === unknown) {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify({ message: "status failed" }),
|
||||
})
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: "[]",
|
||||
})
|
||||
})
|
||||
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()
|
||||
const body = route.request().postDataJSON() as { directory: string }
|
||||
removals.push(body.directory)
|
||||
await deleteGate
|
||||
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 page.getByRole("button", { name: /^local$/i }).click()
|
||||
await page.getByRole("menuitem", { name: "View all" }).click()
|
||||
const settings = page.locator(".settings-v2-dialog")
|
||||
await settings.getByRole("button", { name: "More options" }).click()
|
||||
await page.getByRole("menuitem", { name: "Delete all workspaces" }).click()
|
||||
const confirmation = page.locator('[data-dialog-layer="1"]')
|
||||
await expect(confirmation).toContainText("Delete all 3 workspaces?")
|
||||
await transport.send({
|
||||
directory: "global",
|
||||
payload: {
|
||||
id: "evt_workspace_added_after_confirmation",
|
||||
type: "project.updated",
|
||||
properties: { ...inventory, sandboxes: [...inventory.sandboxes, later] },
|
||||
},
|
||||
})
|
||||
await confirmation.getByRole("button", { name: "Delete all workspaces" }).click()
|
||||
|
||||
await expect.poll(() => removals).toEqual([clean])
|
||||
const deleteButtons = settings.getByRole("button", { name: /Delete workspace/ })
|
||||
await expect.poll(() => deleteButtons.count()).toBeGreaterThan(0)
|
||||
expect(await deleteButtons.evaluateAll((buttons) => buttons.every((button) => button.hasAttribute("disabled")))).toBe(
|
||||
true,
|
||||
)
|
||||
release()
|
||||
await expect(settings.getByText(workspace, { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText(unknown, { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText(clean, { exact: true })).toHaveCount(0)
|
||||
await expect(settings.getByText(later, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("applies the recovered new-workspace default without automatic cleanup", async ({ page }) => {
|
||||
const draftID = "draft_workspace_default"
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: root,
|
||||
project,
|
||||
provider,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ draftID, root, server }) => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: { newLayoutDesigns: true },
|
||||
workspaces: { defaultDestination: "new", lastUsed: {} },
|
||||
}),
|
||||
)
|
||||
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([{ type: "draft", draftID, server, directory: root }]),
|
||||
)
|
||||
},
|
||||
{ draftID, root, server },
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await expect(page.getByRole("button", { name: /New workspace/ })).toBeVisible()
|
||||
await expect(page.getByText("from main", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
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 },
|
||||
}
|
||||
let prompt: unknown
|
||||
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
|
||||
}
|
||||
prompt = route.request().postDataJSON()
|
||||
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")
|
||||
for (const attempt of [1, 2, 3, 4, 5]) {
|
||||
await transport.send({
|
||||
directory: createdWorkspace,
|
||||
payload: {
|
||||
id: `evt_submit_ready_${attempt}`,
|
||||
type: "worktree.ready",
|
||||
properties: { name: "quick-contrast-fix" },
|
||||
},
|
||||
})
|
||||
await page.waitForTimeout(100)
|
||||
if (prompt) break
|
||||
}
|
||||
await expect.poll(() => prompt).not.toBeUndefined()
|
||||
await expect(lifecycle).toContainText("Workspace created")
|
||||
})
|
||||
|
||||
test("shows neutral workspace identity and the ready-ish session summary panel", async ({ page }) => {
|
||||
const sessionID = "ses_workspace_summary"
|
||||
const messageID = "msg_workspace_summary"
|
||||
const session = {
|
||||
id: sessionID,
|
||||
slug: "workspace-summary",
|
||||
projectID: project.id,
|
||||
directory: workspace,
|
||||
title: "Workspace summary session",
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
const vcsRequests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname === "/api/vcs/diff") vcsRequests.push(request.url())
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory: workspace,
|
||||
project,
|
||||
provider,
|
||||
sessions: [session],
|
||||
pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Implement workspace support", true)] }),
|
||||
vcsDiff: [diff],
|
||||
})
|
||||
await init(page, { type: "session", sessionId: sessionID })
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
const timeline = page.locator("[data-workspace-session]")
|
||||
await expect(timeline).toBeVisible()
|
||||
await expect(timeline.locator(`[aria-label="${workspace}"]`)).toHaveAttribute("tabindex", "0")
|
||||
await expect(timeline.locator('[data-slot="session-title-child"]')).toHaveClass(/text-v2-text-text-base/)
|
||||
await expect(timeline.locator('[data-slot="user-message-text"]')).not.toHaveCSS(
|
||||
"background-color",
|
||||
"rgb(59, 92, 246)",
|
||||
)
|
||||
|
||||
const title = page.locator("[data-session-title]")
|
||||
await title.getByRole("button", { name: "Session details" }).click()
|
||||
const panel = page.locator('[data-component="session-summary-panel"]')
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel).toContainText("workspace-project")
|
||||
await expect(panel).toContainText("feature")
|
||||
await expect(panel).toContainText("1 Changed file")
|
||||
await expect.poll(() => vcsRequests.length).toBeGreaterThan(0)
|
||||
const request = new URL(vcsRequests.at(-1)!)
|
||||
expect(request.searchParams.get("mode")).toBe("working")
|
||||
expect(request.searchParams.get("location[directory]")).toBe(workspace)
|
||||
})
|
||||
|
||||
test("moves a changed local session to an existing workspace with an end-of-turn divider", async ({ page }) => {
|
||||
const sessionID = "ses_workspace_move_existing"
|
||||
const messageID = "msg_workspace_move_existing"
|
||||
const session = {
|
||||
id: sessionID,
|
||||
slug: "workspace-move-existing",
|
||||
projectID: project.id,
|
||||
directory: root,
|
||||
title: "Move this session",
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
let move: unknown
|
||||
let releaseMove = () => {}
|
||||
const moveGate = new Promise<void>((resolve) => {
|
||||
releaseMove = resolve
|
||||
})
|
||||
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, "Move this work", true)] }),
|
||||
vcsDiff: [diff],
|
||||
})
|
||||
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
|
||||
}
|
||||
move = route.request().postDataJSON()
|
||||
await moveGate
|
||||
session.directory = workspace
|
||||
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()
|
||||
const inlineMove = page
|
||||
.locator('[data-component="session-turn-diffs-group"]')
|
||||
.getByRole("button", { name: "Move to workspace" })
|
||||
await expect(inlineMove).toBeVisible()
|
||||
await inlineMove.click()
|
||||
await page.getByRole("menuitem", { name: "Workspace", exact: true }).hover()
|
||||
await page.getByRole("menuitem", { name: "feature" }).click()
|
||||
|
||||
await expect
|
||||
.poll(() => move)
|
||||
.toEqual({
|
||||
sessionID,
|
||||
destination: { directory: workspace },
|
||||
moveChanges: true,
|
||||
})
|
||||
await page.locator("[data-session-title]").getByRole("button", { name: "More options" }).click()
|
||||
await expect(page.getByRole("menuitem", { name: "Archive" })).toBeDisabled()
|
||||
await expect(page.getByRole("menuitem", { name: /Delete/ })).toBeDisabled()
|
||||
await page.keyboard.press("Escape")
|
||||
releaseMove()
|
||||
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
|
||||
await expect(lifecycle).toContainText("Workspace set")
|
||||
})
|
||||
|
||||
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 },
|
||||
}
|
||||
let move: unknown
|
||||
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
|
||||
}
|
||||
move = route.request().postDataJSON()
|
||||
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")
|
||||
for (const attempt of [1, 2, 3, 4, 5]) {
|
||||
await transport.send({
|
||||
directory: createdWorkspace,
|
||||
payload: {
|
||||
id: `evt_worktree_ready_${attempt}`,
|
||||
type: "worktree.ready",
|
||||
properties: { name: "quick-contrast-fix" },
|
||||
},
|
||||
})
|
||||
await page.waitForTimeout(100)
|
||||
if (move) break
|
||||
}
|
||||
await expect
|
||||
.poll(() => move)
|
||||
.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")
|
||||
})
|
||||
@@ -247,18 +247,23 @@ export async function installSseTransport<T>(
|
||||
return {
|
||||
server,
|
||||
async waitForConnection(input = {}) {
|
||||
await page.waitForFunction(
|
||||
const connection = await page.waitForFunction(
|
||||
(after) => {
|
||||
const transport = (window as BrowserTransport).__testSseTransport
|
||||
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
|
||||
return connections?.some((connection) => connection.id > after)
|
||||
return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined)
|
||||
},
|
||||
input.after ?? 0,
|
||||
{ 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 })
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.14",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./desktop-menu": "./src/desktop-menu.ts",
|
||||
"./i18n/desktop-native": "./src/i18n/desktop-native.ts",
|
||||
"./updater": "./src/updater.ts",
|
||||
"./wsl/types": "./src/wsl/types.ts",
|
||||
"./vite": "./vite.js",
|
||||
@@ -27,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": {
|
||||
|
||||
+28
-15
@@ -3,8 +3,6 @@ import * as Sentry from "@sentry/solid"
|
||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||
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"
|
||||
@@ -59,18 +57,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()
|
||||
@@ -234,7 +238,13 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
|
||||
function UiI18nBridge(props: ParentProps) {
|
||||
const language = useLanguage()
|
||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||
return (
|
||||
<I18nProvider
|
||||
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
|
||||
>
|
||||
{props.children}
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutCompatibility(props: ParentProps) {
|
||||
@@ -327,7 +337,7 @@ function DesktopCommands() {
|
||||
if (platform.platform === "desktop" && platform.exportDebugLogs) {
|
||||
commands.push({
|
||||
id: "logs.export",
|
||||
title: "Export logs",
|
||||
title: language.t("command.logs.export"),
|
||||
category: language.t("command.category.settings"),
|
||||
onSelect: () => {
|
||||
void platform.exportDebugLogs?.()
|
||||
@@ -385,7 +395,12 @@ function DraftProviders(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
export function AppBaseProviders(
|
||||
props: ParentProps<{
|
||||
locale?: Locale
|
||||
onNativeTranslations?: Parameters<typeof LanguageProvider>[0]["onNativeTranslations"]
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<MetaProvider>
|
||||
<Font />
|
||||
@@ -394,7 +409,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
void window.api?.setTitlebar?.({ mode, scheme })
|
||||
}}
|
||||
>
|
||||
<LanguageProvider locale={props.locale}>
|
||||
<LanguageProvider locale={props.locale} onNativeTranslations={props.onNativeTranslations}>
|
||||
<UiI18nBridge>
|
||||
<ErrorBoundary
|
||||
fallback={(error) => {
|
||||
@@ -405,9 +420,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
<QueryProvider>
|
||||
<WslServersProvider>
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</DialogProvider>
|
||||
</WslServersProvider>
|
||||
</QueryProvider>
|
||||
|
||||
@@ -60,35 +60,44 @@ function Cell(props: {
|
||||
label: string
|
||||
tip: string
|
||||
value: string
|
||||
wide?: boolean
|
||||
span?: 2 | 3
|
||||
}) {
|
||||
const content = () => (
|
||||
<div
|
||||
classList={{
|
||||
"flex min-w-0 items-center": true,
|
||||
"min-h-[20px] w-fit flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline,
|
||||
"min-h-[20px] w-fit justify-start px-1.5 py-0.5 text-left": !!props.inline,
|
||||
"justify-center text-center": !props.inline,
|
||||
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
|
||||
"col-span-2": !!props.wide && !props.inline,
|
||||
"col-span-2": props.span === 2 && !props.inline,
|
||||
"col-span-3": props.span === 3 && !props.inline,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
|
||||
"flex min-w-0": true,
|
||||
"-translate-y-px items-baseline gap-1.5": !!props.inline,
|
||||
"flex-col items-center": !props.inline,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
<div
|
||||
classList={{
|
||||
"uppercase leading-none font-bold tabular-nums": true,
|
||||
"text-[11px]": !!props.inline,
|
||||
"text-[13px] sm:text-[14px]": !props.inline,
|
||||
"text-text-on-critical-base": !!props.bad,
|
||||
"opacity-70": !!props.dim,
|
||||
}}
|
||||
>
|
||||
{props.value}
|
||||
<div
|
||||
classList={{
|
||||
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
<div
|
||||
classList={{
|
||||
"uppercase leading-none font-bold tabular-nums": true,
|
||||
"text-[11px]": !!props.inline,
|
||||
"text-[13px] sm:text-[14px]": !props.inline,
|
||||
"text-text-on-critical-base": !!props.bad,
|
||||
"opacity-70": !!props.dim,
|
||||
}}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -108,37 +117,50 @@ function Cell(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
|
||||
function ToggleCell(props: {
|
||||
active: boolean
|
||||
inline?: boolean
|
||||
label: string
|
||||
onClick: () => void
|
||||
tip: string
|
||||
value: string
|
||||
}) {
|
||||
const content = () => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Force focus styles on all interactive elements"
|
||||
aria-label={`${props.label}: ${props.value}`}
|
||||
aria-pressed={props.active}
|
||||
classList={{
|
||||
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
|
||||
"min-h-[20px] w-fit flex-row justify-start gap-1.5 rounded px-1.5 py-0.5 text-left": !!props.inline,
|
||||
"min-h-[20px] w-fit justify-start rounded px-1.5 py-0.5 text-left": !!props.inline,
|
||||
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
|
||||
"bg-surface-raised-base text-text-strong": props.active,
|
||||
}}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
|
||||
<span classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}>
|
||||
{props.active ? "ON" : "OFF"}
|
||||
<span
|
||||
classList={{
|
||||
flex: true,
|
||||
"-translate-y-px items-baseline gap-1.5": !!props.inline,
|
||||
"flex-col items-center": !props.inline,
|
||||
}}
|
||||
>
|
||||
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">{props.label}</span>
|
||||
<span class="text-[11px] leading-none font-bold">{props.value}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
if (props.inline) {
|
||||
return (
|
||||
<TooltipV2 value="Force focus styles on all interactive elements" placement="top">
|
||||
<TooltipV2 value={props.tip} placement="top">
|
||||
{content()}
|
||||
</TooltipV2>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip value="Force focus styles on all interactive elements" placement="top">
|
||||
<Tooltip value={props.tip} placement="top">
|
||||
{content()}
|
||||
</Tooltip>
|
||||
)
|
||||
@@ -457,7 +479,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
"gap-[9px]": !!props.inline,
|
||||
"gap-px": !props.inline,
|
||||
"flex w-full flex-nowrap items-center justify-start": !!props.inline,
|
||||
"grid-cols-5": !props.inline,
|
||||
"grid-cols-4": !props.inline,
|
||||
grid: !props.inline,
|
||||
}}
|
||||
>
|
||||
@@ -539,10 +561,25 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
bad={bad(heap(), 0.8)}
|
||||
dim={state.heap.used === undefined}
|
||||
inline={props.inline}
|
||||
wide={!platform.setForceFocus}
|
||||
span={platform.setForceFocus ? 2 : 3}
|
||||
/>
|
||||
<ToggleCell
|
||||
active={language.direction() === "rtl"}
|
||||
inline={props.inline}
|
||||
label={language.t("debugBar.direction.label")}
|
||||
tip={language.t("debugBar.direction.tip")}
|
||||
value={language.t(`debugBar.direction.${language.direction()}`)}
|
||||
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
|
||||
/>
|
||||
{platform.setForceFocus && (
|
||||
<FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
|
||||
<ToggleCell
|
||||
active={state.focus}
|
||||
inline={props.inline}
|
||||
label={language.t("debugBar.focus.label")}
|
||||
tip={language.t("debugBar.focus.tip")}
|
||||
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
|
||||
onClick={() => void toggleFocus()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { Link } from "@/components/link"
|
||||
import { ExternalLink } from "@/components/external-link"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -509,7 +509,12 @@ function ProviderConnection(props: {
|
||||
const hint = suffix?.[1]
|
||||
return {
|
||||
label: suffix ? label.slice(0, -suffix[0].length) : label,
|
||||
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined,
|
||||
hint:
|
||||
hint?.toLowerCase() === "headless"
|
||||
? language.t("provider.connect.method.headless")
|
||||
: hint?.toLowerCase() === "browser" || (!hint && value?.type === "key")
|
||||
? language.t("provider.connect.method.browser")
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,12 +841,12 @@ function ProviderConnection(props: {
|
||||
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
|
||||
<div>
|
||||
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
||||
<Link
|
||||
<ExternalLink
|
||||
href="https://opencode.ai/zen"
|
||||
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
||||
>
|
||||
{language.t("provider.connect.opencodeZen.visit.link")}
|
||||
</Link>
|
||||
</ExternalLink>
|
||||
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
||||
</div>
|
||||
</div>
|
||||
@@ -886,9 +891,9 @@ function ProviderConnection(props: {
|
||||
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
||||
<Link href="https://opencode.ai/zen" tabIndex={-1}>
|
||||
<ExternalLink href="https://opencode.ai/zen" tabIndex={-1}>
|
||||
{language.t("provider.connect.opencodeZen.visit.link")}
|
||||
</Link>
|
||||
</ExternalLink>
|
||||
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
||||
</div>
|
||||
</div>
|
||||
@@ -967,9 +972,9 @@ function ProviderConnection(props: {
|
||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<div>
|
||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||
<Link href={store.authorization!.url} class="text-v2-text-text-base">
|
||||
<ExternalLink href={store.authorization!.url} class="text-v2-text-text-base">
|
||||
{language.t("provider.connect.oauth.code.visit.link")}
|
||||
</Link>
|
||||
</ExternalLink>
|
||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
||||
@@ -1006,7 +1011,9 @@ function ProviderConnection(props: {
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
|
||||
<ExternalLink href={store.authorization!.url}>
|
||||
{language.t("provider.connect.oauth.code.visit.link")}
|
||||
</ExternalLink>
|
||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||
@@ -1077,7 +1084,9 @@ function ProviderConnection(props: {
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
||||
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
|
||||
<ExternalLink href={store.authorization!.url}>
|
||||
{language.t("provider.connect.oauth.auto.visit.link")}
|
||||
</ExternalLink>
|
||||
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
||||
</div>
|
||||
<TextField
|
||||
|
||||
@@ -8,7 +8,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { batch, For } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { ExternalLink } from "@/components/external-link"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -131,7 +131,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||
|
||||
const saveMutation = useMutation(() => ({
|
||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
|
||||
if ((await serverSDK().protocol) !== "v1") throw new Error(language.t("provider.custom.unavailable"))
|
||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||
|
||||
@@ -185,9 +185,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
||||
<p class="text-14-regular text-text-base">
|
||||
{language.t("provider.custom.description.prefix")}
|
||||
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
||||
<ExternalLink href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
||||
{language.t("provider.custom.description.link")}
|
||||
</Link>
|
||||
</ExternalLink>
|
||||
{language.t("provider.custom.description.suffix")}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ interface DialogSelectDirectoryProps {
|
||||
server: ServerConnection.Any
|
||||
}
|
||||
|
||||
const RECENT_PROJECT_LIMIT = 5
|
||||
|
||||
type Row = {
|
||||
absolute: string
|
||||
search: string
|
||||
@@ -57,9 +59,9 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const [filter, setFilter] = createSignal("")
|
||||
let list: ListRef | undefined
|
||||
|
||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||
const missingHome = createMemo(() => !sync.data.path.home)
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
() => (missingHome() ? true : undefined),
|
||||
async (): Promise<Path | undefined> => {
|
||||
if ((await sdk.protocol) !== "v1") return
|
||||
return sdk.client.path
|
||||
@@ -102,7 +104,6 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
return projects
|
||||
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
|
||||
.sort((a, b) => b.at - a.at || a.index - b.index)
|
||||
.slice(0, 5)
|
||||
.map(({ project }) => {
|
||||
const row = toRow(project.worktree, home(), "recent")
|
||||
const name = project.name || getFilename(project.worktree)
|
||||
@@ -116,7 +117,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const items = async (value: string) => {
|
||||
const results = await directories(value)
|
||||
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
|
||||
return uniqueRows([...recentProjects(), ...directoryRows])
|
||||
// Cap the idle list only. Once a query narrows the results, every project stays searchable.
|
||||
const recent = recentProjects()
|
||||
const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
|
||||
return uniqueRows([...visible, ...directoryRows])
|
||||
}
|
||||
|
||||
function resolve(absolute: string) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
|
||||
const platform = usePlatform()
|
||||
|
||||
const runAction = () => {
|
||||
if (props.link) platform.openLink(props.link)
|
||||
if (props.link) platform.openExternal(props.link)
|
||||
props.onClose?.()
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ComponentProps, splitProps } from "solid-js"
|
||||
|
||||
export interface ExternalLinkProps extends Omit<ComponentProps<"a">, "href"> {
|
||||
href: string
|
||||
}
|
||||
|
||||
export function ExternalLink(props: ExternalLinkProps) {
|
||||
const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"])
|
||||
|
||||
return (
|
||||
<a
|
||||
href={local.href}
|
||||
class={`text-text-strong underline ${local.class ?? ""}`}
|
||||
target={local.target ?? "_blank"}
|
||||
rel={local.rel ?? "noopener noreferrer"}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -29,14 +29,14 @@ export type { Kind } from "@/components/file-tree"
|
||||
|
||||
const INDENT_STEP = 16
|
||||
|
||||
function rowPaddingLeft(level: number, type: FileNode["type"]) {
|
||||
function rowPaddingStart(level: number, type: FileNode["type"]) {
|
||||
if (type === "directory") return 8 + level * INDENT_STEP
|
||||
if (level === 0) return 8
|
||||
return 8 + level * INDENT_STEP - INDENT_STEP
|
||||
}
|
||||
|
||||
function guideLineLeft(level: number) {
|
||||
return rowPaddingLeft(level, "directory") + 8
|
||||
function guideLineStart(level: number) {
|
||||
return rowPaddingStart(level, "directory") + 8
|
||||
}
|
||||
|
||||
export const kindLabel = (kind: Kind) => {
|
||||
@@ -87,7 +87,7 @@ const FileTreeNodeV2 = (
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
style={`padding-left: ${rowPaddingLeft(local.level, local.node.type)}px`}
|
||||
style={`padding-inline-start: ${rowPaddingStart(local.level, local.node.type)}px`}
|
||||
draggable={local.draggable}
|
||||
onDragStart={(event: DragEvent) => {
|
||||
if (!local.draggable) return
|
||||
@@ -99,7 +99,9 @@ const FileTreeNodeV2 = (
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
<span class="flex-1 min-w-0 text-12-medium whitespace-nowrap truncate">{local.node.name}</span>
|
||||
<span class="flex-1 min-w-0 text-start text-12-medium whitespace-nowrap truncate">
|
||||
<bdi dir="auto">{local.node.name}</bdi>
|
||||
</span>
|
||||
{(() => {
|
||||
const value = kind()
|
||||
if (!value || local.node.type !== "file") return null
|
||||
@@ -116,7 +118,7 @@ const FileTreeNodeV2 = (
|
||||
function GuideLines(props: { level: number }) {
|
||||
return (
|
||||
<For each={Array.from({ length: props.level })}>
|
||||
{(_, index) => <div data-slot="file-tree-v2-guide" style={`left: ${guideLineLeft(index())}px`} />}
|
||||
{(_, index) => <div data-slot="file-tree-v2-guide" style={`inset-inline-start: ${guideLineStart(index())}px`} />}
|
||||
</For>
|
||||
)
|
||||
}
|
||||
@@ -227,7 +229,7 @@ export default function FileTreeV2(props: {
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
"inset-inline-start": "0",
|
||||
width: "100%",
|
||||
height: `${item().size}px`,
|
||||
transform: `translateY(${item().start}px)`,
|
||||
|
||||
@@ -146,13 +146,13 @@ const FileTreeNode = (
|
||||
<Dynamic
|
||||
component={local.as ?? "div"}
|
||||
classList={{
|
||||
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
||||
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-start hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
||||
"bg-surface-base-active": local.node.path === local.active,
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
[local.nodeClass ?? ""]: !!local.nodeClass,
|
||||
}}
|
||||
style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
|
||||
style={`padding-inline-start: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
|
||||
draggable={local.draggable}
|
||||
onDragStart={(event: DragEvent) => {
|
||||
if (!local.draggable) return
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { isRTL } from "@kobalte/core/i18n"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useLanguage } from "@/context/language"
|
||||
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()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
||||
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
||||
const rtl = () => isRTL(language.intl())
|
||||
|
||||
return (
|
||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side={rtl() ? "left" : "right"}>
|
||||
<Show when={settings.general.shouldDisplayTabsToast()}>
|
||||
<div
|
||||
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
||||
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
|
||||
class="fixed bottom-5 end-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
||||
aria-label={language.t("help.tabs.toast.ariaLabel")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss Tabs information"
|
||||
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
||||
aria-label={language.t("help.tabs.toast.dismiss")}
|
||||
class="absolute top-3 end-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
||||
onClick={settings.general.dismissTabsToast}
|
||||
>
|
||||
<svg
|
||||
@@ -44,7 +45,7 @@ export function TabsInfoPopup() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
||||
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-start"
|
||||
onClick={() => {
|
||||
settings.general.dismissTabsToast()
|
||||
setDrawerOpen(true)
|
||||
@@ -62,10 +63,10 @@ export function TabsInfoPopup() {
|
||||
/>
|
||||
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
|
||||
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
|
||||
Introducing Tabs
|
||||
{language.t("help.tabs.title")}
|
||||
</p>
|
||||
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
||||
Organize your work and active sessions with tabs
|
||||
{language.t("help.tabs.description")}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
@@ -75,7 +76,10 @@ export function TabsInfoPopup() {
|
||||
style={
|
||||
windows()
|
||||
? {
|
||||
inset: "0 0 0 auto",
|
||||
top: "0",
|
||||
bottom: "0",
|
||||
"inset-inline-end": "0",
|
||||
"inset-inline-start": "auto",
|
||||
"max-height": "100vh",
|
||||
"max-width": "100vw",
|
||||
"border-radius": "0",
|
||||
@@ -89,9 +93,9 @@ export function TabsInfoPopup() {
|
||||
type="button"
|
||||
size="small"
|
||||
variant="neutral"
|
||||
aria-label="Close"
|
||||
aria-label={language.t("common.close")}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
class="absolute top-[10px] left-[-36px]"
|
||||
class="absolute top-[10px] start-[-36px]"
|
||||
/>
|
||||
</Show>
|
||||
<div
|
||||
@@ -102,7 +106,7 @@ export function TabsInfoPopup() {
|
||||
}}
|
||||
>
|
||||
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
||||
July 14
|
||||
{language.t("help.tabs.date")}
|
||||
</p>
|
||||
<Show when={!windows()}>
|
||||
<DrawerClose
|
||||
@@ -110,36 +114,23 @@ export function TabsInfoPopup() {
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
aria-label="Close"
|
||||
aria-label={language.t("common.close")}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
|
||||
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
||||
Introducing Tabs
|
||||
{language.t("help.tabs.title")}
|
||||
</p>
|
||||
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||
<p>OpenCode Desktop is now built around tabs.</p>
|
||||
<p>{language.t("help.tabs.introduction")}</p>
|
||||
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||
<p>
|
||||
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
|
||||
you're starting something new, and close it when you're done.
|
||||
</p>
|
||||
<p>
|
||||
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
|
||||
memorable if you plan to keep them around.
|
||||
</p>
|
||||
<p>
|
||||
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
|
||||
</p>
|
||||
<p>{language.t("help.tabs.sessions")}</p>
|
||||
<p>{language.t("help.tabs.organize")}</p>
|
||||
<p>{language.t("help.tabs.home")}</p>
|
||||
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||
<p>When you reopen the app, your tabs are still open.</p>
|
||||
<p>
|
||||
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
|
||||
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
|
||||
will become permanent in a few weeks.
|
||||
</p>
|
||||
<p>{language.t("help.tabs.persistence")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { ComponentProps, splitProps } from "solid-js"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
|
||||
export interface LinkProps extends Omit<ComponentProps<"a">, "href"> {
|
||||
href: string
|
||||
}
|
||||
|
||||
export function Link(props: LinkProps) {
|
||||
const platform = usePlatform()
|
||||
const [local, rest] = splitProps(props, ["href", "children", "class"])
|
||||
|
||||
return (
|
||||
<a
|
||||
href={local.href}
|
||||
class={`text-text-strong underline ${local.class ?? ""}`}
|
||||
onClick={(event) => {
|
||||
if (!local.href) return
|
||||
event.preventDefault()
|
||||
platform.openLink(local.href)
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -137,7 +137,10 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
|
||||
}),
|
||||
)
|
||||
const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder())
|
||||
const designPlaceholder = () =>
|
||||
promptDesignPlaceholder(mode(), placeholder(), (key, params) =>
|
||||
language.t(key as Parameters<typeof language.t>[0], params as never),
|
||||
)
|
||||
|
||||
const historyComments = () => {
|
||||
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||
@@ -344,7 +347,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
if (item?.commentID) comments.remove(item.path, item.commentID)
|
||||
},
|
||||
openAttachment: (attachment) =>
|
||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
|
||||
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
|
||||
openContext(key) {
|
||||
const item = controller.contextItem(key)
|
||||
if (item) openComment(item, props, sync, layout, files, comments)
|
||||
@@ -377,6 +380,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
}),
|
||||
readClipboardImage: platform.readClipboardImage,
|
||||
getPathForFile: platform.getPathForFile,
|
||||
store: platform.draftStore?.putBlob,
|
||||
},
|
||||
view: {
|
||||
placeholder: designPlaceholder,
|
||||
|
||||
@@ -77,7 +77,7 @@ import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/
|
||||
import { PromptContextItems } from "./prompt-input/context-items"
|
||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||
import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
@@ -1489,10 +1489,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<PromptImageAttachments
|
||||
attachments={imageAttachments()}
|
||||
onOpen={(attachment) =>
|
||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
||||
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />)
|
||||
}
|
||||
onRemove={removeAttachment}
|
||||
removeLabel={language.t("prompt.attachment.remove")}
|
||||
fileLabel={language.t("ui.common.file")}
|
||||
newLayoutDesigns={false}
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -3,28 +3,13 @@ import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import { getCursorPosition } from "./editor-dom"
|
||||
import { createBlobReference, type DraftStore } from "@/utils/draft-store"
|
||||
import { attachmentMime } from "./files"
|
||||
import { normalizePaste, pasteMode } from "./paste"
|
||||
|
||||
function dataUrl(file: File, mime: string) {
|
||||
return new Promise<string>((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener("error", () => resolve(""))
|
||||
reader.addEventListener("load", () => {
|
||||
const value = typeof reader.result === "string" ? reader.result : ""
|
||||
const idx = value.indexOf(",")
|
||||
if (idx === -1) {
|
||||
resolve(value)
|
||||
return
|
||||
}
|
||||
resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
|
||||
})
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
|
||||
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
|
||||
|
||||
@@ -36,6 +21,7 @@ type PromptAttachmentsCoreInput = {
|
||||
warn?: () => void
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
getPathForFile?: (file: File) => string
|
||||
draftStore?: DraftStore
|
||||
}
|
||||
|
||||
export type PromptAttachmentsInput = {
|
||||
@@ -65,16 +51,13 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||
return false
|
||||
}
|
||||
|
||||
const url = await dataUrl(file, mime)
|
||||
if (!url) return false
|
||||
|
||||
const attachment: ImageAttachmentPart = {
|
||||
type: "image",
|
||||
id: uuid(),
|
||||
filename: file.name,
|
||||
sourcePath: input.getPathForFile?.(file) || undefined,
|
||||
mime,
|
||||
dataUrl: url,
|
||||
blob: input.draftStore ? await input.draftStore.putBlob(file) : await createBlobReference(file),
|
||||
}
|
||||
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
|
||||
return true
|
||||
@@ -166,8 +149,10 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||
|
||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const attachments = createPromptAttachmentsCore({
|
||||
...input,
|
||||
draftStore: platform.draftStore,
|
||||
capture: input.prompt.capture,
|
||||
warn: () => {
|
||||
showToast({
|
||||
|
||||
@@ -22,7 +22,7 @@ type ContextFile = {
|
||||
type BuildRequestPartsInput = {
|
||||
prompt: Prompt
|
||||
context: ContextFile[]
|
||||
images: ImageAttachmentPart[]
|
||||
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
|
||||
text: string
|
||||
messageID: string
|
||||
sessionID: string
|
||||
|
||||
@@ -47,7 +47,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
{...(!props.newLayoutDesigns ? { openDelay: 800 } : {})}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { prependHistoryEntry, type PromptHistoryComment, type PromptHistoryStoredEntry } from "./history"
|
||||
import {
|
||||
clonePromptHistoryComments,
|
||||
clonePromptParts,
|
||||
prependHistoryEntry,
|
||||
type PromptHistoryComment,
|
||||
type PromptHistoryStoredEntry,
|
||||
} from "./history"
|
||||
|
||||
export type PromptInputHistory = {
|
||||
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||
@@ -35,13 +41,23 @@ export function createPromptInputHistory(): PromptInputHistory {
|
||||
}
|
||||
|
||||
export function createPersistedPromptInputHistory() {
|
||||
const [normal, setNormal] = persisted(
|
||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||
const [normal, setNormal, normalInit] = persisted(
|
||||
Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])),
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
const [shell, setShell] = persisted(
|
||||
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
||||
const [shell, setShell, shellInit] = persisted(
|
||||
Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])),
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||
const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||
return {
|
||||
...history,
|
||||
add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
|
||||
const ready = mode === "shell" ? shellInit : normalInit
|
||||
if (!(ready instanceof Promise)) return history.add(prompt, mode, comments)
|
||||
const saved = clonePromptParts(prompt)
|
||||
const metadata = clonePromptHistoryComments(comments)
|
||||
void ready.then(() => history.add(saved, mode, metadata))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ describe("prompt-input history", () => {
|
||||
end: 12,
|
||||
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
|
||||
},
|
||||
{ type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" },
|
||||
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
|
||||
]
|
||||
const copy = clonePromptParts(original)
|
||||
expect(copy).not.toBe(original)
|
||||
|
||||
@@ -16,6 +16,7 @@ type PromptImageAttachmentsProps = {
|
||||
onOpen: (attachment: ImageAttachmentPart) => void
|
||||
onRemove: (id: string) => void
|
||||
removeLabel: string
|
||||
fileLabel: string
|
||||
newLayoutDesigns: boolean
|
||||
comments?: PromptCommentItem[]
|
||||
commentActive?: (item: PromptCommentItem) => boolean
|
||||
@@ -52,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}
|
||||
@@ -94,13 +90,13 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
||||
}
|
||||
>
|
||||
<AttachmentCardV2 title={attachment.filename}>
|
||||
{typeLabel(attachment.filename, attachment.mime)}
|
||||
{typeLabel(attachment.filename, attachment.mime, props.fileLabel)}
|
||||
</AttachmentCardV2>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={attachment.dataUrl}
|
||||
src={attachment.blob.url}
|
||||
alt={attachment.filename}
|
||||
class={props.newLayoutDesigns ? imageClassV2 : imageClass}
|
||||
onClick={() => props.onOpen(attachment)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { promptPlaceholder } from "./placeholder"
|
||||
import { promptDesignPlaceholder, promptPlaceholder } from "./placeholder"
|
||||
|
||||
describe("promptPlaceholder", () => {
|
||||
const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}`
|
||||
@@ -46,3 +46,18 @@ describe("promptPlaceholder", () => {
|
||||
expect(value).toBe("prompt.placeholder.simple")
|
||||
})
|
||||
})
|
||||
|
||||
describe("promptDesignPlaceholder", () => {
|
||||
const t = (key: string, params?: Record<string, string>) => {
|
||||
if (key !== "ui.promptInput.placeholder.normal") return key
|
||||
return `Ask anything, ${params?.slash} for commands, ${params?.at} for context...`
|
||||
}
|
||||
|
||||
test("composes the design placeholder from localized fragments", () => {
|
||||
expect(promptDesignPlaceholder("normal", "fallback", t)).toBe("Ask anything, / for commands, @ for context...")
|
||||
})
|
||||
|
||||
test("preserves the shell placeholder", () => {
|
||||
expect(promptDesignPlaceholder("shell", "Enter shell command...", t)).toBe("Enter shell command...")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,11 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
|
||||
return input.t("prompt.placeholder.normal", { example: input.example })
|
||||
}
|
||||
|
||||
export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) {
|
||||
export function promptDesignPlaceholder(
|
||||
mode: PromptPlaceholderInput["mode"],
|
||||
placeholder: string,
|
||||
t: PromptPlaceholderInput["t"],
|
||||
) {
|
||||
if (mode === "shell") return placeholder
|
||||
return "Ask anything, / for commands, @ for context..."
|
||||
return t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ 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
|
||||
|
||||
@@ -26,12 +29,17 @@ 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 sentShellDirectories: string[] = []
|
||||
const syncedDirectories: 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 sentCommandDirectories: 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 } = {}
|
||||
@@ -40,6 +48,18 @@ let selected = "/repo/worktree-a"
|
||||
let variant: string | undefined
|
||||
let permissionServer = "server-a"
|
||||
let createSessionGate: Promise<void> | undefined
|
||||
let sessionFailure: Error | 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,16 +87,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)
|
||||
const clientFor = (directory: string, track = true) => {
|
||||
if (track) createdClients.push(directory)
|
||||
return {
|
||||
api: {
|
||||
session: {
|
||||
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
||||
await createSessionGate
|
||||
if (sessionFailure) throw sessionFailure
|
||||
const location = input.location?.directory ?? directory
|
||||
createdSessions.push(location)
|
||||
sessionCreateInputs.push(input)
|
||||
@@ -99,9 +124,11 @@ const clientFor = (directory: string) => {
|
||||
},
|
||||
command: async (input: unknown) => {
|
||||
sentCommands.push(input)
|
||||
sentCommandDirectories.push(directory)
|
||||
},
|
||||
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||
sentShell.push(input)
|
||||
sentShellDirectories.push(directory)
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -110,7 +137,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 } }
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -173,7 +209,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 +233,81 @@ 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, false).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)
|
||||
optimisticSeeded.push(
|
||||
!!value.directory &&
|
||||
!!value.sessionID &&
|
||||
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
|
||||
)
|
||||
},
|
||||
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)
|
||||
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 }>
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/platform", () => ({
|
||||
@@ -287,24 +335,204 @@ beforeEach(() => {
|
||||
optimisticSeeded.length = 0
|
||||
promoted.length = 0
|
||||
promotedDrafts.length = 0
|
||||
updatedDrafts.length = 0
|
||||
sentCommands.length = 0
|
||||
sentCommandDirectories.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
|
||||
sentShellDirectories.length = 0
|
||||
syncedDirectories.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
|
||||
sessionFailure = undefined
|
||||
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]
|
||||
})
|
||||
|
||||
describe("prompt submit worktree selection", () => {
|
||||
test("admits only one concurrent new-workspace submission", async () => {
|
||||
selected = "create"
|
||||
let release = () => {}
|
||||
createWorktreeGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
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
|
||||
const first = submit.handleSubmit(event)
|
||||
const duplicate = submit.handleSubmit(event)
|
||||
expect(worktreeCreates).toBe(1)
|
||||
|
||||
release()
|
||||
await Promise.all([first, duplicate])
|
||||
expect(createdSessions).toEqual([worktreeDirectory])
|
||||
Worktree.ready(ServerScope.local, worktreeDirectory)
|
||||
await settle()
|
||||
|
||||
expect(worktreeCreates).toBe(1)
|
||||
expect(createdSessions).toHaveLength(1)
|
||||
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||
})
|
||||
|
||||
test("allows retry after new-workspace submission fails", async () => {
|
||||
selected = "create"
|
||||
worktreeFailure = new Error("create failed")
|
||||
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(worktreeCreates).toBe(1)
|
||||
expect(createdSessions).toEqual([])
|
||||
|
||||
worktreeFailure = undefined
|
||||
await submit.handleSubmit(event)
|
||||
Worktree.ready(ServerScope.local, worktreeDirectory)
|
||||
await settle()
|
||||
|
||||
expect(worktreeCreates).toBe(2)
|
||||
expect(createdSessions).toEqual([worktreeDirectory])
|
||||
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||
})
|
||||
|
||||
test("aborts a hung new-workspace request and allows retry", async () => {
|
||||
selected = "create"
|
||||
worktreeHung = true
|
||||
let resets = 0
|
||||
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: () => resets++,
|
||||
onSubmit: () => undefined,
|
||||
worktreeRequestTimeoutMs: 1,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
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("retains workspace selection after session creation failure and clears it after retry", async () => {
|
||||
sessionFailure = new Error("session create failed")
|
||||
let resets = 0
|
||||
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: () => {
|
||||
resets++
|
||||
selected = "main"
|
||||
},
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
await submit.handleSubmit(event)
|
||||
expect(resets).toBe(0)
|
||||
expect(selected).toBe("/repo/worktree-a")
|
||||
|
||||
sessionFailure = undefined
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(resets).toBe(1)
|
||||
expect(selected).toBe("main")
|
||||
})
|
||||
|
||||
test("reads the latest worktree accessor value per submit", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
@@ -420,6 +648,54 @@ describe("prompt submit worktree selection", () => {
|
||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
let submitted = 0
|
||||
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: () => submitted++,
|
||||
})
|
||||
|
||||
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
activeSDK = "server-b"
|
||||
activeServerSync = "server-b"
|
||||
activeDirectorySync = "server-b"
|
||||
search.draftId = "draft-2"
|
||||
release()
|
||||
await result
|
||||
await settle()
|
||||
|
||||
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("promotes drafts using the selected project's server", async () => {
|
||||
search = { draftId: "draft-1" }
|
||||
const submit = createPromptSubmit({
|
||||
@@ -448,7 +724,7 @@ describe("prompt submit worktree selection", () => {
|
||||
})
|
||||
|
||||
test("includes the selected variant on optimistic prompts", async () => {
|
||||
params = { id: "session-1" }
|
||||
params.id = "session-1"
|
||||
variant = "high"
|
||||
|
||||
const submit = createPromptSubmit({
|
||||
@@ -595,4 +871,154 @@ describe("prompt submit worktree selection", () => {
|
||||
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
|
||||
expect(optimisticSeeded).toEqual([true])
|
||||
})
|
||||
|
||||
test("waits for a new workspace before sending an initial shell", async () => {
|
||||
selected = "create"
|
||||
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,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
expect(sentShell).toEqual([])
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending")
|
||||
|
||||
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("waits for a new workspace before sending an initial custom command", async () => {
|
||||
selected = "create"
|
||||
commands = [{ name: "deploy" }]
|
||||
promptValue = [{ type: "text", content: "/deploy now", start: 0, end: 11 }]
|
||||
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(sentCommands).toEqual([])
|
||||
|
||||
Worktree.ready(ServerScope.local, worktreeDirectory)
|
||||
await settle()
|
||||
|
||||
expect(sentCommands).toHaveLength(1)
|
||||
expect(sentCommands[0]).toMatchObject({
|
||||
sessionID: "session-1",
|
||||
command: "deploy",
|
||||
arguments: "now",
|
||||
agent: "agent",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
files: [],
|
||||
})
|
||||
expect(sentCommandDirectories).toEqual([worktreeDirectory])
|
||||
})
|
||||
|
||||
test("settles a pending workspace operation when the initial prompt is aborted", async () => {
|
||||
selected = "create"
|
||||
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(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",
|
||||
message: "aborted",
|
||||
})
|
||||
})
|
||||
|
||||
test("settles a pending workspace operation when preparation fails", async () => {
|
||||
selected = "create"
|
||||
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,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
Worktree.failed(ServerScope.local, worktreeDirectory, "bootstrap failed")
|
||||
await settle()
|
||||
|
||||
expect(sentShell).toEqual([])
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")).toMatchObject({
|
||||
status: "failed",
|
||||
message: "bootstrap failed",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,20 +15,27 @@ 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"
|
||||
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
|
||||
@@ -42,6 +49,7 @@ export type FollowupDraft = {
|
||||
|
||||
type FollowupSendInput = {
|
||||
api: DirectorySDK["api"]["session"]
|
||||
scope: ServerScope
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
draft: FollowupDraft
|
||||
@@ -55,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 = () => {
|
||||
@@ -95,10 +105,12 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
providerID: input.draft.model.providerID,
|
||||
variant: input.draft.variant,
|
||||
},
|
||||
files: images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
name: attachment.filename,
|
||||
})),
|
||||
files: await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
name: attachment.filename,
|
||||
})),
|
||||
),
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
@@ -108,10 +120,16 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
}
|
||||
|
||||
const messageID = input.messageID ?? Identifier.ascending("message")
|
||||
const encodedImages = await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
...attachment,
|
||||
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
})),
|
||||
)
|
||||
const { requestParts, optimisticParts } = buildRequestParts({
|
||||
prompt: input.draft.prompt,
|
||||
context: input.draft.context,
|
||||
images,
|
||||
images: encodedImages,
|
||||
text,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
@@ -220,6 +238,7 @@ type PromptSubmitInput = {
|
||||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
model?: ModelSelection
|
||||
worktreeRequestTimeoutMs?: number
|
||||
}
|
||||
|
||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
@@ -235,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
|
||||
@@ -248,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, "aborted")
|
||||
pending.delete(key)
|
||||
return Promise.resolve()
|
||||
}
|
||||
@@ -291,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]
|
||||
@@ -325,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()
|
||||
@@ -338,293 +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, worktree.message)
|
||||
}
|
||||
|
||||
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, worktree.message)
|
||||
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, result.message)
|
||||
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: images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
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,29 +10,49 @@ export function PromptWorkspaceSelector(props: {
|
||||
projectRoot: string
|
||||
workspaces: string[]
|
||||
branch?: string
|
||||
onboarding?: boolean
|
||||
intro?: boolean
|
||||
onIntroDismiss?: () => void
|
||||
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("")
|
||||
props.onIntroDismiss?.()
|
||||
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 = () => {
|
||||
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
|
||||
if (selected() === "main") return language.t("workspace.type.local")
|
||||
if (props.value === "create") return language.t("workspace.new")
|
||||
return getFilename(props.value)
|
||||
}
|
||||
@@ -41,67 +60,179 @@ 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={language.t("session.new.workspace.trigger.tooltip")}
|
||||
class="relative 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}>
|
||||
<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(/…$/, "")}
|
||||
</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.SubContent>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2.Sub>
|
||||
</Show>
|
||||
<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.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<Show when={props.intro}>
|
||||
<div
|
||||
data-component="workspace-onboarding-card"
|
||||
class="pointer-events-none absolute right-0 top-full z-50 flex w-[280px] flex-col gap-2 rounded-lg bg-v2-background-bg-base p-3 shadow-[var(--v2-elevation-floating)]"
|
||||
>
|
||||
<div class="flex items-start gap-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<Icon name="workspace-isolated" class="shrink-0 text-v2-text-text-accent" />
|
||||
<span class="min-w-0 flex-1">{language.t("workspace.onboarding.title")}</span>
|
||||
</div>
|
||||
<p class="text-[13px] font-[440] leading-4 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
{language.t("workspace.onboarding.description")}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</TooltipV2>
|
||||
<PromptGitStatus branch={props.branch} from={selected() === "create"} connected={selected() === "create"} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
|
||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; connected?: boolean }) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -109,15 +240,24 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
|
||||
<Show when={label()}>
|
||||
{(value) => (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<Show when={!props.connected}>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
</Show>
|
||||
<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" />
|
||||
<div
|
||||
class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 px-1.5 text-[13px] font-[440] leading-5 tracking-[-0.04px]"
|
||||
classList={{ "ml-0.5": props.connected }}
|
||||
>
|
||||
<Icon
|
||||
name={props.noGit ? "monitor" : "branch"}
|
||||
size="small"
|
||||
class="shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{value()}</span>
|
||||
</div>
|
||||
</TooltipV2>
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
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 { 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 [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) => {
|
||||
setStore("selected", undefined)
|
||||
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
|
||||
WorkspaceOperation.fail(scope, sessionID, message)
|
||||
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)
|
||||
|
||||
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)) {
|
||||
fail(scope, sessionID, language.t("workspace.move.failed"))
|
||||
return
|
||||
}
|
||||
await workspaceRequestWithTimeout(
|
||||
(signal) =>
|
||||
sdk.client.experimental.controlPlane.moveSession(
|
||||
{
|
||||
sessionID,
|
||||
destination: { directory: destination },
|
||||
moveChanges: true,
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
language.t("workspace.move.failed"),
|
||||
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
)
|
||||
.then(async () => {
|
||||
for (const attempt of Array.from({ length: 20 }, (_, index) => index)) {
|
||||
const session = await workspaceRequestWithTimeout(
|
||||
(signal) => sync.session.resolve(sessionID, { force: true, signal }),
|
||||
language.t("workspace.move.failed"),
|
||||
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
|
||||
).catch(() => undefined)
|
||||
if (session && pathKey(session.directory) === pathKey(destination)) {
|
||||
WorkspaceOperation.complete(scope, sessionID, destination)
|
||||
sync.reindexSession(sessionID, source)
|
||||
return
|
||||
}
|
||||
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(250 + attempt * 50, 1_000)))
|
||||
}
|
||||
fail(scope, sessionID, language.t("workspace.move.failed"))
|
||||
})
|
||||
.catch((error) =>
|
||||
fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed")),
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
|
||||
<Icon name="workspace-new" />
|
||||
{language.t("workspace.new")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
<Show when={workspaces().length > 0}>
|
||||
<MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
|
||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<MenuV2.SubTrigger>
|
||||
<Icon name="workspace-isolated" />
|
||||
{language.t("session.new.workspace.existing").replace(/…$/, "")}
|
||||
</MenuV2.SubTrigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.SubContent class="w-[200px]">
|
||||
<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.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
|
||||
}
|
||||
@@ -289,9 +289,9 @@ export function SessionHeader() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={search() && centerMount()}>
|
||||
<Show when={search() && centerMount()} keyed>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<Portal mount={mount}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -308,10 +308,10 @@ export function SessionHeader() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={hotkey()}>
|
||||
<Show when={hotkey()} keyed>
|
||||
{(keybind) => (
|
||||
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
|
||||
{keybind()}
|
||||
{keybind}
|
||||
</Keybind>
|
||||
)}
|
||||
</Show>
|
||||
@@ -319,9 +319,9 @@ export function SessionHeader() {
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={rightMount()}>
|
||||
<Show when={rightMount()} keyed>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<Portal mount={mount}>
|
||||
<Show
|
||||
when={isV2}
|
||||
fallback={
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "@/context/settings"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "./link"
|
||||
import { ExternalLink } from "./external-link"
|
||||
import { SettingsList } from "./settings-list"
|
||||
|
||||
let demoSoundState = {
|
||||
@@ -482,7 +482,7 @@ export const SettingsGeneral: Component = () => {
|
||||
description={
|
||||
<>
|
||||
{language.t("settings.general.row.theme.description")}{" "}
|
||||
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
|
||||
<ExternalLink href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</ExternalLink>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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="fork" />
|
||||
{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,8 +8,8 @@ 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 { Link } from "../link"
|
||||
import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings"
|
||||
import { ExternalLink } from "../external-link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||
@@ -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(() =>
|
||||
@@ -150,9 +178,9 @@ const AppearanceSection: Component<{ controller: AppearanceSettingsController }>
|
||||
description={
|
||||
<>
|
||||
{language.t("settings.general.row.theme.description")}{" "}
|
||||
<Link class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
||||
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
||||
{language.t("common.learnMore")}
|
||||
</Link>
|
||||
</ExternalLink>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -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> }),
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
}
|
||||
|
||||
.settings-v2-provider-env-hint {
|
||||
padding-right: 12px;
|
||||
padding-inline-end: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
@@ -311,7 +311,7 @@
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-accent);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.settings-v2-providers-view-all:hover {
|
||||
@@ -361,13 +361,13 @@
|
||||
}
|
||||
|
||||
.settings-v2-tab-search [data-slot="text-input-v2-input"] {
|
||||
padding-right: 28px;
|
||||
padding-inline-end: 28px;
|
||||
}
|
||||
|
||||
.settings-v2-tab-search-clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 6px;
|
||||
inset-inline-end: 6px;
|
||||
z-index: 1;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
@@ -684,6 +684,189 @@
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-tab-header.settings-v2-workspaces-header {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings-v2-tab-body.settings-v2-workspaces {
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar,
|
||||
.settings-v2-workspaces-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-count {
|
||||
font-size: 13px;
|
||||
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;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-row:not(:last-child) {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.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: var(--font-family-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 440;
|
||||
line-height: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-meta,
|
||||
.settings-v2-workspaces-active,
|
||||
.settings-v2-workspaces-more {
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-active {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-sessions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 16px;
|
||||
color: var(--v2-text-text-base);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session > span:first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session-active {
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.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,476 @@
|
||||
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,
|
||||
runWorkspaceDeleteTransaction,
|
||||
sessionsForWorkspace,
|
||||
type WorkspaceDeleteTransaction,
|
||||
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",
|
||||
deleting: undefined as string | undefined,
|
||||
deletingAll: false,
|
||||
transaction: undefined as WorkspaceDeleteTransaction,
|
||||
})
|
||||
|
||||
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.t(count === 1 ? "settings.workspaces.sessions.one" : "settings.workspaces.sessions.other", {
|
||||
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 inspect = async (workspace: Workspace, context = captureDeleteContext()) => {
|
||||
const [status, sessions] = await Promise.all([
|
||||
Promise.all([
|
||||
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
|
||||
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
|
||||
])
|
||||
.then(([working, branch]) =>
|
||||
working.data.length > 0 || branch.data.length > 0 ? ("dirty" as const) : ("clean" as const),
|
||||
)
|
||||
.catch(() => "unknown" as const),
|
||||
loadSessions(context).catch(() => undefined),
|
||||
])
|
||||
const result = inspectWorkspaceDeletion({
|
||||
workspace: workspace.directory,
|
||||
activeDirectory: context.activeDirectory,
|
||||
sessions: sessions ?? [],
|
||||
status: sessions ? status : "unknown",
|
||||
})
|
||||
return { result, sessions: 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")
|
||||
if (result === "unknown") return language.t("workspace.status.error")
|
||||
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, bulk = false, context = captureDeleteContext()) => {
|
||||
if (!bulk) setStore("deleting", pathKey(workspace.directory))
|
||||
const preflight = await inspect(workspace, context)
|
||||
if (preflight.result !== "safe" && (bulk || preflight.result !== "dirty")) {
|
||||
blocked(preflight.result)
|
||||
if (!bulk) setStore("deleting", undefined)
|
||||
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 (!bulk) setStore("deleting", undefined)
|
||||
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)
|
||||
}
|
||||
let transactionID = 0
|
||||
const transact = async (task: () => Promise<void>) => {
|
||||
await runWorkspaceDeleteTransaction({
|
||||
token: ++transactionID,
|
||||
set: (update) => setStore("transaction", update),
|
||||
task: async () => {
|
||||
try {
|
||||
await task()
|
||||
} finally {
|
||||
setStore({ deleting: undefined, deletingAll: false })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
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, false, context))}
|
||||
/>
|
||||
),
|
||||
releaseConfirmation,
|
||||
)
|
||||
}
|
||||
const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
|
||||
setStore("deletingAll", true)
|
||||
await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, true, 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.t(
|
||||
filtered().length === 1 ? "settings.workspaces.count.one" : "settings.workspaces.count.other",
|
||||
{
|
||||
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-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>
|
||||
<Show when={lastActive(workspace)}>
|
||||
{(value) => <span class="settings-v2-workspaces-active">{value()}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
|
||||
<Show when={linked().length > 0}>
|
||||
<div class="settings-v2-workspaces-sessions">
|
||||
<For each={linked()}>
|
||||
{(session, index) => (
|
||||
<span class="settings-v2-workspaces-session">
|
||||
<span>{session.title}</span>
|
||||
<Show when={index() === 0}>
|
||||
<span class="settings-v2-workspaces-session-active">
|
||||
{language.t("settings.workspaces.lastActiveSession")}
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
aria-label={language.t("workspace.delete.confirm", { name: getFilename(workspace.directory) })}
|
||||
disabled={!!store.transaction || store.deleting === pathKey(workspace.directory)}
|
||||
icon={<Icon name="trash" size="small" />}
|
||||
onClick={() => confirmDelete(workspace)}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -382,7 +382,11 @@ export const Terminal = (props: TerminalProps) => {
|
||||
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
platform.openLink(text)
|
||||
if (URL.canParse(text) && new URL(text).protocol === "file:" && platform.openLocalFile) {
|
||||
platform.openLocalFile(text)
|
||||
return
|
||||
}
|
||||
platform.openExternal(text)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -569,9 +573,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
if (!result) return
|
||||
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
|
||||
if (result.response.status === 404 || result.response.status === 405) return
|
||||
if (result.response.status === 403)
|
||||
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
|
||||
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
|
||||
if (result.response.status === 403) throw new Error(language.t("terminal.connectTicket.csrfError"))
|
||||
throw new Error(language.t("terminal.connectTicket.statusError", { status: result.response.status }))
|
||||
}
|
||||
// return sdk()
|
||||
// .api.pty.connectToken({
|
||||
@@ -738,6 +741,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
<div
|
||||
ref={container}
|
||||
data-component="terminal"
|
||||
dir="ltr"
|
||||
data-prevent-autofocus
|
||||
tabIndex={-1}
|
||||
style={{ "background-color": terminalColors().background }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[data-titlebar-tab] [data-slot="tab-close"] {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
inset-inline-end: 4px;
|
||||
display: flex;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
@@ -52,7 +52,7 @@
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: -3.75px;
|
||||
inset-inline-start: -3.75px;
|
||||
width: 1.5px;
|
||||
height: 12px;
|
||||
border-radius: 9999px;
|
||||
@@ -86,6 +86,23 @@
|
||||
);
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to left,
|
||||
black 0,
|
||||
black calc(100% - var(--tab-title-fade-offset) - 16px),
|
||||
transparent calc(100% - var(--tab-title-fade-offset)),
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to left,
|
||||
black 0,
|
||||
black calc(100% - var(--tab-title-fade-offset) - 16px),
|
||||
transparent calc(100% - var(--tab-title-fade-offset)),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
|
||||
[data-slot="tab-link"] {
|
||||
--tab-title-fade-offset: 24px;
|
||||
@@ -93,7 +110,7 @@
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
|
||||
[data-slot="tab-link"] {
|
||||
padding-right: 0;
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] [data-slot="tab-title"] {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createMutation } from "@tanstack/solid-query"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
@@ -224,6 +225,7 @@ export function TabNavItem(props: {
|
||||
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
|
||||
<Show
|
||||
when={props.session()}
|
||||
keyed
|
||||
fallback={
|
||||
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
||||
}
|
||||
@@ -231,8 +233,8 @@ export function TabNavItem(props: {
|
||||
{(session) => (
|
||||
<SessionTabAvatar
|
||||
project={project()}
|
||||
directory={session().directory}
|
||||
sessionId={session().id}
|
||||
directory={session.directory}
|
||||
sessionId={session.id}
|
||||
server={props.server}
|
||||
/>
|
||||
)}
|
||||
@@ -322,6 +324,7 @@ export function DraftTabItem(props: {
|
||||
pressed?: boolean
|
||||
hidden?: boolean
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -396,7 +399,7 @@ export function DraftTabItem(props: {
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label="Close tab"
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { createEffect, createMemo, createResource, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
Match,
|
||||
on,
|
||||
onMount,
|
||||
Show,
|
||||
Switch,
|
||||
untrack,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -42,8 +53,11 @@ export type TitlebarUpdate = {
|
||||
}
|
||||
|
||||
export function useTitlebarRightMount() {
|
||||
const language = useLanguage()
|
||||
const [mount, setMount] = createSignal<HTMLElement | null>(null)
|
||||
onMount(() => setMount(document.getElementById("opencode-titlebar-right")))
|
||||
const sync = () => setMount(document.getElementById("opencode-titlebar-right"))
|
||||
onMount(sync)
|
||||
createEffect(on(language.direction, sync, { defer: true }))
|
||||
return mount
|
||||
}
|
||||
|
||||
@@ -113,9 +127,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
return {
|
||||
visible: version !== undefined || installing,
|
||||
installing,
|
||||
label: "Update",
|
||||
label: language.t("titlebar.update"),
|
||||
ariaLabel: language.t("toast.update.action.installRestart"),
|
||||
title: version ? `Update ${version}` : undefined,
|
||||
title: version ? language.t("titlebar.updateVersion", { version }) : undefined,
|
||||
onInstall: () => props.update?.install(),
|
||||
}
|
||||
})
|
||||
@@ -169,7 +183,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
"padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0,
|
||||
width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||
"max-width": windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||
"align-self": windows() ? "flex-start" : undefined,
|
||||
// Native Windows caption controls remain on the physical right in both writing directions.
|
||||
"margin-right": windows() ? "auto" : undefined,
|
||||
}}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
@@ -515,7 +530,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
<div
|
||||
class="flex items-center shrink-0"
|
||||
classList={{
|
||||
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
|
||||
"ltr:-translate-x-[36px] rtl:translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
|
||||
"duration-180 ease-out": !layout.sidebar.opened(),
|
||||
"duration-180 ease-in": layout.sidebar.opened(),
|
||||
}}
|
||||
|
||||
@@ -55,7 +55,7 @@ const DrawerContent = <T extends ValidComponent = "div">(props: DynamicProps<T,
|
||||
<DrawerPrimitive.Content
|
||||
class={props.class}
|
||||
classList={{
|
||||
"group/drawer-content fixed inset-y-[6px] right-[6px] left-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true,
|
||||
"group/drawer-content fixed inset-y-[6px] end-[6px] start-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export function WindowsAppMenu(props: {
|
||||
command: ReturnType<typeof useCommand>
|
||||
@@ -15,6 +16,7 @@ export function WindowsAppMenu(props: {
|
||||
variant?: "legacy" | "v2"
|
||||
}) {
|
||||
let lastFocused: HTMLElement | undefined
|
||||
const language = useLanguage()
|
||||
|
||||
const rememberFocus = () => {
|
||||
const active = document.activeElement
|
||||
@@ -43,7 +45,7 @@ export function WindowsAppMenu(props: {
|
||||
runAction(entry.action)
|
||||
return
|
||||
}
|
||||
if (entry.href) props.platform.openLink(entry.href)
|
||||
if (entry.href) props.platform.openExternal(entry.href)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -58,7 +60,7 @@ export function WindowsAppMenu(props: {
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="menu" />}
|
||||
aria-label="OpenCode menu"
|
||||
aria-label={language.t("desktop.menu.ariaLabel")}
|
||||
onPointerDown={rememberFocus}
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
@@ -69,7 +71,7 @@ export function WindowsAppMenu(props: {
|
||||
icon="menu"
|
||||
variant="ghost"
|
||||
class="titlebar-icon rounded-md shrink-0"
|
||||
aria-label="OpenCode menu"
|
||||
aria-label={language.t("desktop.menu.ariaLabel")}
|
||||
onPointerDown={rememberFocus}
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
@@ -79,7 +81,7 @@ export function WindowsAppMenu(props: {
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
|
||||
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
|
||||
<DesktopMenuSubmenu label={menu.label}>
|
||||
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
|
||||
{menu.items
|
||||
?.filter((entry) => desktopMenuVisible(entry, "windows"))
|
||||
.map((entry) =>
|
||||
@@ -87,7 +89,7 @@ export function WindowsAppMenu(props: {
|
||||
<DropdownMenu.Separator />
|
||||
) : (
|
||||
<DesktopMenuItem
|
||||
label={entry.label ?? ""}
|
||||
label={entry.labelKey ? language.t(entry.labelKey) : ""}
|
||||
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
|
||||
disabled={entry.command ? commandDisabled(entry.command) : false}
|
||||
onSelect={() => runEntry(entry)}
|
||||
|
||||
@@ -83,7 +83,7 @@ export const ACCEPTED_FILE_EXTENSIONS = Array.from(
|
||||
),
|
||||
).sort()
|
||||
|
||||
export function filePickerFilters(ext?: string[]) {
|
||||
export function filePickerFilters(name: string, ext?: string[]) {
|
||||
if (!ext || ext.length === 0) return undefined
|
||||
return [{ name: "Files", extensions: ext }]
|
||||
return [{ name, extensions: ext }]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
bootstrapDirectory,
|
||||
loadAgentsQuery,
|
||||
loadCommands,
|
||||
loadGlobalConfigQuery,
|
||||
loadPathQuery,
|
||||
loadProjectsQuery,
|
||||
loadProvidersQuery,
|
||||
@@ -76,6 +77,7 @@ function directoryState() {
|
||||
|
||||
describe("bootstrapDirectory", () => {
|
||||
test("uses legacy MCP endpoints while refreshing a v1 directory", async () => {
|
||||
const legacyConfigReads: string[] = []
|
||||
const mcpReads: string[] = []
|
||||
const [store, setStore] = directoryState()
|
||||
|
||||
@@ -91,7 +93,12 @@ describe("bootstrapDirectory", () => {
|
||||
},
|
||||
sdk: {
|
||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
config: {
|
||||
get: async () => {
|
||||
legacyConfigReads.push("directory")
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
session: { status: async () => ({ data: {} }) },
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: {
|
||||
@@ -134,8 +141,88 @@ describe("bootstrapDirectory", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
|
||||
expect(store.status).toBe("complete")
|
||||
expect(legacyConfigReads).toEqual(["directory"])
|
||||
expect(mcpReads.sort()).toEqual(["command", "resource", "status"])
|
||||
})
|
||||
|
||||
test("skips legacy config while refreshing a v2 directory", async () => {
|
||||
const [store, setStore] = directoryState()
|
||||
|
||||
await bootstrapDirectory({
|
||||
directory: "/project",
|
||||
scope: ServerScope.local,
|
||||
mcp: false,
|
||||
global: {
|
||||
config: {} satisfies Config,
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
project: [{ id: "project", worktree: "/project" } as Project],
|
||||
provider,
|
||||
},
|
||||
sdk: {
|
||||
config: {
|
||||
get: async () => {
|
||||
throw new Error("legacy directory config should not be called")
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient,
|
||||
api,
|
||||
store,
|
||||
setStore,
|
||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||
loadSessions() {},
|
||||
translate: (key) => key,
|
||||
queryClient: new QueryClient(),
|
||||
protocol: Promise.resolve("v2"),
|
||||
})
|
||||
|
||||
expect(store.status).toBe("partial")
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
|
||||
expect(store.status).toBe("complete")
|
||||
})
|
||||
})
|
||||
|
||||
describe("config queries", () => {
|
||||
test("skips legacy global config for v2 servers", async () => {
|
||||
const sdk = {
|
||||
global: {
|
||||
config: {
|
||||
get: async () => {
|
||||
throw new Error("legacy global config should not be called")
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
|
||||
const result = await new QueryClient().fetchQuery(
|
||||
loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v2")),
|
||||
)
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test("loads legacy global config for v1 servers", async () => {
|
||||
const calls: string[] = []
|
||||
const config = { shell: "zsh" } satisfies Config
|
||||
const sdk = {
|
||||
global: {
|
||||
config: {
|
||||
get: async () => {
|
||||
calls.push("global")
|
||||
return { data: config }
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
|
||||
const result = await new QueryClient().fetchQuery(
|
||||
loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v1")),
|
||||
)
|
||||
|
||||
expect(result).toEqual(config)
|
||||
expect(calls).toEqual(["global"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("query keys", () => {
|
||||
|
||||
@@ -105,10 +105,13 @@ function showErrors(input: {
|
||||
})
|
||||
}
|
||||
|
||||
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
||||
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient, protocol?: Promise<ServerProtocol>) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, "config"],
|
||||
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
||||
queryFn: async () => {
|
||||
if ((await protocol) !== "v1") return {}
|
||||
return retry(() => sdk.global.config.get().then((x) => x.data!))
|
||||
},
|
||||
})
|
||||
|
||||
type ProjectApi = {
|
||||
@@ -149,12 +152,6 @@ export async function bootstrapGlobal(input: {
|
||||
queryClient: QueryClient
|
||||
}) {
|
||||
const slow = [
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
||||
() =>
|
||||
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))
|
||||
@@ -376,7 +373,10 @@ export async function bootstrapDirectory(input: {
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
|
||||
.then((data) => input.setStore("agent", data)),
|
||||
() =>
|
||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
||||
retry(async () => {
|
||||
if ((await input.protocol) !== "v1") return
|
||||
return input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))
|
||||
}),
|
||||
() =>
|
||||
retry(() =>
|
||||
(async () => {
|
||||
@@ -518,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,
|
||||
|
||||
@@ -208,6 +208,57 @@ describe("applyDirectoryEvent", () => {
|
||||
expect(store.sessionTotal).toBe(2)
|
||||
})
|
||||
|
||||
test("moves a root between loaded directory indexes exactly once", () => {
|
||||
const info = { ...rootSession({ id: "session" }), directory: "/destination" }
|
||||
const [source, setSource] = createStore(
|
||||
baseState({ session: [{ ...info, directory: "/source" }], sessionTotal: 1 }),
|
||||
)
|
||||
const [destination, setDestination] = createStore(baseState({ session: [], sessionTotal: 0 }))
|
||||
const created = { type: "session.created", properties: { info } }
|
||||
const moved = {
|
||||
type: "session.moved",
|
||||
properties: { sessionID: info.id, location: { directory: info.directory } },
|
||||
}
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: created,
|
||||
store: destination,
|
||||
setStore: setDestination,
|
||||
push() {},
|
||||
directory: "/destination",
|
||||
loadLsp() {},
|
||||
})
|
||||
applyDirectoryEvent({
|
||||
event: moved,
|
||||
store: source,
|
||||
setStore: setSource,
|
||||
push() {},
|
||||
directory: "/source",
|
||||
loadLsp() {},
|
||||
})
|
||||
applyDirectoryEvent({
|
||||
event: created,
|
||||
store: destination,
|
||||
setStore: setDestination,
|
||||
push() {},
|
||||
directory: "/destination",
|
||||
loadLsp() {},
|
||||
})
|
||||
applyDirectoryEvent({
|
||||
event: moved,
|
||||
store: source,
|
||||
setStore: setSource,
|
||||
push() {},
|
||||
directory: "/source",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(source.session).toEqual([])
|
||||
expect(source.sessionTotal).toBe(0)
|
||||
expect(destination.session.map((session) => session.id)).toEqual([info.id])
|
||||
expect(destination.sessionTotal).toBe(1)
|
||||
})
|
||||
|
||||
test("cleans session caches when archived", () => {
|
||||
const message = userMessage("msg_1", "ses_1")
|
||||
const [store, setStore] = createStore(
|
||||
|
||||
@@ -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,58 +2,41 @@ 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 { Persist, persisted } from "@/utils/persist"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||
import {
|
||||
createDesktopNativeBundle,
|
||||
DESKTOP_NATIVE_ENGLISH,
|
||||
DESKTOP_NATIVE_LOCALES,
|
||||
type DesktopNativeBundle,
|
||||
type DesktopNativeLocale,
|
||||
} from "@/i18n/desktop-native"
|
||||
|
||||
export type Locale =
|
||||
| "en"
|
||||
| "zh"
|
||||
| "zht"
|
||||
| "ko"
|
||||
| "de"
|
||||
| "es"
|
||||
| "fr"
|
||||
| "da"
|
||||
| "ja"
|
||||
| "pl"
|
||||
| "ru"
|
||||
| "uk"
|
||||
| "ar"
|
||||
| "no"
|
||||
| "br"
|
||||
| "th"
|
||||
| "bs"
|
||||
| "tr"
|
||||
export type Locale = DesktopNativeLocale
|
||||
export type Direction = "ltr" | "rtl"
|
||||
|
||||
const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "ur", "pa"])
|
||||
|
||||
function localeDirection(locale: Locale): Direction {
|
||||
return RTL_LOCALES.has(locale) ? "rtl" : "ltr"
|
||||
}
|
||||
|
||||
type RawDictionary = typeof en & typeof uiEn
|
||||
type Dictionary = i18n.Flatten<RawDictionary>
|
||||
type PluralKey =
|
||||
| UiI18nPluralKey
|
||||
| "session.question.pending"
|
||||
| "session.followupDock.summary"
|
||||
| "session.revertDock.summary"
|
||||
type Source = { dict: Record<string, string> }
|
||||
|
||||
function cookie(locale: Locale) {
|
||||
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
|
||||
}
|
||||
|
||||
const LOCALES: readonly Locale[] = [
|
||||
"en",
|
||||
"zh",
|
||||
"zht",
|
||||
"ko",
|
||||
"de",
|
||||
"es",
|
||||
"fr",
|
||||
"da",
|
||||
"ja",
|
||||
"pl",
|
||||
"ru",
|
||||
"uk",
|
||||
"bs",
|
||||
"ar",
|
||||
"no",
|
||||
"br",
|
||||
"th",
|
||||
"tr",
|
||||
]
|
||||
const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES
|
||||
|
||||
const INTL: Record<Locale, string> = {
|
||||
en: "en",
|
||||
@@ -74,9 +57,19 @@ const INTL: Record<Locale, string> = {
|
||||
th: "th",
|
||||
bs: "bs",
|
||||
tr: "tr",
|
||||
hi: "hi-IN",
|
||||
nl: "nl-NL",
|
||||
id: "id-ID",
|
||||
vi: "vi-VN",
|
||||
it: "it-IT",
|
||||
ur: "ur-PK",
|
||||
pa: "pa-Arab-PK",
|
||||
az: "az-Latn-AZ",
|
||||
fi: "fi-FI",
|
||||
sv: "sv-SE",
|
||||
}
|
||||
|
||||
const LABEL_KEY: Record<Locale, keyof Dictionary> = {
|
||||
const LABEL_KEY: Partial<Record<Locale, keyof Dictionary>> = {
|
||||
en: "language.en",
|
||||
zh: "language.zh",
|
||||
zht: "language.zht",
|
||||
@@ -97,6 +90,19 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
|
||||
tr: "language.tr",
|
||||
}
|
||||
|
||||
const LABEL: Partial<Record<Locale, string>> = {
|
||||
hi: "हिन्दी",
|
||||
nl: "Nederlands",
|
||||
id: "Bahasa Indonesia",
|
||||
vi: "Tiếng Việt",
|
||||
it: "Italiano",
|
||||
ur: "اردو",
|
||||
pa: "پنجابی",
|
||||
az: "Azərbaycanca",
|
||||
fi: "Suomi",
|
||||
sv: "Svenska",
|
||||
}
|
||||
|
||||
const base = i18n.flatten({ ...en, ...uiEn })
|
||||
const dicts = new Map<Locale, Dictionary>([["en", base]])
|
||||
|
||||
@@ -121,6 +127,16 @@ const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
|
||||
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
|
||||
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
|
||||
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
|
||||
hi: () => merge(import("@/i18n/hi"), import("@opencode-ai/ui/i18n/hi")),
|
||||
nl: () => merge(import("@/i18n/nl"), import("@opencode-ai/ui/i18n/nl")),
|
||||
id: () => merge(import("@/i18n/id"), import("@opencode-ai/ui/i18n/id")),
|
||||
vi: () => merge(import("@/i18n/vi"), import("@opencode-ai/ui/i18n/vi")),
|
||||
it: () => merge(import("@/i18n/it"), import("@opencode-ai/ui/i18n/it")),
|
||||
ur: () => merge(import("@/i18n/ur"), import("@opencode-ai/ui/i18n/ur")),
|
||||
pa: () => merge(import("@/i18n/pa"), import("@opencode-ai/ui/i18n/pa")),
|
||||
az: () => merge(import("@/i18n/az"), import("@opencode-ai/ui/i18n/az")),
|
||||
fi: () => merge(import("@/i18n/fi"), import("@opencode-ai/ui/i18n/fi")),
|
||||
sv: () => merge(import("@/i18n/sv"), import("@opencode-ai/ui/i18n/sv")),
|
||||
}
|
||||
|
||||
function loadDict(locale: Locale) {
|
||||
@@ -140,7 +156,12 @@ export function loadLocaleDict(locale: Locale) {
|
||||
|
||||
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
||||
{ locale: "en", match: (language) => language.startsWith("en") },
|
||||
{ locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
|
||||
{
|
||||
locale: "zht",
|
||||
match: (language) =>
|
||||
language.startsWith("zh") &&
|
||||
(language.includes("hant") || language.includes("-tw") || language.includes("-hk") || language.includes("-mo")),
|
||||
},
|
||||
{ locale: "zh", match: (language) => language.startsWith("zh") },
|
||||
{ locale: "ko", match: (language) => language.startsWith("ko") },
|
||||
{ locale: "de", match: (language) => language.startsWith("de") },
|
||||
@@ -160,6 +181,22 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
|
||||
{ locale: "th", match: (language) => language.startsWith("th") },
|
||||
{ locale: "bs", match: (language) => language.startsWith("bs") },
|
||||
{ locale: "tr", match: (language) => language.startsWith("tr") },
|
||||
{ locale: "hi", match: (language) => language.startsWith("hi") },
|
||||
{ locale: "nl", match: (language) => language.startsWith("nl") },
|
||||
{ locale: "id", match: (language) => language.startsWith("id") },
|
||||
{ locale: "vi", match: (language) => language.startsWith("vi") },
|
||||
{ locale: "it", match: (language) => language.startsWith("it") },
|
||||
{ locale: "ur", match: (language) => language.startsWith("ur") },
|
||||
{
|
||||
locale: "pa",
|
||||
match: (language) => language.startsWith("pa") && (language.includes("arab") || language.includes("-pk")),
|
||||
},
|
||||
{
|
||||
locale: "az",
|
||||
match: (language) => language.startsWith("az") && !language.includes("arab") && !language.includes("cyrl"),
|
||||
},
|
||||
{ locale: "fi", match: (language) => language.startsWith("fi") },
|
||||
{ locale: "sv", match: (language) => language.startsWith("sv") },
|
||||
]
|
||||
|
||||
function detectLocale(): Locale {
|
||||
@@ -199,7 +236,7 @@ if (warm !== "en") void loadDict(warm)
|
||||
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
|
||||
name: "Language",
|
||||
gate: false,
|
||||
init: (props: { locale?: Locale }) => {
|
||||
init: (props: { locale?: Locale; onNativeTranslations?: (bundle: DesktopNativeBundle) => void }) => {
|
||||
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("language", ["language.v1"]),
|
||||
@@ -210,6 +247,13 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
|
||||
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
||||
const intl = createMemo(() => INTL[locale()])
|
||||
const [layout, setLayout] = createStore({ direction: undefined as Direction | undefined })
|
||||
const direction = createMemo(() => layout.direction ?? localeDirection(locale()))
|
||||
const layoutLocale = createMemo(() => {
|
||||
if (!layout.direction) return intl()
|
||||
// Kobalte derives menu direction from locale rather than accepting a direction override.
|
||||
return layout.direction === "rtl" ? "ar" : "en"
|
||||
})
|
||||
|
||||
const [dict] = createResource(locale, loadDict, {
|
||||
initialValue: dicts.get(initial) ?? base,
|
||||
@@ -220,24 +264,53 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
params?: Record<string, string | number | boolean>,
|
||||
) => string
|
||||
|
||||
const label = (value: Locale) => t(LABEL_KEY[value])
|
||||
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => {
|
||||
const category = pluralCategory(intl(), count)
|
||||
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 })
|
||||
}
|
||||
|
||||
const label = (value: Locale) => {
|
||||
const key = LABEL_KEY[value]
|
||||
if (key) return t(key)
|
||||
return LABEL[value] ?? value
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document !== "object") return
|
||||
document.documentElement.lang = locale()
|
||||
document.cookie = cookie(locale())
|
||||
const value = locale()
|
||||
document.documentElement.lang = value
|
||||
document.documentElement.dir = direction()
|
||||
document.cookie = cookie(value)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.onNativeTranslations || dict.loading) return
|
||||
const current = dict()
|
||||
if (!current) return
|
||||
props.onNativeTranslations(
|
||||
createDesktopNativeBundle(locale(), (key) => current[key] ?? DESKTOP_NATIVE_ENGLISH[key]),
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
locale,
|
||||
intl,
|
||||
direction,
|
||||
layoutLocale,
|
||||
locales: LOCALES,
|
||||
label,
|
||||
t,
|
||||
plural,
|
||||
setLocale(next: Locale) {
|
||||
setStore("locale", normalizeLocale(next))
|
||||
},
|
||||
setDirection(next: Direction) {
|
||||
setLayout("direction", next === localeDirection(locale()) ? undefined : next)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { ServerSDK } from "./server-sdk"
|
||||
import type { ServerSync } from "./server-sync"
|
||||
@@ -118,6 +118,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
const global = useGlobal()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const navigate = useNavigate()
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
@@ -153,6 +154,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
platform,
|
||||
settings,
|
||||
language,
|
||||
navigate,
|
||||
}),
|
||||
}),
|
||||
owner ?? undefined,
|
||||
@@ -217,6 +219,7 @@ function createServerNotificationState(input: {
|
||||
platform: ReturnType<typeof usePlatform>
|
||||
settings: ReturnType<typeof useSettings>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
navigate: (href: string) => void
|
||||
}) {
|
||||
const serverSDK = () => input.sdk
|
||||
const serverSync = () => input.sync
|
||||
@@ -353,7 +356,9 @@ function createServerNotificationState(input: {
|
||||
|
||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
|
||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
|
||||
input.navigate(href),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -386,7 +391,7 @@ function createServerNotificationState(input: {
|
||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||
if (settings.notifications.errors()) {
|
||||
void platform.notify(language.t("notification.session.error.title"), description, href)
|
||||
void platform.notify(language.t("notification.session.error.title"), description, () => input.navigate(href))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { DesktopMenuAction } from "../desktop-menu"
|
||||
import { ServerConnection } from "./server"
|
||||
import type { WslServersPlatform } from "../wsl/types"
|
||||
import type { UpdaterPlatform } from "../updater"
|
||||
import type { DraftStore } from "@/utils/draft-store"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
@@ -31,26 +32,23 @@ type PlatformBase = {
|
||||
/** App version */
|
||||
version?: string
|
||||
|
||||
/** Open a URL in the default browser */
|
||||
openLink(url: string): void
|
||||
/** Open a web or mail URL in the default system application */
|
||||
openExternal(url: string): void
|
||||
|
||||
/** Open a local path in a local app (desktop only) */
|
||||
openPath?(path: string, app?: string): Promise<void>
|
||||
|
||||
/** Open a local file URL in its default app (desktop only) */
|
||||
openLocalFile?(url: string): void
|
||||
|
||||
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
|
||||
revealPath?(path: string): Promise<boolean>
|
||||
|
||||
/** Restart the app */
|
||||
restart(): Promise<void>
|
||||
|
||||
/** Navigate back in history */
|
||||
back(): void
|
||||
|
||||
/** Navigate forward in history */
|
||||
forward(): void
|
||||
|
||||
/** Send a system notification (optional deep link) */
|
||||
notify(title: string, description?: string, href?: string): Promise<void>
|
||||
/** Send a system notification */
|
||||
notify(title: string, description?: string, onClick?: () => void): Promise<void>
|
||||
|
||||
/** Open a native attachment picker and read selected files sequentially (desktop only) */
|
||||
openAttachmentPickerDialog?(
|
||||
@@ -67,6 +65,9 @@ type PlatformBase = {
|
||||
/** Storage mechanism, defaults to localStorage */
|
||||
storage?: (name?: string) => SyncStorage | AsyncStorage
|
||||
|
||||
/** Prompt drafts, history, and their blobs. */
|
||||
draftStore?: DraftStore
|
||||
|
||||
/** Stable platform window identity for window-scoped persistence */
|
||||
windowID?: string
|
||||
|
||||
@@ -91,9 +92,6 @@ type PlatformBase = {
|
||||
/** Set the preferred display backend (desktop only) */
|
||||
setDisplayBackend?(backend: DisplayBackend): Promise<void>
|
||||
|
||||
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
|
||||
parseMarkdown?(markdown: string): Promise<string>
|
||||
|
||||
/** Webview zoom level (desktop only) */
|
||||
webviewZoom?: Accessor<number>
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import type { BlobReference } from "@/utils/draft-store"
|
||||
import type { Platform } from "@/context/platform"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
@@ -37,7 +39,7 @@ export interface ImageAttachmentPart {
|
||||
filename: string
|
||||
sourcePath?: string
|
||||
mime: string
|
||||
dataUrl: string
|
||||
blob: BlobReference
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
||||
@@ -168,9 +170,9 @@ function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
|
||||
}
|
||||
|
||||
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
|
||||
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
|
||||
if ("draftID" in scope) return Persist.prompt(Persist.draft(scope.draftID, "prompt"))
|
||||
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
||||
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
||||
return Persist.prompt(Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]))
|
||||
}
|
||||
|
||||
function promptStore(initial?: InitialPrompt): PromptStore {
|
||||
@@ -235,17 +237,22 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<P
|
||||
return value
|
||||
}
|
||||
|
||||
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt) {
|
||||
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)))
|
||||
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt, platform?: Platform) {
|
||||
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)), platform)
|
||||
return { ready, ...createPromptStateValue(store, setStore) }
|
||||
}
|
||||
|
||||
export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) {
|
||||
return createPersistedPrompt(promptTarget(serverScope, scope), initial)
|
||||
export function createPromptSession(
|
||||
serverScope: ServerScope,
|
||||
scope: PromptScope,
|
||||
initial?: InitialPrompt,
|
||||
platform?: Platform,
|
||||
) {
|
||||
return createPersistedPrompt(promptTarget(serverScope, scope), initial, platform)
|
||||
}
|
||||
|
||||
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
|
||||
return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial)
|
||||
return createPersistedPrompt(Persist.prompt(Persist.draft(draftID, "prompt")), initial)
|
||||
}
|
||||
|
||||
export type PromptSession = ReturnType<typeof createPromptSession>
|
||||
|
||||
@@ -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,126 @@
|
||||
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" })
|
||||
})
|
||||
|
||||
test("ignores unrelated global compatibility events", () => {
|
||||
expect(
|
||||
adaptWorktreeCompatibilityEvent({
|
||||
directory: "/repo",
|
||||
payload: {
|
||||
id: "status",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "idle" } },
|
||||
} as Event,
|
||||
}),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test("resolves readiness in the server scope that received the event", () => {
|
||||
const directory = "/repo/worktree-ready"
|
||||
Worktree.pending(ServerScope.local, directory)
|
||||
|
||||
expect(
|
||||
applyWorktreeEvent(
|
||||
ServerScope.local,
|
||||
{ directory, payload: { id: "ready", type: "worktree.ready", properties: { name: "ready" } } as Event },
|
||||
"failed",
|
||||
),
|
||||
).toBe(true)
|
||||
expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "ready" })
|
||||
})
|
||||
|
||||
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("does not let duplicate readiness settle placement after creation", () => {
|
||||
const directory = "/repo/worktree-placement"
|
||||
Worktree.ready(ServerScope.local, directory)
|
||||
WorkspaceOperation.start(ServerScope.local, "session-worktree-placement", "create", directory)
|
||||
|
||||
applyWorktreeEvent(
|
||||
ServerScope.local,
|
||||
{ directory, payload: { id: "ready-again", type: "worktree.ready", properties: { name: "ready" } } as Event },
|
||||
"failed",
|
||||
)
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-worktree-placement")?.status).toBe("pending")
|
||||
})
|
||||
})
|
||||
|
||||
test("moved events complete the matching workspace operation", () => {
|
||||
WorkspaceOperation.start(ServerScope.local, "session-move", "move", "/workspace")
|
||||
applyWorkspaceOperationEvent(ServerScope.local, {
|
||||
directory: "/workspace",
|
||||
payload: {
|
||||
id: "moved",
|
||||
type: "session.next.moved",
|
||||
properties: {
|
||||
timestamp: Date.now(),
|
||||
sessionID: "session-move",
|
||||
location: { directory: "/workspace" },
|
||||
},
|
||||
} as Event,
|
||||
})
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-move")?.status).toBe("complete")
|
||||
})
|
||||
|
||||
test("current moved events complete the matching workspace operation", () => {
|
||||
WorkspaceOperation.start(ServerScope.local, "session-current-move", "move", "/workspace")
|
||||
applyWorkspaceOperationEvent(ServerScope.local, {
|
||||
directory: "/workspace",
|
||||
payload: adaptServerEvent({
|
||||
id: "moved-current",
|
||||
created: Date.now(),
|
||||
type: "session.moved",
|
||||
data: {
|
||||
sessionID: "session-current-move",
|
||||
location: { directory: "/workspace" },
|
||||
},
|
||||
} as OpenCodeEvent),
|
||||
})
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-current-move")?.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 { decodeVcsDiff } from "@/utils/vcs-diff-decoder"
|
||||
import { decodeSessionList } from "./session-message-decoder"
|
||||
import { Worktree } from "@/utils/worktree"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
|
||||
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,34 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
return output
|
||||
}
|
||||
|
||||
export function applyWorktreeEvent(scope: ServerScope, event: QueuedServerEvent, fallback: string) {
|
||||
if (event.payload.type === "worktree.ready") {
|
||||
const pending = Worktree.get(scope, event.directory)?.status === "pending"
|
||||
Worktree.ready(scope, event.directory)
|
||||
if (pending) WorkspaceOperation.completeCreate(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)
|
||||
WorkspaceOperation.failCreate(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 +223,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 +276,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 +293,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 +351,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 +361,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 +408,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
started = false
|
||||
generation++
|
||||
attempt?.abort()
|
||||
worktreeAttempt?.abort()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -346,7 +435,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 +521,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,47 @@ 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("does not reuse a hung signal-scoped placement refresh", async () => {
|
||||
let calls = 0
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => {
|
||||
calls++
|
||||
if (calls === 1) return new Promise<never>(() => {})
|
||||
return { data: { ...session("child"), directory: "/destination" } }
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const store = createServerSession(client)
|
||||
store.remember({ ...session("child"), directory: "/source" })
|
||||
const first = new AbortController()
|
||||
|
||||
void store.resolve("child", { force: true, signal: first.signal })
|
||||
first.abort()
|
||||
const result = await store.resolve("child", { force: true, signal: new AbortController().signal })
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(result.directory).toBe("/destination")
|
||||
expect(store.get("child")?.directory).toBe("/destination")
|
||||
})
|
||||
|
||||
test("loads session content through the server client", async () => {
|
||||
const ctx = setup({ root: session("root") })
|
||||
|
||||
@@ -343,6 +384,17 @@ describe("server session", () => {
|
||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||
})
|
||||
|
||||
test("indexes optimistic users for the current timeline projection", () => {
|
||||
const ctx = setup({})
|
||||
const message = userMessage("message-optimistic")
|
||||
|
||||
ctx.store.optimistic.add({ sessionID: "child", message, parts: [] })
|
||||
|
||||
expect(ctx.store.data.session_message.child.map((item) => item.id)).toEqual([message.id])
|
||||
ctx.store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
expect(ctx.store.data.session_message.child).toEqual([])
|
||||
})
|
||||
|
||||
test("backfills an assistant-only initial page through its user root", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
|
||||
@@ -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,27 @@
|
||||
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,
|
||||
shouldRefreshWorkspaceSessions,
|
||||
} 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 +111,87 @@ 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" })
|
||||
})
|
||||
|
||||
test("refreshes workspace inventory for current and V1 lifecycle events", () => {
|
||||
const current = adaptServerEvent({
|
||||
id: "event-current-move-refresh",
|
||||
created: 10,
|
||||
type: "session.moved",
|
||||
data: { sessionID: "session", location: { directory: "/destination" } },
|
||||
} as OpenCodeEvent)
|
||||
const legacy = {
|
||||
type: "session.next.moved",
|
||||
properties: { timestamp: 10, sessionID: "session", location: { directory: "/destination" } },
|
||||
} as Event
|
||||
|
||||
expect(shouldRefreshWorkspaceSessions(current)).toBe(true)
|
||||
expect(
|
||||
shouldRefreshWorkspaceSessions(
|
||||
adaptServerEvent({
|
||||
id: "event-current-rename-refresh",
|
||||
created: 10,
|
||||
type: "session.renamed",
|
||||
data: { sessionID: "session", title: "Renamed" },
|
||||
} as OpenCodeEvent),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(shouldRefreshWorkspaceSessions(legacy)).toBe(true)
|
||||
expect(
|
||||
shouldRefreshWorkspaceSessions({ type: "session.created", properties: { info: sessionAt("/source") } } as Event),
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldRefreshWorkspaceSessions({ type: "session.updated", properties: { info: sessionAt("/source") } } as Event),
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldRefreshWorkspaceSessions({ type: "session.deleted", properties: { info: sessionAt("/source") } } as Event),
|
||||
).toBe(true)
|
||||
expect(shouldRefreshWorkspaceSessions({ type: "server.connected", properties: {} } as Event)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pickDirectoriesToEvict", () => {
|
||||
test("keeps pinned stores and evicts idle stores", () => {
|
||||
const now = 5_000
|
||||
@@ -174,6 +264,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
|
||||
@@ -184,7 +211,7 @@ function makeQueryOptionsApi(
|
||||
protocol: Promise<"v1" | "v2">,
|
||||
) {
|
||||
return {
|
||||
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
|
||||
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK(), protocol),
|
||||
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
||||
providers: (directory: PathKey | null) =>
|
||||
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
|
||||
@@ -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
|
||||
|
||||
@@ -101,7 +103,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const removeDraftPersisted = (draftID: string) => {
|
||||
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
|
||||
for (const key of draftPersistedKeys()) {
|
||||
const target = Persist.draft(draftID, key)
|
||||
removePersisted(key === "prompt" ? Persist.prompt(target) : target, platform)
|
||||
}
|
||||
}
|
||||
|
||||
const removeInfo = (key: string) => {
|
||||
@@ -145,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
|
||||
@@ -174,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))
|
||||
@@ -354,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,12 +2,33 @@ 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.command === "tab.prev" || item.command === "tab.next"),
|
||||
)
|
||||
|
||||
expect(items).toEqual([
|
||||
{ type: "item", labelKey: "desktop.menu.previousTab", command: "tab.prev", accelerator: { macos: "Option+Left" } },
|
||||
{ type: "item", labelKey: "desktop.menu.nextTab", command: "tab.next", accelerator: { macos: "Option+Right" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("exports logs through the desktop command registry", () => {
|
||||
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||
(item) => item.type === "item" && item.label === "Export Logs...",
|
||||
(item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs",
|
||||
)
|
||||
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true)
|
||||
})
|
||||
|
||||
test("provides translated labels for role-backed entries", () => {
|
||||
const windowMenu = DESKTOP_MENU.find((menu) => menu.role === "windowMenu")
|
||||
const roleItems = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||
(item) => item.type === "item" && item.role && item.labelKey,
|
||||
)
|
||||
|
||||
expect(windowMenu?.labelKey).toBe("desktop.menu.window")
|
||||
expect(roleItems.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { DesktopNativeKey } from "./i18n/desktop-native"
|
||||
|
||||
export type DesktopMenuPlatform = "macos" | "windows"
|
||||
|
||||
export type DesktopMenuAction =
|
||||
@@ -44,7 +46,7 @@ export type DesktopMenuRole =
|
||||
|
||||
export type DesktopMenuItem = {
|
||||
type: "item"
|
||||
label?: string
|
||||
labelKey?: DesktopNativeKey
|
||||
command?: string
|
||||
action?: DesktopMenuAction
|
||||
role?: DesktopMenuRole
|
||||
@@ -63,7 +65,7 @@ export type DesktopMenuEntry = DesktopMenuItem | DesktopMenuSeparator
|
||||
|
||||
export type DesktopMenu = {
|
||||
id: string
|
||||
label: string
|
||||
labelKey: DesktopNativeKey
|
||||
role?: DesktopMenuRole
|
||||
items?: DesktopMenuEntry[]
|
||||
platforms?: DesktopMenuPlatform[]
|
||||
@@ -72,15 +74,20 @@ export type DesktopMenu = {
|
||||
export const DESKTOP_MENU: DesktopMenu[] = [
|
||||
{
|
||||
id: "app",
|
||||
label: "OpenCode",
|
||||
labelKey: "desktop.menu.app",
|
||||
platforms: ["macos"],
|
||||
items: [
|
||||
{ type: "item", role: "about" },
|
||||
{ type: "item", label: "Check for Updates...", action: "app.checkForUpdates", enabled: "updater" },
|
||||
{ type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
|
||||
{ type: "item", label: "Reload Webview", action: "view.reload" },
|
||||
{ type: "item", label: "Restart", action: "app.relaunch" },
|
||||
{ type: "item", label: "Export Logs...", command: "logs.export" },
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.checkForUpdates",
|
||||
action: "app.checkForUpdates",
|
||||
enabled: "updater",
|
||||
},
|
||||
{ type: "item", labelKey: "desktop.menu.settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
|
||||
{ type: "item", labelKey: "desktop.menu.reloadWebview", action: "view.reload" },
|
||||
{ type: "item", labelKey: "desktop.menu.restart", action: "app.relaunch" },
|
||||
{ type: "item", labelKey: "desktop.menu.exportLogs", command: "logs.export" },
|
||||
{ type: "separator" },
|
||||
{ type: "item", role: "hide" },
|
||||
{ type: "item", role: "hideOthers" },
|
||||
@@ -91,46 +98,81 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
||||
},
|
||||
{
|
||||
id: "file",
|
||||
label: "File",
|
||||
labelKey: "desktop.menu.file",
|
||||
items: [
|
||||
{
|
||||
type: "item",
|
||||
label: "New Session",
|
||||
labelKey: "desktop.menu.newSession",
|
||||
command: "session.new",
|
||||
accelerator: { macos: "Shift+Cmd+S" },
|
||||
},
|
||||
{ type: "item", label: "Open Project...", command: "project.open", accelerator: { macos: "Cmd+O" } },
|
||||
{
|
||||
type: "item",
|
||||
label: "Settings",
|
||||
labelKey: "desktop.menu.openProject",
|
||||
command: "project.open",
|
||||
accelerator: { macos: "Cmd+O" },
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.settings",
|
||||
command: "settings.open",
|
||||
accelerator: { windows: "Ctrl+," },
|
||||
platforms: ["windows"],
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: "New Window",
|
||||
labelKey: "desktop.menu.newWindow",
|
||||
action: "window.new",
|
||||
accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" },
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ type: "item", label: "Close Window", action: "window.close", role: "close" },
|
||||
{ type: "item", labelKey: "desktop.menu.closeWindow", action: "window.close", role: "close" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
label: "Edit",
|
||||
labelKey: "desktop.menu.edit",
|
||||
items: [
|
||||
{ type: "item", label: "Undo", action: "edit.undo", role: "undo", accelerator: { windows: "Ctrl+Z" } },
|
||||
{ type: "item", label: "Redo", action: "edit.redo", role: "redo", accelerator: { windows: "Ctrl+Y" } },
|
||||
{ type: "separator" },
|
||||
{ type: "item", label: "Cut", action: "edit.cut", role: "cut", accelerator: { windows: "Ctrl+X" } },
|
||||
{ type: "item", label: "Copy", action: "edit.copy", role: "copy", accelerator: { windows: "Ctrl+C" } },
|
||||
{ type: "item", label: "Paste", action: "edit.paste", role: "paste", accelerator: { windows: "Ctrl+V" } },
|
||||
{ type: "item", label: "Delete", action: "edit.delete" },
|
||||
{
|
||||
type: "item",
|
||||
label: "Select All",
|
||||
labelKey: "desktop.menu.undo",
|
||||
action: "edit.undo",
|
||||
role: "undo",
|
||||
accelerator: { windows: "Ctrl+Z" },
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.redo",
|
||||
action: "edit.redo",
|
||||
role: "redo",
|
||||
accelerator: { windows: "Ctrl+Y" },
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.cut",
|
||||
action: "edit.cut",
|
||||
role: "cut",
|
||||
accelerator: { windows: "Ctrl+X" },
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.copy",
|
||||
action: "edit.copy",
|
||||
role: "copy",
|
||||
accelerator: { windows: "Ctrl+C" },
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.paste",
|
||||
action: "edit.paste",
|
||||
role: "paste",
|
||||
accelerator: { windows: "Ctrl+V" },
|
||||
},
|
||||
{ type: "item", labelKey: "desktop.menu.delete", action: "edit.delete" },
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.selectAll",
|
||||
action: "edit.selectAll",
|
||||
role: "selectAll",
|
||||
accelerator: { windows: "Ctrl+A" },
|
||||
@@ -139,47 +181,87 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
||||
},
|
||||
{
|
||||
id: "view",
|
||||
label: "View",
|
||||
labelKey: "desktop.menu.view",
|
||||
items: [
|
||||
{ type: "item", label: "Toggle Sidebar", command: "sidebar.toggle" },
|
||||
{ type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } },
|
||||
{ type: "item", label: "Toggle File Tree", command: "fileTree.toggle" },
|
||||
{ type: "item", labelKey: "desktop.menu.toggleSidebar", command: "sidebar.toggle" },
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.toggleTerminal",
|
||||
command: "terminal.toggle",
|
||||
accelerator: { macos: "Ctrl+`" },
|
||||
},
|
||||
{ type: "item", labelKey: "desktop.menu.toggleFileTree", command: "fileTree.toggle" },
|
||||
{ type: "separator" },
|
||||
{ type: "item", label: "Reload", action: "view.reload", role: "reload" },
|
||||
{ type: "item", label: "Toggle Developer Tools", action: "view.toggleDevTools", role: "toggleDevTools" },
|
||||
{ type: "item", labelKey: "desktop.menu.reload", action: "view.reload", role: "reload" },
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.toggleDeveloperTools",
|
||||
action: "view.toggleDevTools",
|
||||
role: "toggleDevTools",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "item",
|
||||
label: "Actual Size",
|
||||
labelKey: "desktop.menu.actualSize",
|
||||
action: "view.resetZoom",
|
||||
role: "resetZoom",
|
||||
accelerator: { windows: "Ctrl+0" },
|
||||
},
|
||||
{ type: "item", label: "Zoom In", action: "view.zoomIn", role: "zoomIn", accelerator: { windows: "Ctrl++" } },
|
||||
{ type: "item", label: "Zoom Out", action: "view.zoomOut", role: "zoomOut", accelerator: { windows: "Ctrl+-" } },
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.zoomIn",
|
||||
action: "view.zoomIn",
|
||||
role: "zoomIn",
|
||||
accelerator: { windows: "Ctrl++" },
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.zoomOut",
|
||||
action: "view.zoomOut",
|
||||
role: "zoomOut",
|
||||
accelerator: { windows: "Ctrl+-" },
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ type: "item", label: "Toggle Full Screen", action: "view.toggleFullscreen", role: "togglefullscreen" },
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.toggleFullScreen",
|
||||
action: "view.toggleFullscreen",
|
||||
role: "togglefullscreen",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "go",
|
||||
label: "Go",
|
||||
labelKey: "desktop.menu.go",
|
||||
items: [
|
||||
{ type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
|
||||
{ type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
|
||||
{ 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", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } },
|
||||
{ type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } },
|
||||
{ type: "item", labelKey: "desktop.menu.previousTab", command: "tab.prev", accelerator: { macos: "Option+Left" } },
|
||||
{ type: "item", labelKey: "desktop.menu.nextTab", command: "tab.next", accelerator: { macos: "Option+Right" } },
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "item",
|
||||
label: "Previous Project",
|
||||
labelKey: "desktop.menu.previousSession",
|
||||
command: "session.previous",
|
||||
accelerator: { macos: "Option+Up" },
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.nextSession",
|
||||
command: "session.next",
|
||||
accelerator: { macos: "Option+Down" },
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.previousProject",
|
||||
command: "project.previous",
|
||||
accelerator: { macos: "Cmd+Option+Up" },
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: "Next Project",
|
||||
labelKey: "desktop.menu.nextProject",
|
||||
command: "project.next",
|
||||
accelerator: { macos: "Cmd+Option+Down" },
|
||||
},
|
||||
@@ -187,31 +269,31 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
||||
},
|
||||
{
|
||||
id: "window",
|
||||
label: "Window",
|
||||
labelKey: "desktop.menu.window",
|
||||
role: "windowMenu",
|
||||
items: [
|
||||
{ type: "item", label: "Minimize", action: "window.minimize" },
|
||||
{ type: "item", label: "Maximize", action: "window.toggleMaximize" },
|
||||
{ type: "item", labelKey: "desktop.menu.minimize", action: "window.minimize" },
|
||||
{ type: "item", labelKey: "desktop.menu.maximize", action: "window.toggleMaximize" },
|
||||
{ type: "separator" },
|
||||
{ type: "item", label: "Close Window", action: "window.close" },
|
||||
{ type: "item", labelKey: "desktop.menu.closeWindow", action: "window.close" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "help",
|
||||
label: "Help",
|
||||
labelKey: "desktop.menu.help",
|
||||
items: [
|
||||
{ type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" },
|
||||
{ type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" },
|
||||
{ type: "item", label: "Export Logs...", command: "logs.export" },
|
||||
{ type: "item", labelKey: "desktop.menu.documentation", href: "https://opencode.ai/docs" },
|
||||
{ type: "item", labelKey: "desktop.menu.supportForum", href: "https://discord.com/invite/opencode" },
|
||||
{ type: "item", labelKey: "desktop.menu.exportLogs", command: "logs.export" },
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "item",
|
||||
label: "Share Feedback",
|
||||
labelKey: "desktop.menu.shareFeedback",
|
||||
href: "https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: "Report a Bug",
|
||||
labelKey: "desktop.menu.reportBug",
|
||||
href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml",
|
||||
},
|
||||
],
|
||||
|
||||
+11
-16
@@ -4,9 +4,9 @@ import * as Sentry from "@sentry/solid"
|
||||
import { render } from "solid-js/web"
|
||||
import { AppBaseProviders, AppInterface } from "@/app"
|
||||
import { type Platform, PlatformProvider } from "@/context/platform"
|
||||
import { createBrowserDraftStore } from "@/utils/draft-store"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { dict as zh } from "@/i18n/zh"
|
||||
import { handleNotificationClick } from "@/utils/notification-click"
|
||||
import { authFromToken } from "@/utils/server"
|
||||
import pkg from "../package.json"
|
||||
import { ServerConnection } from "./context/server"
|
||||
@@ -54,7 +54,7 @@ const setStorage = (key: string, value: string | null) => {
|
||||
const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY)
|
||||
const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url)
|
||||
|
||||
const notify: Platform["notify"] = async (title, description, href) => {
|
||||
const notify: Platform["notify"] = async (title, description, onClick) => {
|
||||
if (!("Notification" in window)) return
|
||||
|
||||
const permission =
|
||||
@@ -73,21 +73,17 @@ const notify: Platform["notify"] = async (title, description, href) => {
|
||||
})
|
||||
|
||||
notification.onclick = () => {
|
||||
handleNotificationClick(href)
|
||||
window.focus()
|
||||
onClick?.()
|
||||
notification.close()
|
||||
}
|
||||
}
|
||||
|
||||
const openLink: Platform["openLink"] = (url) => {
|
||||
window.open(url, "_blank")
|
||||
}
|
||||
|
||||
const back: Platform["back"] = () => {
|
||||
window.history.back()
|
||||
}
|
||||
|
||||
const forward: Platform["forward"] = () => {
|
||||
window.history.forward()
|
||||
const openExternal: Platform["openExternal"] = (value) => {
|
||||
if (!URL.canParse(value)) return
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:" && url.protocol !== "mailto:") return
|
||||
window.open(url.href, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
const restart: Platform["restart"] = async () => {
|
||||
@@ -121,10 +117,9 @@ const clearAuthToken = () => {
|
||||
|
||||
const platform: Platform = {
|
||||
platform: "web",
|
||||
draftStore: createBrowserDraftStore(),
|
||||
version: pkg.version,
|
||||
openLink,
|
||||
back,
|
||||
forward,
|
||||
openExternal,
|
||||
restart,
|
||||
notify,
|
||||
getDefaultServer: async () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user