Compare commits

..

1 Commits

Author SHA1 Message Date
Brendan Allan 8bc53f67de fix(app): use current default model 2026-07-27 13:49:54 +08:00
635 changed files with 6965 additions and 81398 deletions
+6 -2
View File
@@ -219,6 +219,7 @@ jobs:
build-electron: build-electron:
needs: needs:
- build-cli
- version - version
if: github.repository == 'anomalyco/opencode' if: github.repository == 'anomalyco/opencode'
continue-on-error: false continue-on-error: false
@@ -315,14 +316,16 @@ jobs:
env: env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }} RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
- name: Build - name: Build
run: bun run build run: bun run build
working-directory: packages/desktop working-directory: packages/desktop
env: env:
NODE_OPTIONS: --max-old-space-size=4096 NODE_OPTIONS: --max-old-space-size=4096
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }} SENTRY_ORG: ${{ vars.SENTRY_ORG }}
@@ -340,7 +343,8 @@ jobs:
env: env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
GH_TOKEN: ${{ steps.committer.outputs.token }} GH_TOKEN: ${{ steps.committer.outputs.token }}
CSC_KEYCHAIN: build.keychain CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8 APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
@@ -1,63 +0,0 @@
---
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/)
+27
View File
@@ -252,6 +252,33 @@ 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. 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 ## Issue Requirements
All issues **must** use one of our issue templates: All issues **must** use one of our issue templates:
+44 -40
View File
@@ -29,7 +29,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@corvu/drawer": "catalog:", "@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0", "@dnd-kit/abstract": "0.5.0",
@@ -96,7 +96,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@opencode-ai/cli", "name": "@opencode-ai/cli",
"version": "1.18.15", "version": "1.18.6",
"bin": { "bin": {
"lildax": "./bin/lildax.cjs", "lildax": "./bin/lildax.cjs",
}, },
@@ -144,7 +144,7 @@
}, },
"packages/codemode": { "packages/codemode": {
"name": "@opencode-ai/codemode", "name": "@opencode-ai/codemode",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"acorn": "8.15.0", "acorn": "8.15.0",
"effect": "catalog:", "effect": "catalog:",
@@ -158,7 +158,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -194,7 +194,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -221,7 +221,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.82", "@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -243,7 +243,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -267,7 +267,7 @@
}, },
"packages/console/support": { "packages/console/support": {
"name": "@opencode-ai/console-support", "name": "@opencode-ai/console-support",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-core": "workspace:*",
@@ -287,7 +287,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.18.15", "version": "1.18.6",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -339,7 +339,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.1", "gitlab-ai-provider": "6.11.1",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@@ -381,16 +381,16 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"electron-context-menu": "4.1.2", "electron-context-menu": "4.1.2",
"electron-log": "^5", "electron-log": "^5",
"electron-store": "11.0.2", "electron-store": "11.0.2",
"electron-updater": "6.8.9", "electron-updater": "6.8.9",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"marked": "^15",
}, },
"devDependencies": { "devDependencies": {
"@actions/artifact": "4.0.0", "@actions/artifact": "4.0.0",
@@ -435,7 +435,7 @@
}, },
"packages/effect-drizzle-sqlite": { "packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite", "name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -449,7 +449,7 @@
}, },
"packages/effect-sqlite-node": { "packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node", "name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"effect": "catalog:", "effect": "catalog:",
}, },
@@ -461,7 +461,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@hono/standard-validator": "catalog:", "@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -493,7 +493,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -509,7 +509,7 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83",
@@ -540,7 +540,7 @@
}, },
"packages/llm": { "packages/llm": {
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14", "@smithy/eventstream-codec": "4.2.14",
@@ -559,7 +559,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.18.15", "version": "1.18.6",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -632,7 +632,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.1", "gitlab-ai-provider": "6.11.1",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@@ -690,7 +690,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -766,7 +766,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -781,7 +781,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@opencode-ai/server", "name": "@opencode-ai/server",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*", "@opencode-ai/protocol": "workspace:*",
@@ -796,7 +796,7 @@
}, },
"packages/session-ui": { "packages/session-ui": {
"name": "@opencode-ai/session-ui", "name": "@opencode-ai/session-ui",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
@@ -811,11 +811,15 @@
"@solid-primitives/media": "2.3.3", "@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"diff": "catalog:", "diff": "catalog:",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"katex": "0.16.27",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"morphdom": "2.7.8", "morphdom": "2.7.8",
"motion": "12.34.5", "motion": "12.34.5",
"remeda": "catalog:", "remeda": "catalog:",
@@ -828,6 +832,7 @@
"devDependencies": { "devDependencies": {
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/katex": "0.16.7",
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
@@ -836,7 +841,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -849,7 +854,7 @@
}, },
"packages/stats/app": { "packages/stats/app": {
"name": "@opencode-ai/stats-app", "name": "@opencode-ai/stats-app",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
@@ -883,7 +888,7 @@
}, },
"packages/stats/core": { "packages/stats/core": {
"name": "@opencode-ai/stats-core", "name": "@opencode-ai/stats-core",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@aws-sdk/client-athena": "3.933.0", "@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0", "@planetscale/database": "1.19.0",
@@ -902,7 +907,7 @@
}, },
"packages/stats/server": { "packages/stats/server": {
"name": "@opencode-ai/stats-server", "name": "@opencode-ai/stats-server",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@aws-sdk/client-firehose": "3.933.0", "@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
@@ -944,7 +949,7 @@
}, },
"packages/tui": { "packages/tui": {
"name": "@opencode-ai/tui", "name": "@opencode-ai/tui",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
@@ -971,7 +976,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
@@ -987,6 +992,7 @@
"katex": "0.16.27", "katex": "0.16.27",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
"morphdom": "2.7.8", "morphdom": "2.7.8",
"motion": "12.34.5", "motion": "12.34.5",
@@ -996,7 +1002,6 @@
"remend": "catalog:", "remend": "catalog:",
"shiki": "catalog:", "shiki": "catalog:",
"solid-list": "catalog:", "solid-list": "catalog:",
"solid-sonner": "catalog:",
"strip-ansi": "7.1.2", "strip-ansi": "7.1.2",
}, },
"devDependencies": { "devDependencies": {
@@ -1022,7 +1027,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.18.15", "version": "1.18.6",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -1077,8 +1082,6 @@
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.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": { "overrides": {
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
@@ -1133,7 +1136,7 @@
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
"luxon": "3.6.1", "luxon": "3.6.1",
"marked": "18.0.7", "marked": "17.0.6",
"marked-shiki": "1.2.1", "marked-shiki": "1.2.1",
"opentui-spinner": "0.0.7", "opentui-spinner": "0.0.7",
"remeda": "2.26.0", "remeda": "2.26.0",
@@ -1142,7 +1145,6 @@
"shiki": "4.2.0", "shiki": "4.2.0",
"solid-js": "1.9.10", "solid-js": "1.9.10",
"solid-list": "0.3.0", "solid-list": "0.3.0",
"solid-sonner": "0.3.1",
"sst": "4.13.1", "sst": "4.13.1",
"tailwindcss": "4.1.11", "tailwindcss": "4.1.11",
"typescript": "5.8.2", "typescript": "5.8.2",
@@ -3852,7 +3854,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="], "gitlab-ai-provider": ["gitlab-ai-provider@6.11.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -4314,7 +4316,9 @@
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@18.0.7", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA=="], "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-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="], "marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
@@ -5122,8 +5126,6 @@
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
"solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="],
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="], "solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="], "solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
@@ -6038,6 +6040,8 @@
"@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/@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/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=="], "@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=="],
+2 -6
View File
@@ -288,12 +288,8 @@ new sst.cloudflare.x.SolidStart("Console", {
server: { server: {
placement: { region: "aws:us-east-2" }, placement: { region: "aws:us-east-2" },
transform: { transform: {
worker: (args) => { worker: {
args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [ tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
...(flags ?? []),
"global_fetch_strictly_public",
])
args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }]
}, },
}, },
}, },
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-uduwrM143NDSc+tXsi4lVVfoMll2a3BDHRUjuO7GB68=", "x86_64-linux": "sha256-2suqkFAUeT2YgppKqP14s0C+GE3TR3uNKOOD8vWEiAg=",
"aarch64-linux": "sha256-6DUda78XdXY6DP86lIUkweSjys3iG4Y4mo1PiaNuXbg=", "aarch64-linux": "sha256-cJ/XYTodYmgs9ZFQhHQbfZPqxjIMmEvz062OsX6/6p4=",
"aarch64-darwin": "sha256-AkJwfLULLZVwwz+XU1QcFUZoIS7oVPCn+n/MXEaxrqE=", "aarch64-darwin": "sha256-tKyqHeI6r1aHEk4vBS39+eA6wh9KcIwsa2ctcktKkEc=",
"x86_64-darwin": "sha256-hAxKGdiITTxQ2uujQt6prNjo3NxGAMMeo+9HlMWK6GU=" "x86_64-darwin": "sha256-1u7+dNjRcqKSvUTCEarVJ9fMrRUyg9y+4seZAm6Fmus="
} }
} }
+2 -5
View File
@@ -70,7 +70,7 @@
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"luxon": "3.6.1", "luxon": "3.6.1",
"marked": "18.0.7", "marked": "17.0.6",
"marked-shiki": "1.2.1", "marked-shiki": "1.2.1",
"remend": "1.3.0", "remend": "1.3.0",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
@@ -90,7 +90,6 @@
"@sentry/solid": "10.36.0", "@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0", "@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.10", "solid-js": "1.9.10",
"solid-sonner": "0.3.1",
"vite-plugin-solid": "2.11.10", "vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.12" "@lydell/node-pty": "1.2.0-beta.12"
} }
@@ -144,7 +143,6 @@
"@types/node": "catalog:" "@types/node": "catalog:"
}, },
"patchedDependencies": { "patchedDependencies": {
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch", "@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
@@ -158,7 +156,6 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@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", "@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", "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"
} }
} }
-15
View File
@@ -19,21 +19,6 @@
- Always prefer `createStore` over multiple `createSignal` calls - 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.
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.
- Prefer complete translated phrases. Do not concatenate grammatical fragments or make call sites assemble sentences. Keep placeholders to irreducible dynamic values such as names, paths, and counts.
- If a translation cannot be expressed by the current API, deepen the shared language/UI i18n module so one typed call owns locale selection, plural resolution, fallback, and interpolation. Do not leak that machinery into product code.
- 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`.
- For developer-facing terminology, prefer the words already used by the target language's developer community over literal dictionary translations. Cross-check maintained localized developer products such as Firefox, KDE, and VS Code; use at least two independent corpora when they are available. If established practice keeps an English loanword or acronym, keep it rather than inventing a translation.
- Translate complete UI phrases in context. A glossary hit is evidence, not permission to translate word-by-word. Check terse labels such as session, prompt, agent, model, fork, shell, terminal, workspace, and worktree in the same grammatical role before choosing a term.
- Before a locale is ready, audit recurring concepts for one consistent translation and review every value that still equals English. Classify retained English as a product name, provider/tool name, URL, code token, keyboard legend, acronym, asset name, or established borrowing; translate unexplained leftovers.
- In translation review notes, name the corpora used and call out uncertain or region-specific terminology so native speakers can focus review where it matters.
- 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 ## Tool Calling
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
-16
View File
@@ -1,16 +0,0 @@
## 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.
@@ -197,9 +197,7 @@ export async function setupTimeline(
) )
}, },
async waitForPart(partID: string) { async waitForPart(partID: string) {
const part = page.locator(`[data-timeline-part-id="${partID}"]`) await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible()
await expect(part).toHaveCount(1)
await expect(part).toBeVisible()
}, },
} }
} }
@@ -1,132 +0,0 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/OpenFileExpand"
const projectID = "proj_open_file_expand"
const sessionID = "ses_open_file_expand"
const title = "Open file expand"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "open-file-expand",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: sessionID,
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [],
fileList: (path) => {
if (path === "frontend\\" || path === "frontend") {
return [
{
name: "app.ts",
path: "frontend\\app.ts",
absolute: `${directory}/frontend/app.ts`,
type: "file" as const,
ignored: false,
},
]
}
if (path) return []
return [
{
name: "frontend",
path: "frontend\\",
absolute: `${directory}/frontend`,
type: "directory" as const,
ignored: false,
},
{
name: "README.md",
path: "README.md",
absolute: `${directory}/README.md`,
type: "file" as const,
ignored: false,
},
]
},
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }),
)
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const panel = page.locator("#review-panel")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
await expect(sidebar).toBeVisible()
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
await expect(frontendRow).toBeVisible()
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
await frontendRow.click()
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
await expect(appRow).toBeVisible()
await appRow.click()
await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible()
})
@@ -1,60 +0,0 @@
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,7 +18,6 @@ test("opens the comment editor when code is clicked", async ({ page }) => {
await line.click() await line.click()
await expect(review.getByRole("textbox")).toBeVisible() 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 }) => { test("opens the comment editor when a line number is clicked", async ({ page }) => {
@@ -28,7 +27,6 @@ test("opens the comment editor when a line number is clicked", async ({ page })
await lineNumber.click() await lineNumber.click()
await expect(review.getByRole("textbox")).toBeVisible() 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 }) => { test("opens the comment editor for a line number range", async ({ page }) => {
@@ -38,10 +36,15 @@ test("opens the comment editor for a line number range", async ({ page }) => {
await expectAppVisible(start) await expectAppVisible(start)
await expectAppVisible(end) await expectAppVisible(end)
await start.dragTo(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 expect(review.getByRole("textbox")).toBeVisible() 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 }) => { test("shows a comment button when a line number is hovered", async ({ page }) => {
@@ -51,38 +54,31 @@ test("shows a comment button when a line number is hovered", async ({ page }) =>
const comment = review.getByRole("button", { name: "Comment", exact: true }) const comment = review.getByRole("button", { name: "Comment", exact: true })
await expect(async () => { await expect(async () => {
await page.mouse.move(0, 0)
await lineNumber.hover() await lineNumber.hover()
await expect(lineNumber).toHaveAttribute("data-hovered", "") await expect(comment).toBeVisible({ timeout: 500 })
await expect(comment).toHaveCount(1) await comment.click({ timeout: 500 })
await expect(comment).toHaveCSS("pointer-events", "auto") }).toPass()
await comment.focus()
await expect(comment).toBeFocused()
}).toPass({ timeout: 10_000 })
await comment.press("Enter")
await expect(review.getByRole("textbox")).toBeVisible() 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 }) => { test("stages a submitted line comment in the prompt context", async ({ page }) => {
const requests: string[] = []
page.on("request", (request) => { page.on("request", (request) => {
expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET") if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`)
}) })
const review = page.locator('[data-component="session-review"]') const review = page.locator('[data-component="session-review"]')
await review.getByText("export const value = 'after'", { exact: true }).click() await review.getByText("export const value = 'after'", { exact: true }).click()
const textbox = review.getByRole("textbox") await review.getByRole("textbox").fill("Use the existing value instead")
await expect(textbox).toBeVisible() await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click()
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 expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "Session" }).click() await page.getByRole("tab", { name: "Session" }).click()
const context = page.getByText("Use the existing value instead", { exact: true }).last() const context = page.getByText("Use the existing value instead", { exact: true }).last()
await expect(context).toBeVisible() await expect(context).toBeVisible()
await expect(context.locator("..")).toContainText("review.ts:2") await expect(context.locator("..")).toContainText("review.ts:2")
expect(requests).toEqual([])
}) })
async function openReview(page: Page) { async function openReview(page: Page) {
@@ -148,22 +144,15 @@ async function openReview(page: Page) {
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title) await expectSessionTitle(page, title)
const changes = page.getByRole("tab", { name: "Changes" }) const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff")
const diffResponse = page.waitForResponse( await page.getByRole("tab", { name: "Changes" }).click()
(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) 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"]') const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review) await expectAppVisible(review)
const file = review.locator('[data-file="src/review.ts"]') await review
await expectAppVisible(file) .getByRole("heading", { name: /review\.ts/ })
const trigger = file.getByRole("button", { expanded: false }) .getByRole("button")
await expect(trigger).toHaveCount(1) .first()
await trigger.click() .click()
await expect(file.getByRole("button", { expanded: true })).toBeVisible()
await expect(file.getByText("export const value = 'after'", { exact: true })).toBeVisible()
} }
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
for (const profile of [ for (const profile of [
{ locale: "de", label: "Erkundung abgeschlossen" }, { locale: "de", label: "Erkundet" },
{ locale: "ar", label: "تم الاستكشاف" }, { locale: "ar", label: "تم الاستكشاف" },
] as const) { ] as const) {
test(`projects translated context status in ${profile.locale}`, async ({ page }) => { test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
@@ -1,8 +1,9 @@
import { expect, test, type Page } from "@playwright/test" import { expect, test } from "@playwright/test"
import { import {
assistantMessage, assistantMessage,
partUpdated, partUpdated,
setupTimeline, setupTimeline,
status,
textPart, textPart,
userMessage, userMessage,
} from "../performance/timeline-stability/fixture" } from "../performance/timeline-stability/fixture"
@@ -16,7 +17,7 @@ test("keeps one connection open while delivering multiple events", async ({ page
await timeline.waitForPart("prt_transport_first") await timeline.waitForPart("prt_transport_first")
await timeline.waitForPart("prt_transport_second") await timeline.waitForPart("prt_transport_second")
expect(first.connectionID).toBe(second.connectionID) expect(first.connectionID).toBe(second.connectionID)
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) expect(await timeline.transport.connections()).toHaveLength(1)
expect(await timeline.transport.acknowledgements()).toHaveLength(2) expect(await timeline.transport.acknowledgements()).toHaveLength(2)
}) })
@@ -50,28 +51,20 @@ test("parses split JSON and a split multibyte code point", async ({ page }) => {
}) })
test("delivers server heartbeat without mutating the timeline", async ({ page }) => { test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
const sentinelID = "prt_transport_heartbeat_sentinel"
const timeline = await setupTimeline(page, { const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])], messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])],
}) })
await timeline.waitForPart("prt_transport_steady") const before = await page.locator("[data-timeline-row]").allTextContents()
const before = await stableTimelineRows(page)
await timeline.transport.writeRaw(": heartbeat\n\n") await timeline.transport.heartbeat()
await timeline.transport.send(partUpdated(textPart(sentinelID, "heartbeat processed"))) await timeline.settle()
await timeline.waitForPart(sentinelID)
await expect expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before)
.poll(async () => { expect(await timeline.transport.connections()).toHaveLength(1)
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 }) => { test("reconnects after a clean close", async ({ page }) => {
const timeline = await setupTimeline(page) const timeline = await setupTimeline(page, { eventRetry: 10 })
const first = await timeline.transport.waitForConnection() const first = await timeline.transport.waitForConnection()
await timeline.transport.close() await timeline.transport.close()
@@ -84,21 +77,20 @@ test("reconnects after a clean close", async ({ page }) => {
}) })
test("reconnects after a stream error", async ({ page }) => { test("reconnects after a stream error", async ({ page }) => {
const timeline = await setupTimeline(page) const timeline = await setupTimeline(page, { eventRetry: 10 })
const first = await timeline.transport.waitForConnection() const first = await timeline.transport.waitForConnection()
await timeline.transport.error("contract failure") await timeline.transport.error("contract failure")
const second = await timeline.transport.waitForConnection({ after: first.id }) const second = await timeline.transport.waitForConnection({ after: first.id })
await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error"))) await timeline.transport.send(status("busy"))
await timeline.waitForPart("prt_transport_error")
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2) await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
expect(second.id).toBeGreaterThan(first.id) expect(second.id).toBeGreaterThan(first.id)
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
}) })
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
const timeline = await setupTimeline(page, { protocol: "v2" }) const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
id: "timeline-event-7", id: "timeline-event-7",
}) })
@@ -120,35 +112,5 @@ test("passes through non-event fetches", async ({ page }) => {
}) })
expect(health).toEqual({ healthy: true }) expect(health).toEqual({ healthy: true })
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) expect(await timeline.transport.connections()).toHaveLength(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,
})),
)
}
@@ -5,8 +5,6 @@ import { currentSession } from "../utils/mock-server"
const server = "http://127.0.0.1:4096" const server = "http://127.0.0.1:4096"
const sessionA = session("ses_tab_a", "Tab A session") const sessionA = session("ses_tab_a", "Tab A session")
const sessionB = session("ses_tab_b", "Tab B session") const sessionB = session("ses_tab_b", "Tab B session")
const sessionC = session("ses_tab_c", "Tab C session")
const unresolvedSessionID = "ses_tab_unresolved"
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => { test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
await mockServer(page) await mockServer(page)
@@ -42,34 +40,6 @@ test("pressing mouse down on a tab navigates before mouse up", async ({ page })
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
}) })
test("keyboard navigation follows the visible tab order", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, unresolved, sessionC }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
{ type: "session", server, sessionId: unresolved },
{ type: "session", server, sessionId: sessionC },
]),
)
},
{ server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id },
)
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}`
await page.goto(hrefA)
await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2)
await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible()
await page.keyboard.press("Control+Alt+ArrowRight")
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
function session(id: string, title: string) { function session(id: string, title: string) {
return { return {
id, id,
@@ -83,12 +53,10 @@ function session(id: string, title: string) {
} }
async function mockServer(page: Page) { async function mockServer(page: Page) {
const sessions = [sessionA, sessionB, sessionC] const sessions = [sessionA, sessionB]
await page.route("**/*", async (route) => { await page.route("**/*", async (route) => {
const url = new URL(route.request().url()) const url = new URL(route.request().url())
if (url.origin !== server) return route.fallback() if (url.origin !== server) return route.fallback()
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
return new Promise(() => {})
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route) return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true }) if (url.pathname === "/global/health") return json(route, { healthy: true })
+5 -10
View File
@@ -247,23 +247,18 @@ export async function installSseTransport<T>(
return { return {
server, server,
async waitForConnection(input = {}) { async waitForConnection(input = {}) {
const connection = await page.waitForFunction( await page.waitForFunction(
(after) => { (after) => {
const transport = (window as BrowserTransport).__testSseTransport const transport = (window as BrowserTransport).__testSseTransport
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined) return connections?.some((connection) => connection.id > after)
}, },
input.after ?? 0, input.after ?? 0,
{ timeout: input.timeout }, { timeout: input.timeout },
) )
let result: SseConnectionRecord | undefined return (await command<SseConnectionRecord[]>({ type: "connections" })).findLast(
try { (connection) => connection.id > (input.after ?? 0),
result = await connection.jsonValue() )!
} finally {
await connection.dispose()
}
if (!result) throw new Error("SSE transport connection disappeared while waiting")
return result
}, },
send(payload, eventOptions) { send(payload, eventOptions) {
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false }) return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
+3 -4
View File
@@ -1,12 +1,11 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.18.15", "version": "1.18.6",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./desktop-menu": "./src/desktop-menu.ts", "./desktop-menu": "./src/desktop-menu.ts",
"./i18n/desktop-native": "./src/i18n/desktop-native.ts",
"./updater": "./src/updater.ts", "./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts", "./wsl/types": "./src/wsl/types.ts",
"./vite": "./vite.js", "./vite": "./vite.js",
@@ -20,9 +19,9 @@
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"test": "bun run test:unit && bun run test:browser", "test": "bun run test:unit && bun run test:browser",
"test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src", "test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser", "test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src", "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:local": "playwright test", "test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
+8 -16
View File
@@ -3,6 +3,7 @@ import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context" import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file" 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 { File } from "@opencode-ai/session-ui/file"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo" import { Splash } from "@opencode-ai/ui/logo"
@@ -233,13 +234,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
function UiI18nBridge(props: ParentProps) { function UiI18nBridge(props: ParentProps) {
const language = useLanguage() const language = useLanguage()
return ( return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
<I18nProvider
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
>
{props.children}
</I18nProvider>
)
} }
function LayoutCompatibility(props: ParentProps) { function LayoutCompatibility(props: ParentProps) {
@@ -332,7 +327,7 @@ function DesktopCommands() {
if (platform.platform === "desktop" && platform.exportDebugLogs) { if (platform.platform === "desktop" && platform.exportDebugLogs) {
commands.push({ commands.push({
id: "logs.export", id: "logs.export",
title: language.t("command.logs.export"), title: "Export logs",
category: language.t("command.category.settings"), category: language.t("command.category.settings"),
onSelect: () => { onSelect: () => {
void platform.exportDebugLogs?.() void platform.exportDebugLogs?.()
@@ -390,12 +385,7 @@ function DraftProviders(props: ParentProps) {
) )
} }
export function AppBaseProviders( export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
props: ParentProps<{
locale?: Locale
onNativeTranslations?: Parameters<typeof LanguageProvider>[0]["onNativeTranslations"]
}>,
) {
return ( return (
<MetaProvider> <MetaProvider>
<Font /> <Font />
@@ -404,7 +394,7 @@ export function AppBaseProviders(
void window.api?.setTitlebar?.({ mode, scheme }) void window.api?.setTitlebar?.({ mode, scheme })
}} }}
> >
<LanguageProvider locale={props.locale} onNativeTranslations={props.onNativeTranslations}> <LanguageProvider locale={props.locale}>
<UiI18nBridge> <UiI18nBridge>
<ErrorBoundary <ErrorBoundary
fallback={(error) => { fallback={(error) => {
@@ -415,7 +405,9 @@ export function AppBaseProviders(
<QueryProvider> <QueryProvider>
<WslServersProvider> <WslServersProvider>
<DialogProvider> <DialogProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider> <MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider> </DialogProvider>
</WslServersProvider> </WslServersProvider>
</QueryProvider> </QueryProvider>
+27 -64
View File
@@ -60,44 +60,35 @@ function Cell(props: {
label: string label: string
tip: string tip: string
value: string value: string
span?: 2 | 3 wide?: boolean
}) { }) {
const content = () => ( const content = () => (
<div <div
classList={{ classList={{
"flex min-w-0 items-center": true, "flex min-w-0 items-center": true,
"min-h-[20px] w-fit justify-start px-1.5 py-0.5 text-left": !!props.inline, "min-h-[20px] w-fit flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline,
"justify-center text-center": !props.inline, "justify-center text-center": !props.inline,
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline, "min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
"col-span-2": props.span === 2 && !props.inline, "col-span-2": !!props.wide && !props.inline,
"col-span-3": props.span === 3 && !props.inline,
}} }}
> >
<div <div
classList={{ classList={{
"flex min-w-0": true, "text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
"-translate-y-px items-baseline gap-1.5": !!props.inline,
"flex-col items-center": !props.inline,
}} }}
> >
<div {props.label}
classList={{ </div>
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true, <div
}} classList={{
> "uppercase leading-none font-bold tabular-nums": true,
{props.label} "text-[11px]": !!props.inline,
</div> "text-[13px] sm:text-[14px]": !props.inline,
<div "text-text-on-critical-base": !!props.bad,
classList={{ "opacity-70": !!props.dim,
"uppercase leading-none font-bold tabular-nums": true, }}
"text-[11px]": !!props.inline, >
"text-[13px] sm:text-[14px]": !props.inline, {props.value}
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div> </div>
</div> </div>
) )
@@ -117,50 +108,37 @@ function Cell(props: {
) )
} }
function ToggleCell(props: { function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
active: boolean
inline?: boolean
label: string
onClick: () => void
tip: string
value: string
}) {
const content = () => ( const content = () => (
<button <button
type="button" type="button"
aria-label={`${props.label}: ${props.value}`} aria-label="Force focus styles on all interactive elements"
aria-pressed={props.active} aria-pressed={props.active}
classList={{ 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, "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 justify-start rounded px-1.5 py-0.5 text-left": !!props.inline, "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-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !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, "bg-surface-raised-base text-text-strong": props.active,
}} }}
onClick={props.onClick} onClick={props.onClick}
> >
<span <span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
classList={{ <span classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}>
flex: true, {props.active ? "ON" : "OFF"}
"-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> </span>
</button> </button>
) )
if (props.inline) { if (props.inline) {
return ( return (
<TooltipV2 value={props.tip} placement="top"> <TooltipV2 value="Force focus styles on all interactive elements" placement="top">
{content()} {content()}
</TooltipV2> </TooltipV2>
) )
} }
return ( return (
<Tooltip value={props.tip} placement="top"> <Tooltip value="Force focus styles on all interactive elements" placement="top">
{content()} {content()}
</Tooltip> </Tooltip>
) )
@@ -479,7 +457,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
"gap-[9px]": !!props.inline, "gap-[9px]": !!props.inline,
"gap-px": !props.inline, "gap-px": !props.inline,
"flex w-full flex-nowrap items-center justify-start": !!props.inline, "flex w-full flex-nowrap items-center justify-start": !!props.inline,
"grid-cols-4": !props.inline, "grid-cols-5": !props.inline,
grid: !props.inline, grid: !props.inline,
}} }}
> >
@@ -561,25 +539,10 @@ export function DebugBar(props: { inline?: boolean } = {}) {
bad={bad(heap(), 0.8)} bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined} dim={state.heap.used === undefined}
inline={props.inline} inline={props.inline}
span={platform.setForceFocus ? 2 : 3} wide={!platform.setForceFocus}
/>
<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 && ( {platform.setForceFocus && (
<ToggleCell <FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
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> </div>
</aside> </aside>
@@ -28,8 +28,9 @@ import {
Switch, Switch,
} from "solid-js" } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { useQueryClient } from "@tanstack/solid-query"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/components/external-link" import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -37,6 +38,7 @@ import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { CustomProviderForm } from "./dialog-custom-provider" import { CustomProviderForm } from "./dialog-custom-provider"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { pathKey } from "@/utils/path-key"
const CUSTOM_ID = "_custom" const CUSTOM_ID = "_custom"
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }> type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
@@ -384,6 +386,7 @@ function ProviderConnection(props: {
const dialog = useDialog() const dialog = useDialog()
const serverSync = useServerSync() const serverSync = useServerSync()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const queryClient = useQueryClient()
const params = useParams() const params = useParams()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
@@ -509,12 +512,7 @@ function ProviderConnection(props: {
const hint = suffix?.[1] const hint = suffix?.[1]
return { return {
label: suffix ? label.slice(0, -suffix[0].length) : label, label: suffix ? label.slice(0, -suffix[0].length) : label,
hint: hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined,
hint?.toLowerCase() === "headless"
? language.t("provider.connect.method.headless")
: hint?.toLowerCase() === "browser" || (!hint && value?.type === "key")
? language.t("provider.connect.method.browser")
: undefined,
} }
} }
@@ -709,8 +707,9 @@ function ProviderConnection(props: {
}) })
async function complete() { async function complete() {
await serverSync() const value = directory()
.refreshProviders() await queryClient
.refetchQueries(serverSync().queryOptions.providers(value ? pathKey(value) : null))
.catch(() => undefined) .catch(() => undefined)
dialog.close() dialog.close()
showToast({ showToast({
@@ -841,12 +840,12 @@ function ProviderConnection(props: {
<div>{language.t("provider.connect.opencodeZen.line2")}</div> <div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div> <div>
{language.t("provider.connect.opencodeZen.visit.prefix")} {language.t("provider.connect.opencodeZen.visit.prefix")}
<ExternalLink <Link
href="https://opencode.ai/zen" 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" 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")} {language.t("provider.connect.opencodeZen.visit.link")}
</ExternalLink> </Link>
{language.t("provider.connect.opencodeZen.visit.suffix")} {language.t("provider.connect.opencodeZen.visit.suffix")}
</div> </div>
</div> </div>
@@ -891,9 +890,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.line2")}</div>
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.visit.prefix")} {language.t("provider.connect.opencodeZen.visit.prefix")}
<ExternalLink href="https://opencode.ai/zen" tabIndex={-1}> <Link href="https://opencode.ai/zen" tabIndex={-1}>
{language.t("provider.connect.opencodeZen.visit.link")} {language.t("provider.connect.opencodeZen.visit.link")}
</ExternalLink> </Link>
{language.t("provider.connect.opencodeZen.visit.suffix")} {language.t("provider.connect.opencodeZen.visit.suffix")}
</div> </div>
</div> </div>
@@ -972,9 +971,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 class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div> <div>
{language.t("provider.connect.oauth.code.visit.prefix")} {language.t("provider.connect.oauth.code.visit.prefix")}
<ExternalLink href={store.authorization!.url} class="text-v2-text-text-base"> <Link href={store.authorization!.url} class="text-v2-text-text-base">
{language.t("provider.connect.oauth.code.visit.link")} {language.t("provider.connect.oauth.code.visit.link")}
</ExternalLink> </Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div> </div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch"> <form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
@@ -1011,9 +1010,7 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.code.visit.prefix")} {language.t("provider.connect.oauth.code.visit.prefix")}
<ExternalLink href={store.authorization!.url}> <Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
{language.t("provider.connect.oauth.code.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div> </div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
@@ -1084,9 +1081,7 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")} {language.t("provider.connect.oauth.auto.visit.prefix")}
<ExternalLink href={store.authorization!.url}> <Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
{language.t("provider.connect.oauth.auto.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })} {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div> </div>
<TextField <TextField
@@ -8,7 +8,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { batch, For } from "solid-js" import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { ExternalLink } from "@/components/external-link" import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -131,7 +131,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const saveMutation = useMutation(() => ({ const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => { mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
if ((await serverSDK().protocol) !== "v1") throw new Error(language.t("provider.custom.unavailable")) if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
const disabledProviders = serverSync().data.config.disabled_providers ?? [] const disabledProviders = serverSync().data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) 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"> <form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
<p class="text-14-regular text-text-base"> <p class="text-14-regular text-text-base">
{language.t("provider.custom.description.prefix")} {language.t("provider.custom.description.prefix")}
<ExternalLink href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}> <Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
{language.t("provider.custom.description.link")} {language.t("provider.custom.description.link")}
</ExternalLink> </Link>
{language.t("provider.custom.description.suffix")} {language.t("provider.custom.description.suffix")}
</p> </p>
@@ -18,8 +18,6 @@ interface DialogSelectDirectoryProps {
server: ServerConnection.Any server: ServerConnection.Any
} }
const RECENT_PROJECT_LIMIT = 5
type Row = { type Row = {
absolute: string absolute: string
search: string search: string
@@ -59,9 +57,9 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const [filter, setFilter] = createSignal("") const [filter, setFilter] = createSignal("")
let list: ListRef | undefined let list: ListRef | undefined
const missingHome = createMemo(() => !sync.data.path.home) const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource( const [fallbackPath] = createResource(
() => (missingHome() ? true : undefined), () => (missingBase() ? true : undefined),
async (): Promise<Path | undefined> => { async (): Promise<Path | undefined> => {
if ((await sdk.protocol) !== "v1") return if ((await sdk.protocol) !== "v1") return
return sdk.client.path return sdk.client.path
@@ -104,6 +102,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return projects return projects
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index })) .map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
.sort((a, b) => b.at - a.at || a.index - b.index) .sort((a, b) => b.at - a.at || a.index - b.index)
.slice(0, 5)
.map(({ project }) => { .map(({ project }) => {
const row = toRow(project.worktree, home(), "recent") const row = toRow(project.worktree, home(), "recent")
const name = project.name || getFilename(project.worktree) const name = project.name || getFilename(project.worktree)
@@ -117,10 +116,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const items = async (value: string) => { const items = async (value: string) => {
const results = await directories(value) const results = await directories(value)
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders")) const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
// Cap the idle list only. Once a query narrows the results, every project stays searchable. return uniqueRows([...recentProjects(), ...directoryRows])
const recent = recentProjects()
const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
return uniqueRows([...visible, ...directoryRows])
} }
function resolve(absolute: string) { function resolve(absolute: string) {
@@ -1,5 +1,15 @@
import { Popover as Kobalte } from "@kobalte/core/popover" import { Popover as Kobalte } from "@kobalte/core/popover"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js" import {
Component,
ComponentProps,
createEffect,
createMemo,
For,
JSX,
onCleanup,
Show,
ValidComponent,
} from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -19,7 +29,6 @@ import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown" import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
import { createEventListener } from "@solid-primitives/event-listener" import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./dialog-select-model-search" import { matchesModelSearch } from "./dialog-select-model-search"
@@ -113,13 +122,14 @@ const ModelList: Component<{
} }
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref"> type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider" type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
export function ModelSelectorPopover(props: { export function ModelSelectorPopover(props: {
provider?: string provider?: string
model?: ModelState model?: ModelState
trigger: ModelSelectorTrigger children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
onClose?: (cause: "escape" | "select") => void onClose?: (cause: "escape" | "select") => void
}) { }) {
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
@@ -164,7 +174,9 @@ export function ModelSelectorPopover(props: {
placement="top-start" placement="top-start"
gutter={4} gutter={4}
> >
<Kobalte.Trigger as={props.trigger} /> <Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}
</Kobalte.Trigger>
<Kobalte.Portal> <Kobalte.Portal>
<Kobalte.Content <Kobalte.Content
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden" class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
@@ -225,101 +237,66 @@ export function ModelSelectorPopover(props: {
export function ModelSelectorPopoverV2(props: { export function ModelSelectorPopoverV2(props: {
provider?: string provider?: string
model?: ModelState model?: ModelState
trigger: ModelSelectorTrigger children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
onClose?: () => void onClose?: () => void
}) { }) {
const model = props.model ?? useLocal().model
const language = useLanguage()
const dialog = useDialog() const dialog = useDialog()
const controller = createModelSelectorController({ const [store, setStore] = createStore({ open: false, search: "", active: "" })
model: props.model, let searchRef: HTMLInputElement | undefined
provider: () => props.provider, let contentRef: HTMLDivElement | undefined
onSelect: () => props.onClose?.(), let restoreTrigger = true
})
return (
<ModelSelectorPopoverV2View
trigger={props.trigger}
models={controller.models}
groups={controller.groups}
current={controller.current}
select={controller.select}
onManage={() => {
void import("./dialog-manage-models").then((module) => {
void dialog.show(() => <module.DialogManageModelsV2 />)
})
}}
onClose={() => props.onClose?.()}
/>
)
}
function createModelSelectorController(input: {
provider: () => string | undefined
model?: ModelState
onSelect: () => void
}) {
const model = input.model ?? useLocal().model
const allModels = createMemo(() => const allModels = createMemo(() =>
model model
.list() .list()
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id })) .filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
.filter((item) => (input.provider() ? item.provider.id === input.provider() : true)), .filter((item) => (props.provider ? item.provider.id === props.provider : true)),
) )
const models = createMemo(() => {
const search = store.search.trim()
const filtered = search
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
: allModels()
return { return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
models: (search: string) => { })
const query = search.trim() const groups = createMemo(() => {
const filtered = query const byProvider = new Map<string, ModelItem[]>()
? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name])) for (const item of models()) {
: allModels() byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
return [...filtered].sort((a, b) => a.name.localeCompare(b.name)) }
}, return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
groups: (models: ModelItem[]) => { })
const byProvider = new Map<string, ModelItem[]>()
for (const item of models) {
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
}
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
},
current: () => {
const value = model.current()
return value ? modelKey(value) : undefined
},
select: (item: ModelItem) => {
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
input.onSelect()
},
}
}
function ModelSelectorPopoverV2View(props: {
trigger: ModelSelectorTrigger
models: (search: string) => ModelItem[]
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
current: () => string | undefined
select: (item: ModelItem) => void
onManage: () => void
onClose: () => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined
let contentRef: HTMLDivElement | undefined
const dismiss = createMenuDismissController(() => contentRef)
const models = createMemo(() => props.models(store.search))
const groups = createMemo(() => props.groups(models()))
const keys = () => [...models().map(modelKey), manageKey] const keys = () => [...models().map(modelKey), manageKey]
const current = () => {
const value = model.current()
return value ? `${value.provider.id}:${value.id}` : undefined
}
const initialActive = () => { const initialActive = () => {
const selected = props.current() const selected = current()
const options = keys() const options = keys()
if (selected && options.includes(selected)) return selected if (selected && options.includes(selected)) return selected
return options[0] ?? "" return options[0] ?? ""
} }
const activeItem = () => const activeItem = () =>
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
const afterClose = (callback: () => void) => {
const complete = () => {
if (contentRef?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
}
const setOpen = (open: boolean) => { const setOpen = (open: boolean) => {
if (open) { if (open) {
dismiss.allowTriggerRestore() restoreTrigger = true
setStore({ open: true, active: initialActive() }) setStore({ open: true, active: initialActive() })
setTimeout(() => setTimeout(() =>
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -331,15 +308,23 @@ function ModelSelectorPopoverV2View(props: {
} }
setStore({ open: false, search: "", active: "" }) setStore({ open: false, search: "", active: "" })
} }
const select = (item: ModelItem) => {
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
props.onClose?.()
}
const selectModel = (item: ModelItem) => { const selectModel = (item: ModelItem) => {
dismiss.preventTriggerRestore() restoreTrigger = false
setOpen(false) setOpen(false)
dismiss.afterClose(() => props.select(item)) afterClose(() => select(item))
} }
const manage = () => { const manage = () => {
dismiss.preventTriggerRestore() restoreTrigger = false
setOpen(false) setOpen(false)
dismiss.afterClose(props.onManage) afterClose(() => {
void import("./dialog-manage-models").then((x) => {
dialog.show(() => <x.DialogManageModelsV2 />)
})
})
} }
const selectActive = () => { const selectActive = () => {
const item = models().find((item) => modelKey(item) === store.active) const item = models().find((item) => modelKey(item) === store.active)
@@ -358,7 +343,10 @@ function ModelSelectorPopoverV2View(props: {
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" })) queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
} }
const setSearch = (value: string) => { const setSearch = (value: string) => {
const first = props.models(value)[0] const search = value.trim()
const first = [...allModels()]
.sort((a, b) => a.name.localeCompare(b.name))
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
setStore({ search: value, active: first ? modelKey(first) : manageKey }) setStore({ search: value, active: first ? modelKey(first) : manageKey })
} }
@@ -374,14 +362,18 @@ function ModelSelectorPopoverV2View(props: {
return ( return (
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}> <MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
<MenuV2.Trigger as={props.trigger} /> <MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}
</MenuV2.Trigger>
<MenuV2.Portal> <MenuV2.Portal>
<MenuV2.Content <MenuV2.Content
ref={(element: HTMLDivElement) => (contentRef = element)} ref={(el: HTMLDivElement) => (contentRef = el)}
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none" class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onPointerDownOutside={dismiss.preventTriggerRestore} onPointerDownOutside={() => (restoreTrigger = false)}
onFocusOutside={dismiss.preventTriggerRestore} onFocusOutside={() => (restoreTrigger = false)}
onCloseAutoFocus={dismiss.onCloseAutoFocus} onCloseAutoFocus={(event) => {
if (!restoreTrigger) event.preventDefault()
}}
> >
<div class="flex flex-col p-0.5"> <div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted"> <div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
@@ -401,9 +393,9 @@ function ModelSelectorPopoverV2View(props: {
event.stopPropagation() event.stopPropagation()
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault() event.preventDefault()
dismiss.preventTriggerRestore() restoreTrigger = false
setOpen(false) setOpen(false)
dismiss.afterClose(props.onClose) afterClose(() => props.onClose?.())
return return
} }
if (event.altKey || event.metaKey) return if (event.altKey || event.metaKey) return
@@ -453,7 +445,7 @@ function ModelSelectorPopoverV2View(props: {
<MenuV2.GroupLabel class="gap-2 px-3"> <MenuV2.GroupLabel class="gap-2 px-3">
<span class="min-w-0 truncate">{group.items[0].provider.name}</span> <span class="min-w-0 truncate">{group.items[0].provider.name}</span>
</MenuV2.GroupLabel> </MenuV2.GroupLabel>
<MenuV2.RadioGroup value={props.current()}> <MenuV2.RadioGroup value={current()}>
<For each={group.items}> <For each={group.items}>
{(item) => ( {(item) => (
<TooltipV2 <TooltipV2
@@ -473,7 +465,7 @@ function ModelSelectorPopoverV2View(props: {
<MenuV2.RadioItem <MenuV2.RadioItem
value={modelKey(item)} value={modelKey(item)}
data-option-key={modelKey(item)} data-option-key={modelKey(item)}
data-selected-model={props.current() === modelKey(item) ? true : undefined} data-selected-model={current() === modelKey(item) ? true : undefined}
class="scroll-my-6 w-full" class="scroll-my-6 w-full"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }} classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => { onMouseEnter={() => {
@@ -1,5 +1,4 @@
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@@ -15,11 +14,10 @@ export type DialogGoUpsellProps = {
export function DialogUsageExceeded(props: DialogGoUpsellProps) { export function DialogUsageExceeded(props: DialogGoUpsellProps) {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const runAction = () => { const runAction = () => {
if (props.link) platform.openExternal(props.link) if (props.link) platform.openLink(props.link)
props.onClose?.() props.onClose?.()
dialog.close() dialog.close()
} }
@@ -34,7 +32,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3"> <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={dismiss}> <Button variant="ghost" size="large" onClick={dismiss}>
{language.t("dialog.usageExceeded.dontShowAgain")} Don't show again
</Button> </Button>
<Button variant="primary" size="large" onClick={runAction}> <Button variant="primary" size="large" onClick={runAction}>
{props.actionLabel} {props.actionLabel}
@@ -1,21 +0,0 @@
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>
)
}
+7 -9
View File
@@ -29,14 +29,14 @@ export type { Kind } from "@/components/file-tree"
const INDENT_STEP = 16 const INDENT_STEP = 16
function rowPaddingStart(level: number, type: FileNode["type"]) { function rowPaddingLeft(level: number, type: FileNode["type"]) {
if (type === "directory") return 8 + level * INDENT_STEP if (type === "directory") return 8 + level * INDENT_STEP
if (level === 0) return 8 if (level === 0) return 8
return 8 + level * INDENT_STEP - INDENT_STEP return 8 + level * INDENT_STEP - INDENT_STEP
} }
function guideLineStart(level: number) { function guideLineLeft(level: number) {
return rowPaddingStart(level, "directory") + 8 return rowPaddingLeft(level, "directory") + 8
} }
export const kindLabel = (kind: Kind) => { export const kindLabel = (kind: Kind) => {
@@ -87,7 +87,7 @@ const FileTreeNodeV2 = (
...local.classList, ...local.classList,
[local.class ?? ""]: !!local.class, [local.class ?? ""]: !!local.class,
}} }}
style={`padding-inline-start: ${rowPaddingStart(local.level, local.node.type)}px`} style={`padding-left: ${rowPaddingLeft(local.level, local.node.type)}px`}
draggable={local.draggable} draggable={local.draggable}
onDragStart={(event: DragEvent) => { onDragStart={(event: DragEvent) => {
if (!local.draggable) return if (!local.draggable) return
@@ -99,9 +99,7 @@ const FileTreeNodeV2 = (
{...rest} {...rest}
> >
{local.children} {local.children}
<span class="flex-1 min-w-0 text-start text-12-medium whitespace-nowrap truncate"> <span class="flex-1 min-w-0 text-12-medium whitespace-nowrap truncate">{local.node.name}</span>
<bdi dir="auto">{local.node.name}</bdi>
</span>
{(() => { {(() => {
const value = kind() const value = kind()
if (!value || local.node.type !== "file") return null if (!value || local.node.type !== "file") return null
@@ -118,7 +116,7 @@ const FileTreeNodeV2 = (
function GuideLines(props: { level: number }) { function GuideLines(props: { level: number }) {
return ( return (
<For each={Array.from({ length: props.level })}> <For each={Array.from({ length: props.level })}>
{(_, index) => <div data-slot="file-tree-v2-guide" style={`inset-inline-start: ${guideLineStart(index())}px`} />} {(_, index) => <div data-slot="file-tree-v2-guide" style={`left: ${guideLineLeft(index())}px`} />}
</For> </For>
) )
} }
@@ -229,7 +227,7 @@ export default function FileTreeV2(props: {
style={{ style={{
position: "absolute", position: "absolute",
top: "0", top: "0",
"inset-inline-start": "0", left: "0",
width: "100%", width: "100%",
height: `${item().size}px`, height: `${item().size}px`,
transform: `translateY(${item().start}px)`, transform: `translateY(${item().start}px)`,
+2 -2
View File
@@ -146,13 +146,13 @@ const FileTreeNode = (
<Dynamic <Dynamic
component={local.as ?? "div"} component={local.as ?? "div"}
classList={{ 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-start 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-left 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, "bg-surface-base-active": local.node.path === local.active,
...local.classList, ...local.classList,
[local.class ?? ""]: !!local.class, [local.class ?? ""]: !!local.class,
[local.nodeClass ?? ""]: !!local.nodeClass, [local.nodeClass ?? ""]: !!local.nodeClass,
}} }}
style={`padding-inline-start: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`} style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
draggable={local.draggable} draggable={local.draggable}
onDragStart={(event: DragEvent) => { onDragStart={(event: DragEvent) => {
if (!local.draggable) return if (!local.draggable) return
+32 -27
View File
@@ -1,11 +1,9 @@
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { isRTL } from "@kobalte/core/i18n"
import { createSignal, Show } from "solid-js" import { createSignal, Show } from "solid-js"
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer" import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useLanguage } from "@/context/language"
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4" import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import homeImage from "@/assets/help/home.png" import homeImage from "@/assets/help/home.png"
import tabsImage from "@/assets/help/tabs.png" import tabsImage from "@/assets/help/tabs.png"
@@ -17,22 +15,20 @@ const showPopover = () => true
export function TabsInfoPopup() { export function TabsInfoPopup() {
const settings = useSettings() const settings = useSettings()
const platform = usePlatform() const platform = usePlatform()
const language = useLanguage()
const [drawerOpen, setDrawerOpen] = createSignal(false) const [drawerOpen, setDrawerOpen] = createSignal(false)
const windows = () => platform.platform === "desktop" && platform.os === "windows" const windows = () => platform.platform === "desktop" && platform.os === "windows"
const rtl = () => isRTL(language.intl())
return ( return (
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side={rtl() ? "left" : "right"}> <Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
<Show when={settings.general.shouldDisplayTabsToast()}> <Show when={settings.general.shouldDisplayTabsToast()}>
<div <div
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)]" 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={language.t("help.tabs.toast.ariaLabel")} aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
> >
<button <button
type="button" type="button"
aria-label={language.t("help.tabs.toast.dismiss")} aria-label="Dismiss Tabs information"
class="absolute top-3 end-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]" class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
onClick={settings.general.dismissTabsToast} onClick={settings.general.dismissTabsToast}
> >
<svg <svg
@@ -48,7 +44,7 @@ export function TabsInfoPopup() {
</button> </button>
<button <button
type="button" type="button"
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-start" class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
onClick={() => { onClick={() => {
settings.general.dismissTabsToast() settings.general.dismissTabsToast()
setDrawerOpen(true) setDrawerOpen(true)
@@ -66,10 +62,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"> <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]"> <p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
{language.t("help.tabs.title")} Introducing Tabs
</p> </p>
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]"> <p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
{language.t("help.tabs.description")} Organize your work and active sessions with tabs
</p> </p>
</div> </div>
</button> </button>
@@ -79,10 +75,7 @@ export function TabsInfoPopup() {
style={ style={
windows() windows()
? { ? {
top: "0", inset: "0 0 0 auto",
bottom: "0",
"inset-inline-end": "0",
"inset-inline-start": "auto",
"max-height": "100vh", "max-height": "100vh",
"max-width": "100vw", "max-width": "100vw",
"border-radius": "0", "border-radius": "0",
@@ -96,9 +89,9 @@ export function TabsInfoPopup() {
type="button" type="button"
size="small" size="small"
variant="neutral" variant="neutral"
aria-label={language.t("common.close")} aria-label="Close"
icon={<IconV2 name="xmark-small" />} icon={<IconV2 name="xmark-small" />}
class="absolute top-[10px] start-[-36px]" class="absolute top-[10px] left-[-36px]"
/> />
</Show> </Show>
<div <div
@@ -109,7 +102,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"> <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">
{language.t("help.tabs.date")} July 14
</p> </p>
<Show when={!windows()}> <Show when={!windows()}>
<DrawerClose <DrawerClose
@@ -117,24 +110,36 @@ export function TabsInfoPopup() {
type="button" type="button"
size="small" size="small"
variant="ghost-muted" variant="ghost-muted"
aria-label={language.t("common.close")} aria-label="Close"
icon={<IconV2 name="xmark-small" />} icon={<IconV2 name="xmark-small" />}
/> />
</Show> </Show>
</div> </div>
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8"> <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"> <p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
{language.t("help.tabs.title")} Introducing Tabs
</p> </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"> <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>{language.t("help.tabs.introduction")}</p> <p>OpenCode Desktop is now built around tabs.</p>
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" /> <img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>{language.t("help.tabs.sessions")}</p> <p>
<p>{language.t("help.tabs.organize")}</p> Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
<p>{language.t("help.tabs.home")}</p> 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>
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" /> <img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>{language.t("help.tabs.persistence")}</p> <p>When you reopen the app, your tabs are still open.</p>
<p>{language.t("help.tabs.worktrees")}</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>
</div> </div>
</div> </div>
</DrawerContent> </DrawerContent>
+26
View File
@@ -0,0 +1,26 @@
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>
)
}
+86 -81
View File
@@ -5,7 +5,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" import type { Prompt, ReferenceInfo } from "@opencode-ai/sdk/v2/client"
import { createEffect, createMemo, on, Show } from "solid-js" import { createEffect, createMemo, on, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
@@ -37,9 +37,11 @@ export type PromptInputV2ComposerProps = {
class?: string class?: string
controller: PromptInputV2ComposerController controller: PromptInputV2ComposerController
borderUnderlay?: boolean borderUnderlay?: boolean
edit?: PromptInputProps["edit"]
onEditLoaded?: PromptInputProps["onEditLoaded"]
} }
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission"> export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
export type PromptInputV2ComposerController = PromptInputV2Interaction & { export type PromptInputV2ComposerController = PromptInputV2Interaction & {
readonly model: PromptInputProps["controls"]["model"] readonly model: PromptInputProps["controls"]["model"]
} }
@@ -49,13 +51,15 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
useCommands(props)
useEditHandler(props)
return ( return (
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<PromptInputV2 <PromptInputV2
controller={props.controller} controller={props.controller}
borderUnderlay={props.borderUnderlay} borderUnderlay={props.borderUnderlay}
class={props.class} class={props.class}
variantControlVisible={!props.controller.model.loading}
attachKeybind={command.keybindParts("file.attach")} attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")} attachShortcut={command.keybind("file.attach")}
modelControl={ modelControl={
@@ -78,6 +82,70 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
) )
} }
const useEditHandler = (props: PromptInputV2ComposerProps) => {
const prompt = usePrompt()
createEffect(
on(
() => props.edit?.id,
(id) => {
const edit = props.edit
if (!id || !edit) return
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
edit.context.forEach((item) =>
prompt.context.add({
type: item.type,
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
}),
)
props.controller.dispatch({ type: "mode.normal" })
props.controller.resetHistory()
prompt.set(edit.prompt, promptLength(edit.prompt))
props.controller.restoreFocus()
props.onEditLoaded?.()
},
{ defer: true },
),
)
}
const useCommands = (props: PromptInputV2ComposerProps) => {
const command = useCommand()
const language = useLanguage()
command.register("prompt-input", () => [
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
category: language.t("command.category.file"),
keybind: "mod+u",
disabled: props.controller.state.mode !== "normal",
onSelect: () => props.controller.attach(),
},
{
id: "prompt.mode.shell",
title: language.t("command.prompt.mode.shell"),
category: language.t("command.category.session"),
keybind: "mod+shift+x",
disabled: props.controller.state.mode === "shell",
onSelect: () => props.controller.dispatch({ type: "mode.shell" }),
},
{
id: "prompt.mode.normal",
title: language.t("command.prompt.mode.normal"),
category: language.t("command.category.session"),
keybind: "mod+shift+e",
disabled: props.controller.state.mode === "normal",
onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
},
])
}
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController { export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
@@ -137,10 +205,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never), t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}), }),
) )
const designPlaceholder = () => const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder())
promptDesignPlaceholder(mode(), placeholder(), (key, params) =>
language.t(key as Parameters<typeof language.t>[0], params as never),
)
const historyComments = () => { const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
@@ -347,7 +412,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
if (item?.commentID) comments.remove(item.path, item.commentID) if (item?.commentID) comments.remove(item.path, item.commentID)
}, },
openAttachment: (attachment) => openAttachment: (attachment) =>
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />), dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
openContext(key) { openContext(key) {
const item = controller.contextItem(key) const item = controller.contextItem(key)
if (item) openComment(item, props, sync, layout, files, comments) if (item) openComment(item, props, sync, layout, files, comments)
@@ -371,7 +436,6 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
title: language.t("prompt.toast.pasteUnsupported.title"), title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"), description: language.t("prompt.toast.pasteUnsupported.description"),
}), }),
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
onError: (error) => onError: (error) =>
showToast({ showToast({
variant: "error", variant: "error",
@@ -380,7 +444,6 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
}), }),
readClipboardImage: platform.readClipboardImage, readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile, getPathForFile: platform.getPathForFile,
store: platform.draftStore?.putBlob,
}, },
view: { view: {
placeholder: designPlaceholder, placeholder: designPlaceholder,
@@ -409,62 +472,6 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
}, },
}) })
Object.defineProperty(controller, "model", { get: () => props.controls.model }) Object.defineProperty(controller, "model", { get: () => props.controls.model })
command.register("prompt-input", () => [
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
category: language.t("command.category.file"),
keybind: "mod+u",
disabled: controller.state.mode !== "normal",
onSelect: () => controller.attach(),
},
{
id: "prompt.mode.shell",
title: language.t("command.prompt.mode.shell"),
category: language.t("command.category.session"),
keybind: "mod+shift+x",
disabled: controller.state.mode === "shell",
onSelect: () => controller.dispatch({ type: "mode.shell" }),
},
{
id: "prompt.mode.normal",
title: language.t("command.prompt.mode.normal"),
category: language.t("command.category.session"),
keybind: "mod+shift+e",
disabled: controller.state.mode === "normal",
onSelect: () => controller.dispatch({ type: "mode.normal" }),
},
])
createEffect(
on(
() => props.edit?.id,
(id) => {
const edit = props.edit
if (!id || !edit) return
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
edit.context.forEach((item) =>
prompt.context.add({
type: item.type,
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
}),
)
controller.dispatch({ type: "mode.normal" })
controller.resetHistory()
prompt.set(edit.prompt, promptLength(edit.prompt))
controller.restoreFocus()
props.onEditLoaded?.()
},
{ defer: true },
),
)
return controller as PromptInputV2ComposerController return controller as PromptInputV2ComposerController
} }
@@ -528,22 +535,20 @@ function PromptInputV2ModelControl(props: {
> >
<ModelSelectorPopoverV2 <ModelSelectorPopoverV2
model={props.model} model={props.model}
trigger={(triggerProps) => ( triggerAs={ButtonV2}
<ButtonV2 triggerProps={{
{...triggerProps} variant: "ghost-muted",
variant="ghost-muted" size: "normal",
size="normal" style: { height: "28px" },
style={{ height: "28px" }} class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group" classList: { "animate-in fade-in": shouldAnimate() },
classList={{ "animate-in fade-in": shouldAnimate() }} "data-action": "prompt-model",
data-action="prompt-model" "data-control-type": "popover",
data-control-type="popover" }}
>
{content()}
</ButtonV2>
)}
onClose={props.onClose} onClose={props.onClose}
/> >
{content()}
</ModelSelectorPopoverV2>
</Show> </Show>
</TooltipV2> </TooltipV2>
</Show> </Show>
+24 -27
View File
@@ -77,7 +77,7 @@ import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/
import { PromptContextItems } from "./prompt-input/context-items" import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder" import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state" import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
@@ -1489,11 +1489,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<PromptImageAttachments <PromptImageAttachments
attachments={imageAttachments()} attachments={imageAttachments()}
onOpen={(attachment) => onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />) dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
} }
onRemove={removeAttachment} onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")} removeLabel={language.t("prompt.attachment.remove")}
fileLabel={language.t("ui.common.file")}
newLayoutDesigns={false} newLayoutDesigns={false}
/> />
<div <div
@@ -1724,31 +1723,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
> >
<ModelSelectorPopover <ModelSelectorPopover
model={props.controls.model.selection} model={props.controls.model.selection}
trigger={(triggerProps) => ( triggerAs={Button}
<Button triggerProps={{
{...triggerProps} variant: "ghost",
variant="ghost" size: "normal",
size="normal" style: control(),
style={control()} class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
class="min-w-0 max-w-[320px] text-13-regular text-text-base group" "data-action": "prompt-model",
data-action="prompt-model" }}
>
<Show when={props.controls.model.selection.current()?.provider?.id}>
<ProviderIcon
id={props.controls.model.selection.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{props.controls.model.selection.current()?.name ??
language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
)}
onClose={restoreFocus} onClose={restoreFocus}
/> >
<Show when={props.controls.model.selection.current()?.provider?.id}>
<ProviderIcon
id={props.controls.model.selection.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{props.controls.model.selection.current()?.name ??
language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind> </TooltipKeybind>
</Show> </Show>
</div> </div>
@@ -3,13 +3,28 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt" import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom" import { getCursorPosition } from "./editor-dom"
import { createBlobReference, type DraftStore } from "@/utils/draft-store"
import { attachmentMime } from "./files" import { attachmentMime } from "./files"
import { normalizePaste, pasteMode } from "./paste" 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 PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined } type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
@@ -21,7 +36,6 @@ type PromptAttachmentsCoreInput = {
warn?: () => void warn?: () => void
readClipboardImage?: () => Promise<File | null> readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string getPathForFile?: (file: File) => string
draftStore?: DraftStore
} }
export type PromptAttachmentsInput = { export type PromptAttachmentsInput = {
@@ -51,13 +65,16 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
return false return false
} }
const url = await dataUrl(file, mime)
if (!url) return false
const attachment: ImageAttachmentPart = { const attachment: ImageAttachmentPart = {
type: "image", type: "image",
id: uuid(), id: uuid(),
filename: file.name, filename: file.name,
sourcePath: input.getPathForFile?.(file) || undefined, sourcePath: input.getPathForFile?.(file) || undefined,
mime, mime,
blob: input.draftStore ? await input.draftStore.putBlob(file) : await createBlobReference(file), dataUrl: url,
} }
target.prompt.set([...target.prompt.current(), attachment], target.cursor) target.prompt.set([...target.prompt.current(), attachment], target.cursor)
return true return true
@@ -149,10 +166,8 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
export function createPromptAttachments(input: PromptAttachmentsInput) { export function createPromptAttachments(input: PromptAttachmentsInput) {
const language = useLanguage() const language = useLanguage()
const platform = usePlatform()
const attachments = createPromptAttachmentsCore({ const attachments = createPromptAttachmentsCore({
...input, ...input,
draftStore: platform.draftStore,
capture: input.prompt.capture, capture: input.prompt.capture,
warn: () => { warn: () => {
showToast({ showToast({
@@ -22,7 +22,7 @@ type ContextFile = {
type BuildRequestPartsInput = { type BuildRequestPartsInput = {
prompt: Prompt prompt: Prompt
context: ContextFile[] context: ContextFile[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[] images: ImageAttachmentPart[]
text: string text: string
messageID: string messageID: string
sessionID: string sessionID: string
@@ -1,13 +1,7 @@
import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { Prompt } from "@/context/prompt" import type { Prompt } from "@/context/prompt"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { import { prependHistoryEntry, type PromptHistoryComment, type PromptHistoryStoredEntry } from "./history"
clonePromptHistoryComments,
clonePromptParts,
prependHistoryEntry,
type PromptHistoryComment,
type PromptHistoryStoredEntry,
} from "./history"
export type PromptInputHistory = { export type PromptInputHistory = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[] entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
@@ -41,23 +35,13 @@ export function createPromptInputHistory(): PromptInputHistory {
} }
export function createPersistedPromptInputHistory() { export function createPersistedPromptInputHistory() {
const [normal, setNormal, normalInit] = persisted( const [normal, setNormal] = persisted(
Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])), Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<PromptHistoryState>({ entries: [] }), createStore<PromptHistoryState>({ entries: [] }),
) )
const [shell, setShell, shellInit] = persisted( const [shell, setShell] = persisted(
Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])), Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<PromptHistoryState>({ entries: [] }), createStore<PromptHistoryState>({ entries: [] }),
) )
const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell) return 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, end: 12,
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 }, selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
}, },
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } }, { type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" },
] ]
const copy = clonePromptParts(original) const copy = clonePromptParts(original)
expect(copy).not.toBe(original) expect(copy).not.toBe(original)
@@ -16,7 +16,6 @@ type PromptImageAttachmentsProps = {
onOpen: (attachment: ImageAttachmentPart) => void onOpen: (attachment: ImageAttachmentPart) => void
onRemove: (id: string) => void onRemove: (id: string) => void
removeLabel: string removeLabel: string
fileLabel: string
newLayoutDesigns: boolean newLayoutDesigns: boolean
comments?: PromptCommentItem[] comments?: PromptCommentItem[]
commentActive?: (item: PromptCommentItem) => boolean commentActive?: (item: PromptCommentItem) => boolean
@@ -95,13 +94,13 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
} }
> >
<AttachmentCardV2 title={attachment.filename}> <AttachmentCardV2 title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime, props.fileLabel)} {typeLabel(attachment.filename, attachment.mime)}
</AttachmentCardV2> </AttachmentCardV2>
</Show> </Show>
} }
> >
<img <img
src={attachment.blob.url} src={attachment.dataUrl}
alt={attachment.filename} alt={attachment.filename}
class={props.newLayoutDesigns ? imageClassV2 : imageClass} class={props.newLayoutDesigns ? imageClassV2 : imageClass}
onClick={() => props.onOpen(attachment)} onClick={() => props.onOpen(attachment)}
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { promptDesignPlaceholder, promptPlaceholder } from "./placeholder" import { promptPlaceholder } from "./placeholder"
describe("promptPlaceholder", () => { describe("promptPlaceholder", () => {
const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}` const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}`
@@ -46,18 +46,3 @@ describe("promptPlaceholder", () => {
expect(value).toBe("prompt.placeholder.simple") 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,11 +14,7 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
return input.t("prompt.placeholder.normal", { example: input.example }) return input.t("prompt.placeholder.normal", { example: input.example })
} }
export function promptDesignPlaceholder( export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) {
mode: PromptPlaceholderInput["mode"],
placeholder: string,
t: PromptPlaceholderInput["t"],
) {
if (mode === "shell") return placeholder if (mode === "shell") return placeholder
return t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" }) return "Ask anything, / for commands, @ for context..."
} }
@@ -22,7 +22,6 @@ import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state" import { createPromptSubmissionState } from "./submission-state"
import { normalizeSessionInfo } from "@/utils/session" import { normalizeSessionInfo } from "@/utils/session"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import { blobDataUrl } from "@/utils/draft-store"
type PendingPrompt = { type PendingPrompt = {
abort: AbortController abort: AbortController
@@ -96,12 +95,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
providerID: input.draft.model.providerID, providerID: input.draft.model.providerID,
variant: input.draft.variant, variant: input.draft.variant,
}, },
files: await Promise.all( files: images.map((attachment) => ({
images.map(async (attachment) => ({ uri: attachment.dataUrl,
uri: await blobDataUrl(attachment.blob, attachment.mime), name: attachment.filename,
name: attachment.filename, })),
})),
),
}) })
return true return true
} catch (err) { } catch (err) {
@@ -111,16 +108,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
} }
const messageID = input.messageID ?? Identifier.ascending("message") 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({ const { requestParts, optimisticParts } = buildRequestParts({
prompt: input.draft.prompt, prompt: input.draft.prompt,
context: input.draft.context, context: input.draft.context,
images: encodedImages, images,
text, text,
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
messageID, messageID,
@@ -525,12 +516,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
arguments: args.join(" "), arguments: args.join(" "),
agent, agent,
model: { id: model.modelID, providerID: model.providerID, variant }, model: { id: model.modelID, providerID: model.providerID, variant },
files: await Promise.all( files: images.map((attachment) => ({
images.map(async (attachment) => ({ uri: attachment.dataUrl,
uri: await blobDataUrl(attachment.blob, attachment.mime), name: attachment.filename,
name: attachment.filename, })),
})),
),
}) })
.catch((err) => { .catch((err) => {
serverSync().session.set("session_status", session.id, { type: "idle" }) serverSync().session.set("session_status", session.id, { type: "idle" })
@@ -18,7 +18,6 @@ import { useLanguage } from "@/context/language"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown" import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
export type PromptProject = { export type PromptProject = {
name?: string name?: string
@@ -198,8 +197,8 @@ export function PromptProjectSelector(props: {
}) { }) {
const [triggerReady, setTriggerReady] = createSignal(false) const [triggerReady, setTriggerReady] = createSignal(false)
let contentRef: HTMLDivElement | undefined let contentRef: HTMLDivElement | undefined
const dismiss = createMenuDismissController(() => contentRef)
let triggerFrame: number | undefined let triggerFrame: number | undefined
let restoreTrigger = true
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption. // Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
const setTriggerRef = (element: HTMLButtonElement) => { const setTriggerRef = (element: HTMLButtonElement) => {
@@ -222,15 +221,25 @@ export function PromptProjectSelector(props: {
props.controller.active() props.controller.active()
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`) ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
: undefined : undefined
const afterClose = (callback: () => void) => {
const complete = () => {
if (contentRef?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
}
const selectProject = (project: PromptProject) => { const selectProject = (project: PromptProject) => {
dismiss.preventTriggerRestore() restoreTrigger = false
props.controller.setOpen(false) props.controller.setOpen(false)
dismiss.afterClose(() => props.controller.select(project)) afterClose(() => props.controller.select(project))
} }
const selectAction = (server?: string) => { const selectAction = (server?: string) => {
dismiss.preventTriggerRestore() restoreTrigger = false
props.controller.setOpen(false) props.controller.setOpen(false)
dismiss.afterClose(() => props.controller.add(server)) afterClose(() => props.controller.add(server))
} }
const selectActive = () => { const selectActive = () => {
const project = props.controller.activeProject() const project = props.controller.activeProject()
@@ -258,7 +267,7 @@ export function PromptProjectSelector(props: {
) )
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap")) .filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
.findLast((element) => element.offsetParent !== null) .findLast((element) => element.offsetParent !== null)
dismiss.preventTriggerRestore() restoreTrigger = false
target?.focus() target?.focus()
queueMicrotask(() => { queueMicrotask(() => {
if (props.controller.open()) props.controller.setOpen(false) if (props.controller.open()) props.controller.setOpen(false)
@@ -282,10 +291,7 @@ export function PromptProjectSelector(props: {
placement={props.placement ?? "bottom"} placement={props.placement ?? "bottom"}
gutter={4} gutter={4}
modal={false} modal={false}
onOpenChange={(open) => { onOpenChange={(open) => props.controller.setOpen(open)}
if (open) dismiss.allowTriggerRestore()
props.controller.setOpen(open)
}}
> >
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} /> <DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
<DropdownMenu.Portal> <DropdownMenu.Portal>
@@ -294,9 +300,11 @@ export function PromptProjectSelector(props: {
id="prompt-project-menu" id="prompt-project-menu"
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none" class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
onOpenAutoFocus={(event) => event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()}
onPointerDownOutside={dismiss.preventTriggerRestore} onPointerDownOutside={() => (restoreTrigger = false)}
onFocusOutside={dismiss.preventTriggerRestore} onFocusOutside={() => (restoreTrigger = false)}
onCloseAutoFocus={dismiss.onCloseAutoFocus} onCloseAutoFocus={(event) => {
if (!restoreTrigger) event.preventDefault()
}}
> >
<div class="flex flex-col p-0.5"> <div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted"> <div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
@@ -4,3 +4,4 @@ export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTabV2 } from "./session-sortable-tab-v2" export { SortableTabV2 } from "./session-sortable-tab-v2"
export { SortableTerminalTab } from "./session-sortable-terminal-tab" export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view" export { NewSessionView } from "./session-new-view"
export { NewSessionDesignView } from "./session-new-design-view"
@@ -5,15 +5,12 @@ import { checksum } from "@opencode-ai/core/util/encode"
import { findLast } from "@opencode-ai/core/util/array" import { findLast } from "@opencode-ai/core/util/array"
import { same } from "@/utils/same" import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { Accordion } from "@opencode-ai/ui/accordion" import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { File } from "@opencode-ai/session-ui/file" import { File } from "@opencode-ai/session-ui/file"
import { Markdown } from "@opencode-ai/session-ui/markdown" import { Markdown } from "@opencode-ai/session-ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
@@ -123,8 +120,7 @@ export function SessionContextTab() {
() => { () => {
const revert = info()?.revert?.messageID const revert = info()?.revert?.messageID
if (!revert) return userMessages() if (!revert) return userMessages()
const boundary = userMessages().findIndex((message) => message.id === revert) return userMessages().filter((m) => m.id < revert)
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
}, },
emptyUserMessages, emptyUserMessages,
{ equals: same }, { equals: same },
@@ -224,31 +220,6 @@ export function SessionContextTab() {
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) }, { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
] satisfies { label: string; value: () => JSX.Element }[] ] satisfies { label: string; value: () => JSX.Element }[]
const exportSession = async () => {
const sessionID = params.id
if (!sessionID) return
try {
const data = await fetchSessionExport({
sessionID,
client: sdk().client,
})
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
showToast({
variant: "success",
icon: "circle-check",
title: language.t("toast.session.export.success.title"),
description: language.t("toast.session.export.success.description", { filename }),
})
} catch (err) {
showToast({
variant: "error",
title: language.t("toast.session.export.failed.title"),
description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
})
}
}
let scroll: HTMLDivElement | undefined let scroll: HTMLDivElement | undefined
let frame: number | undefined let frame: number | undefined
let pending: { x: number; y: number } | undefined let pending: { x: number; y: number } | undefined
@@ -357,18 +328,7 @@ export function SessionContextTab() {
</Show> </Show>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex items-center justify-between"> <div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Button
size="small"
variant="ghost"
class="gap-1.5 px-2 text-text-weak hover:text-text-base"
onClick={exportSession}
>
<Icon name="download" size="small" />
<span>{language.t("context.export.session")}</span>
</Button>
</div>
<Accordion multiple> <Accordion multiple>
<For each={messages()}> <For each={messages()}>
{(message) => ( {(message) => (
@@ -289,9 +289,9 @@ export function SessionHeader() {
return ( return (
<> <>
<Show when={search() && centerMount()} keyed> <Show when={search() && centerMount()}>
{(mount) => ( {(mount) => (
<Portal mount={mount}> <Portal mount={mount()}>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -308,10 +308,10 @@ export function SessionHeader() {
</span> </span>
</div> </div>
<Show when={hotkey()} keyed> <Show when={hotkey()}>
{(keybind) => ( {(keybind) => (
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker"> <Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
{keybind} {keybind()}
</Keybind> </Keybind>
)} )}
</Show> </Show>
@@ -319,9 +319,9 @@ export function SessionHeader() {
</Portal> </Portal>
)} )}
</Show> </Show>
<Show when={rightMount()} keyed> <Show when={rightMount()}>
{(mount) => ( {(mount) => (
<Portal mount={mount}> <Portal mount={mount()}>
<Show <Show
when={isV2} when={isV2}
fallback={ fallback={
@@ -0,0 +1,16 @@
import type { JSX } from "solid-js"
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
export function NewSessionDesignView(props: { children: JSX.Element }) {
return (
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
<div class="mt-8">{props.children}</div>
</div>
</div>
</div>
)
}
@@ -29,7 +29,7 @@ import {
} from "@/context/settings" } from "@/context/settings"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { ExternalLink } from "./external-link" import { Link } from "./link"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
let demoSoundState = { let demoSoundState = {
@@ -482,7 +482,7 @@ export const SettingsGeneral: Component = () => {
description={ description={
<> <>
{language.t("settings.general.row.theme.description")}{" "} {language.t("settings.general.row.theme.description")}{" "}
<ExternalLink href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</ExternalLink> <Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
</> </>
} }
> >
+62 -290
View File
@@ -30,8 +30,6 @@ type KeybindMeta = {
type KeybindMap = Record<string, string | undefined> type KeybindMap = Record<string, string | undefined>
type CommandContext = ReturnType<typeof useCommand> type CommandContext = ReturnType<typeof useCommand>
type LanguageContext = ReturnType<typeof useLanguage>
type SettingsContext = ReturnType<typeof useSettings>
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"] const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
@@ -124,7 +122,7 @@ function keybinds(value: unknown): KeybindMap {
return value as KeybindMap return value as KeybindMap
} }
function listFor(command: Pick<CommandContext, "catalog" | "options">, map: KeybindMap, palette: string) { function listFor(command: CommandContext, map: KeybindMap, palette: string) {
const out = new Map<string, KeybindMeta>() const out = new Map<string, KeybindMeta>()
out.set(PALETTE_ID, { title: palette, group: "General" }) out.set(PALETTE_ID, { title: palette, group: "General" })
@@ -264,274 +262,7 @@ function useKeyCapture(input: {
}) })
} }
export function createKeybindSettingsController(
input: {
command: Pick<CommandContext, "catalog" | "options" | "keybinds">
settings: {
current: { keybinds: unknown }
keybinds: Pick<SettingsContext["keybinds"], "get" | "set" | "resetAll">
}
target?: Document
notify?: (toast: { title: string; description: string }) => void
},
language: Pick<LanguageContext, "locale" | "t"> = useLanguage(),
) {
const [store, setStore] = createStore({ active: null as string | null })
const overrides = createMemo(() => keybinds(input.settings.current.keybinds))
const list = createMemo(() => {
language.locale()
return listFor(input.command, overrides(), language.t("command.palette"))
})
const grouped = createMemo(() => groupedFor(list()))
const title = (id: string) => list().get(id)?.title ?? ""
const effective = (id: string) => {
if (id === PALETTE_ID) return input.settings.keybinds.get(id) ?? DEFAULT_PALETTE_KEYBIND
const custom = input.settings.keybinds.get(id)
if (typeof custom === "string") return custom
const live = input.command.options.find((item) => item.id === id)
if (live?.keybind) return live.keybind
return input.command.catalog.find((item) => item.id === id)?.keybind
}
const used = createMemo(() => {
const value = new Map<string, { id: string; title: string }[]>()
for (const id of list().keys()) {
for (const signature of signatures(effective(id))) {
const items = value.get(signature)
if (items) {
items.push({ id, title: title(id) })
continue
}
value.set(signature, [{ id, title: title(id) }])
}
}
return value
})
const stop = () => {
if (!store.active) return
setStore("active", null)
input.command.keybinds(true)
}
const toggle = (id: string) => {
if (store.active === id) {
stop()
return
}
if (store.active) stop()
setStore("active", id)
input.command.keybinds(false)
}
const notify = input.notify ?? ((toast: { title: string; description: string }) => showToast(toast))
const handle = (event: KeyboardEvent) => {
const id = store.active
if (!id) return
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
if (event.key === "Escape") {
stop()
return
}
const clear =
(event.key === "Backspace" || event.key === "Delete") &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey
if (clear) {
input.settings.keybinds.set(id, "none")
stop()
return
}
const next = recordKeybind(event)
if (!next) return
const conflicts = new Map<string, string>()
for (const signature of signatures(next)) {
for (const item of used().get(signature) ?? []) {
if (item.id === id) continue
conflicts.set(item.id, item.title)
}
}
if (conflicts.size > 0) {
notify({
title: language.t("settings.shortcuts.conflict.title"),
description: language.t("settings.shortcuts.conflict.description", {
keybind: formatKeybind(next, language.t),
titles: [...conflicts.values()].join(", "),
}),
})
return
}
input.settings.keybinds.set(id, next)
stop()
}
const target = input.target ?? (typeof document === "object" ? document : undefined)
if (target) makeEventListener(target, "keydown", handle, { capture: true })
onCleanup(() => {
if (store.active) input.command.keybinds(true)
})
return {
catalog: {
groups: GROUPS,
filtered: (query: string) =>
filteredFor(query, list(), grouped(), (id) => formatKeybind(effective(id) ?? "", language.t)),
title,
keybind: (id: string) => formatKeybind(effective(id) ?? "", language.t),
},
capture: {
active: () => store.active,
toggle,
},
settings: {
hasOverrides: () => Object.values(overrides()).some((value) => typeof value === "string"),
reset: () => {
stop()
input.settings.keybinds.resetAll()
notify({
title: language.t("settings.shortcuts.reset.toast.title"),
description: language.t("settings.shortcuts.reset.toast.description"),
})
},
},
}
}
function SettingsKeybindsV2() {
const command = useCommand()
const settings = useSettings()
const controller = createKeybindSettingsController({
command,
settings,
})
return (
<SettingsKeybindsV2View
groups={controller.catalog.groups}
filtered={controller.catalog.filtered}
title={controller.catalog.title}
keybind={controller.catalog.keybind}
active={controller.capture.active}
onCapture={controller.capture.toggle}
hasOverrides={controller.settings.hasOverrides}
onReset={controller.settings.reset}
/>
)
}
function SettingsKeybindsV2View(props: {
groups: KeybindGroup[]
filtered: (query: string) => Map<KeybindGroup, string[]>
title: (id: string) => string
keybind: (id: string) => string
active: () => string | null
onCapture: (id: string) => void
hasOverrides: () => boolean
onReset: () => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ filter: "" })
const filtered = createMemo(() => props.filtered(store.filter))
const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0))
return (
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</ButtonV2>
</div>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
appearance="base"
value={store.filter}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.shortcuts.search.placeholder")}
/>
<Show when={store.filter}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
</Show>
</div>
</div>
<div class="settings-v2-tab-body">
<div class="settings-v2-shortcuts flex flex-col gap-8">
<For each={props.groups}>
{(group) => (
<Show when={(filtered().get(group) ?? []).length > 0}>
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t(groupKey[group])}</h3>
<SettingsListV2>
<For each={filtered().get(group) ?? []}>
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span>{props.title(id)}</span>
<button
type="button"
data-keybind-id={id}
classList={{
"settings-v2-keybind-button": true,
"settings-v2-keybind-button--active": props.active() === id,
}}
onClick={() => props.onCapture(id)}
>
<Show
when={props.active() === id}
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
</Show>
</button>
</div>
)}
</For>
</SettingsListV2>
</div>
</Show>
)}
</For>
<Show when={store.filter && !hasResults()}>
<div class="settings-v2-shortcuts-status">
<span>{language.t("settings.shortcuts.search.empty")}</span>
<span class="settings-v2-shortcuts-status-filter">&quot;{store.filter}&quot;</span>
</div>
</Show>
</div>
</div>
</>
)
}
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => { export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
if (props.v2) return <SettingsKeybindsV2 />
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
@@ -745,37 +476,78 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
) )
return ( return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"> <Show
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> when={props.v2}
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]"> fallback={
<div class="flex items-center justify-between gap-4"> <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2> <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}> <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
{language.t("settings.shortcuts.reset.button")} <div class="flex items-center justify-between gap-4">
</Button> <h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
</div> <Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</Button>
</div>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base"> <div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" /> <Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField <TextField
variant="ghost" variant="ghost"
type="text" type="text"
value={store.filter}
onChange={(v) => setStore("filter", v)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="flex-1"
/>
<Show when={store.filter}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
</Show>
</div>
</div>
</div>
{groups}
</div>
}
>
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<ButtonV2 variant="ghost" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</ButtonV2>
</div>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
appearance="base"
value={store.filter} value={store.filter}
onChange={(v) => setStore("filter", v)} onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("settings.shortcuts.search.placeholder")} placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false} spellcheck={false}
autocorrect="off" autocorrect="off"
autocomplete="off" autocomplete="off"
autocapitalize="off" autocapitalize="off"
class="flex-1" aria-label={language.t("settings.shortcuts.search.placeholder")}
/> />
<Show when={store.filter}> <Show when={store.filter}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} /> <IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
</Show> </Show>
</div> </div>
</div> </div>
</div> <div class="settings-v2-tab-body">{groups}</div>
{groups} </>
</div> </Show>
) )
} }
@@ -1,70 +0,0 @@
import { onCleanup } from "solid-js"
export type ShellOption = {
path: string
name: string
acceptable: boolean
}
export type ShellSelectOption = {
id: string
value: string
name: string
terminalOnly: boolean
}
export function createShellOptions(input: { shells: ShellOption[]; current: string | undefined }) {
const counts = input.shells.reduce((result, shell) => {
result.set(shell.name, (result.get(shell.name) ?? 0) + 1)
return result
}, new Map<string, number>())
const options: ShellSelectOption[] = [
{ id: "auto", value: "", name: "", terminalOnly: false },
...input.shells.map((shell) => {
const ambiguous = (counts.get(shell.name) ?? 0) > 1
const name = ambiguous ? shell.path : shell.name
return {
id: shell.path,
value: ambiguous ? shell.path : shell.name,
name,
terminalOnly: !shell.acceptable,
}
}),
]
if (input.current && !options.some((option) => option.value === input.current)) {
options.push({ id: input.current, value: input.current, name: input.current, terminalOnly: false })
}
return options
}
export function createSoundPreviewController(player: (id: string | undefined) => Promise<(() => void) | undefined>) {
let cleanup: (() => void) | undefined
let timeout: ReturnType<typeof setTimeout> | undefined
let run = 0
const stop = () => {
run += 1
cleanup?.()
clearTimeout(timeout)
cleanup = undefined
timeout = undefined
}
const play = (id: string | undefined) => {
stop()
if (!id) return
const current = ++run
timeout = setTimeout(() => {
timeout = undefined
void player(id).then((next) => {
if (run === current) {
cleanup = next
return
}
next?.()
})
}, 100)
}
onCleanup(stop)
return { play, stop }
}
@@ -1,53 +0,0 @@
import { describe, expect, test, vi } from "bun:test"
import { createRoot } from "solid-js"
import { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
describe("settings v2 controllers", () => {
test("normalizes shell names and preserves an unavailable configured shell", () => {
expect(
createShellOptions({
shells: [
{ path: "/bin/bash", name: "bash", acceptable: true },
{ path: "/opt/bash", name: "bash", acceptable: false },
{ path: "/bin/zsh", name: "zsh", acceptable: true },
],
current: "fish",
}),
).toEqual([
{ id: "auto", value: "", name: "", terminalOnly: false },
{ id: "/bin/bash", value: "/bin/bash", name: "/bin/bash", terminalOnly: false },
{ id: "/opt/bash", value: "/opt/bash", name: "/opt/bash", terminalOnly: true },
{ id: "/bin/zsh", value: "zsh", name: "zsh", terminalOnly: false },
{ id: "fish", value: "fish", name: "fish", terminalOnly: false },
])
})
test("debounces previews and stops owned audio on disposal", async () => {
vi.useFakeTimers()
try {
const played: string[] = []
const stopped: string[] = []
const owned = createRoot((dispose) => ({
dispose,
preview: createSoundPreviewController(async (id) => {
played.push(id ?? "")
return () => stopped.push(id ?? "")
}),
}))
owned.preview.play("first")
vi.advanceTimersByTime(99)
expect(played).toEqual([])
owned.preview.play("second")
vi.advanceTimersByTime(100)
await Promise.resolve()
expect(played).toEqual(["second"])
owned.dispose()
expect(stopped).toEqual(["second"])
} finally {
vi.useRealTimers()
}
})
})
@@ -1,173 +0,0 @@
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { usePermission } from "@/context/permission"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import {
monoDefault,
monoFontFamily,
monoInput,
sansDefault,
sansFontFamily,
sansInput,
terminalDefault,
terminalFontFamily,
terminalInput,
useSettings,
} from "@/context/settings"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior"
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
export type { ShellOption, ShellSelectOption } from "./general-controller-behavior"
export function createPermissionScopeController(sessionID: Accessor<string | undefined>) {
const permission = usePermission()
const serverSync = useServerSync()
const directory = createMemo(() => {
const id = sessionID()
if (!id) return undefined
return serverSync().session.lineage.peek(id)?.session.directory
})
return {
accepting: createMemo(() => {
const id = sessionID()
const dir = directory()
if (!id || !dir) return false
return permission.isAutoAccepting(id, dir)
}),
enabled: createMemo(() => !!directory()),
set: (checked: boolean) => {
const id = sessionID()
const dir = directory()
if (!id || !dir) return
if (checked) return permission.enableAutoAccept(id, dir)
permission.disableAutoAccept(id, dir)
},
}
}
export function createShellSettingsController() {
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const [shells] = createResource(
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") return (await sdk.client.pty.shells()).data ?? []
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] },
)
const current = createMemo(() => serverSync().data.config.shell ?? "")
return {
shells: () => shells.latest,
current,
select: (value: string) => {
if (value === current()) return
void serverSync().updateConfig({ shell: value })
},
}
}
export function createAppearanceSettingsController() {
const settings = useSettings()
const theme = useTheme()
const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
onMount(() => void theme.loadThemes())
return {
scheme: {
current: theme.colorScheme,
select: (value: ColorScheme) => theme.setColorScheme(value),
},
theme: {
options: themes,
current: createMemo(() => themes().find((option) => option.id === theme.themeId())),
select: (option: { id: string } | null) => option && theme.setTheme(option.id),
},
fonts: {
ui: createMemo(() => ({
value: sansInput(settings.appearance.uiFont()),
family: sansFontFamily(settings.appearance.uiFont()),
placeholder: sansDefault,
})),
code: createMemo(() => ({
value: monoInput(settings.appearance.font()),
family: monoFontFamily(settings.appearance.font()),
placeholder: monoDefault,
})),
terminal: createMemo(() => ({
value: terminalInput(settings.appearance.terminalFont()),
family: terminalFontFamily(settings.appearance.terminalFont()),
placeholder: terminalDefault,
})),
setUI: (value: string) => settings.appearance.setUIFont(value),
setCode: (value: string) => settings.appearance.setFont(value),
setTerminal: (value: string) => settings.appearance.setTerminalFont(value),
},
}
}
const noneSound = { id: "none", label: "sound.option.none" } as const
export const soundOptions = [noneSound, ...SOUND_OPTIONS]
export type SoundSelectOption = (typeof soundOptions)[number]
export function createSoundSettingsController() {
const settings = useSettings()
const preview = createSoundPreviewController(playSoundById)
const channel = (
enabled: Accessor<boolean>,
current: Accessor<string>,
setEnabled: (value: boolean) => void,
set: (id: string) => void,
) => ({
current: createMemo(() =>
enabled() ? (soundOptions.find((option) => option.id === current()) ?? noneSound) : noneSound,
),
highlight: (option: SoundSelectOption | undefined) => {
if (!option) return
preview.play(option.id === "none" ? undefined : option.id)
},
select: (option: SoundSelectOption | null) => {
if (!option) return
if (option.id === "none") {
setEnabled(false)
preview.stop()
return
}
setEnabled(true)
set(option.id)
preview.play(option.id)
},
})
return {
agent: channel(
settings.sounds.agentEnabled,
settings.sounds.agent,
(value) => settings.sounds.setAgentEnabled(value),
(id) => settings.sounds.setAgent(id),
),
permissions: channel(
settings.sounds.permissionsEnabled,
settings.sounds.permissions,
(value) => settings.sounds.setPermissionsEnabled(value),
(id) => settings.sounds.setPermissions(id),
),
errors: channel(
settings.sounds.errorsEnabled,
settings.sounds.errors,
(value) => settings.sounds.setErrorsEnabled(value),
(id) => settings.sounds.setErrors(id),
),
}
}
export type PermissionScopeController = ReturnType<typeof createPermissionScopeController>
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
@@ -1,297 +1,182 @@
import { Component, Show, createMemo, createResource } from "solid-js" import { Component, Show, createMemo, createResource, onMount } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action" import { useUpdaterAction } from "../updater-action"
import { useSettings } from "@/context/settings" import {
import { ExternalLink } from "../external-link" monoDefault,
monoFontFamily,
monoInput,
sansDefault,
sansFontFamily,
sansInput,
terminalDefault,
terminalFontFamily,
terminalInput,
useSettings,
} from "@/context/settings"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "../link"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row" import { SettingsRowV2 } from "./parts/row"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition" import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
import {
createAppearanceSettingsController,
createPermissionScopeController,
createShellOptions,
createShellSettingsController,
createSoundSettingsController,
soundOptions,
type AppearanceSettingsController,
type PermissionScopeController,
type ShellSettingsController,
type SoundSettingsController,
} from "./general-controllers"
import "./settings-v2.css" import "./settings-v2.css"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"] let demoSoundState = {
const fontSettings = { cleanup: undefined as (() => void) | undefined,
ui: { timeout: undefined as NodeJS.Timeout | undefined,
action: "settings-ui-font", run: 0,
title: "settings.general.row.uiFont.title",
description: "settings.general.row.uiFont.description",
font: "ui",
input: "setUI",
},
code: {
action: "settings-code-font",
title: "settings.general.row.font.title",
description: "settings.general.row.font.description",
font: "code",
input: "setCode",
},
terminal: {
action: "settings-terminal-font",
title: "settings.general.row.terminalFont.title",
description: "settings.general.row.terminalFont.description",
font: "terminal",
input: "setTerminal",
},
} as const
const soundSettings = {
agent: {
action: "settings-sounds-agent",
title: "settings.general.sounds.agent.title",
description: "settings.general.sounds.agent.description",
},
permissions: {
action: "settings-sounds-permissions",
title: "settings.general.sounds.permissions.title",
description: "settings.general.sounds.permissions.description",
},
errors: {
action: "settings-sounds-errors",
title: "settings.general.sounds.errors.title",
description: "settings.general.sounds.errors.description",
},
} as const
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
const language = useLanguage()
return (
<SettingsRowV2
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}
>
<div data-action="settings-auto-accept-permissions">
<Switch
checked={props.controller.accepting()}
disabled={!props.controller.enabled()}
onChange={props.controller.set}
/>
</div>
</SettingsRowV2>
)
} }
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { type ThemeOption = {
const language = useLanguage() id: string
const options = createMemo(() => name: string
createShellOptions({
shells: props.controller.shells(),
current: props.controller.current(),
}),
)
return (
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
options={options()}
current={options().find((option) => option.value === props.controller.current()) ?? options()[0]}
placement="bottom-end"
gutter={6}
value={(option) => option.id}
label={(option) => {
if (option.id === "auto") return language.t("settings.general.row.shell.autoDefault")
if (!option.terminalOnly) return option.name
return `${option.name} (${language.t("settings.general.row.shell.terminalOnly")})`
}}
onSelect={(option) => option && props.controller.select(option.value)}
/>
</SettingsRowV2>
)
} }
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => { type ShellOption = {
const language = useLanguage() path: string
return ( name: string
<div class="settings-v2-section"> acceptable: boolean
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<SelectV2
appearance="inline"
data-action="settings-color-scheme"
options={schemeOptions}
current={schemeOptions.find((option) => option === props.controller.scheme.current())}
placement="bottom-end"
gutter={6}
label={(option) => {
if (option === "system") return language.t("theme.scheme.system")
if (option === "light") return language.t("theme.scheme.light")
return language.t("theme.scheme.dark")
}}
onSelect={(option) => option && props.controller.scheme.select(option)}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
{language.t("common.learnMore")}
</ExternalLink>
</>
}
>
<SelectV2
appearance="inline"
data-action="settings-theme"
options={props.controller.theme.options()}
current={props.controller.theme.current()}
placement="bottom-end"
gutter={6}
value={(option) => option.id}
label={(option) => option.name}
onSelect={props.controller.theme.select}
/>
</SettingsRowV2>
<FontSetting kind="ui" fonts={props.controller.fonts} />
<FontSetting kind="code" fonts={props.controller.fonts} />
<FontSetting kind="terminal" fonts={props.controller.fonts} />
</SettingsListV2>
</div>
)
} }
const FontSetting: Component<{ type ShellSelectOption = {
kind: "ui" | "code" | "terminal" id: string
fonts: AppearanceSettingsController["fonts"] value: string
}> = (props) => { label: string
const language = useLanguage()
const config = () => fontSettings[props.kind]
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action={config().action}
type="text"
appearance="base"
value={props.fonts[config().font]().value}
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
placeholder={props.fonts[config().font]().placeholder}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t(config().title)}
style={{ "font-family": props.fonts[config().font]().family }}
/>
</div>
</SettingsRowV2>
)
} }
const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => { // To prevent audio from overlapping/playing very quickly when navigating the settings menus,
const language = useLanguage() // delay the playback by 100ms during quick selection changes and pause existing sounds.
return ( const stopDemoSound = () => {
<div class="settings-v2-section"> demoSoundState.run += 1
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3> if (demoSoundState.cleanup) {
<SettingsListV2> demoSoundState.cleanup()
<SoundSetting kind="agent" channel={props.controller.agent} /> }
<SoundSetting kind="permissions" channel={props.controller.permissions} /> clearTimeout(demoSoundState.timeout)
<SoundSetting kind="errors" channel={props.controller.errors} /> demoSoundState.cleanup = undefined
</SettingsListV2>
</div>
)
} }
const SoundSetting: Component<{ const playDemoSound = (id: string | undefined) => {
kind: "agent" | "permissions" | "errors" stopDemoSound()
channel: SoundSettingsController["agent"] if (!id) return
}> = (props) => {
const language = useLanguage()
const config = () => soundSettings[props.kind]
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<SelectV2
appearance="inline"
data-action={config().action}
options={soundOptions}
current={props.channel.current()}
value={(option) => option.id}
label={(option) => language.t(option.label)}
onHighlight={props.channel.highlight}
onSelect={props.channel.select}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
)
}
const LanguageSetting = () => { const run = ++demoSoundState.run
const language = useLanguage() demoSoundState.timeout = setTimeout(() => {
const options = createMemo(() => void playSoundById(id).then((cleanup) => {
language.locales.map((locale) => ({ if (demoSoundState.run !== run) {
value: locale, cleanup?.()
label: language.label(locale), return
})), }
) demoSoundState.cleanup = cleanup
return ( })
<SettingsRowV2 }, 100)
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<SelectV2
appearance="inline"
data-action="settings-language"
options={options()}
placement="bottom-end"
gutter={6}
current={options().find((option) => option.value === language.locale())}
value={(option) => option.value}
label={(option) => option.label}
onSelect={(option) => option && language.setLocale(option.value)}
/>
</SettingsRowV2>
)
} }
export const SettingsGeneralV2: Component<{ export const SettingsGeneralV2: Component<{
sessionID?: string sessionID?: string
}> = (props) => { }> = (props) => {
const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const permission = usePermission()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
const settings = useSettings() const settings = useSettings()
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const mobile = createMediaQuery("(max-width: 767px)") const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction() const updater = useUpdaterAction()
const permissionScope = createPermissionScopeController(() => props.sessionID)
const shell = createShellSettingsController() const dir = createMemo(() => {
const appearance = createAppearanceSettingsController() if (!props.sessionID) return undefined
const sounds = createSoundSettingsController() return serverSync().session.lineage.peek(props.sessionID)?.session.directory
})
const accepting = createMemo(() => {
const value = dir()
if (!value || !props.sessionID) return false
return permission.isAutoAccepting(props.sessionID, value)
})
const toggleAccept = (checked: boolean) => {
const value = dir()
if (!value || !props.sessionID) return
if (checked) {
permission.enableAutoAccept(props.sessionID, value)
return
}
permission.disableAutoAccept(props.sessionID, value)
}
const desktop = createMemo(() => platform.platform === "desktop") const desktop = createMemo(() => platform.platform === "desktop")
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource(
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] },
)
const [pinchZoom, { mutate: setPinchZoom }] = createResource( const [pinchZoom, { mutate: setPinchZoom }] = createResource(
() => desktop() && "getPinchZoomEnabled" in platform, () => (desktop() && platform.getPinchZoomEnabled ? true : false),
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false), () => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
{ initialValue: false }, { initialValue: false },
) )
onMount(() => {
void theme.loadThemes()
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = serverSync().data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
}
const options = [
autoOption,
...list.map((s) => {
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
const text = ambiguousName ? s.path : s.name
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
return {
id: s.path,
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
value: ambiguousName ? s.path : s.name,
label,
}
}),
]
if (current && !options.some((o) => o.value === current)) {
options.push({ id: current, value: current, label: current })
}
return options
})
const onPinchZoomChange = (checked: boolean) => { const onPinchZoomChange = (checked: boolean) => {
setPinchZoom(checked) setPinchZoom(checked)
const update = platform.setPinchZoomEnabled?.(checked) const update = platform.setPinchZoomEnabled?.(checked)
@@ -299,6 +184,52 @@ export const SettingsGeneralV2: Component<{
void update.catch(() => setPinchZoom(!checked)) void update.catch(() => setPinchZoom(!checked))
} }
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
{ value: "light", label: language.t("theme.scheme.light") },
{ value: "dark", label: language.t("theme.scheme.dark") },
])
const languageOptions = createMemo(() =>
language.locales.map((locale) => ({
value: locale,
label: language.label(locale),
})),
)
const noneSound = { id: "none", label: "sound.option.none" } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const mono = () => monoInput(settings.appearance.font())
const sans = () => sansInput(settings.appearance.uiFont())
const terminal = () => terminalInput(settings.appearance.terminalFont())
const soundSelectProps = (
enabled: () => boolean,
current: () => string,
setEnabled: (value: boolean) => void,
set: (id: string) => void,
) => ({
options: soundOptions,
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
value: (o: (typeof soundOptions)[number]) => o.id,
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
playDemoSound(option.id === "none" ? undefined : option.id)
},
onSelect: (option: (typeof soundOptions)[number] | null) => {
if (!option) return
if (option.id === "none") {
setEnabled(false)
stopDemoSound()
return
}
setEnabled(true)
set(option.id)
playDemoSound(option.id)
},
})
const InterfaceSection = () => ( const InterfaceSection = () => (
<LayoutTransitionToggle <LayoutTransitionToggle
title={language.t("settings.general.row.newInterface.title")} title={language.t("settings.general.row.newInterface.title")}
@@ -320,18 +251,59 @@ export const SettingsGeneralV2: Component<{
title={language.t("settings.general.row.newInterfaceNotice.title")} title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description")} description={language.t("settings.general.row.newInterfaceNotice.description")}
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")} dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
onDismiss={() => settings.general.dismissNewInterfaceNotice()} onDismiss={settings.general.dismissNewInterfaceNotice}
/> />
) )
const GeneralSection = () => ( const GeneralSection = () => (
<div class="settings-v2-section"> <div class="settings-v2-section">
<SettingsListV2> <SettingsListV2>
<LanguageSetting /> <SettingsRowV2
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<SelectV2
appearance="inline"
data-action="settings-language"
options={languageOptions()}
placement="bottom-end"
gutter={6}
current={languageOptions().find((o) => o.value === language.locale())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && language.setLocale(option.value)}
/>
</SettingsRowV2>
<PermissionScopeSetting controller={permissionScope} /> <SettingsRowV2
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}
>
<div data-action="settings-auto-accept-permissions">
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
</div>
</SettingsRowV2>
<ShellSetting controller={shell} /> <SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
placement="bottom-end"
gutter={6}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync().updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>
<SettingsRowV2 <SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")} title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -442,6 +414,124 @@ export const SettingsGeneralV2: Component<{
</div> </div>
) )
const AppearanceSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<SelectV2
appearance="inline"
data-action="settings-color-scheme"
options={colorSchemeOptions()}
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
placement="bottom-end"
gutter={6}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<Link class="settings-v2-link" href="https://opencode.ai/docs/themes/">
{language.t("common.learnMore")}
</Link>
</>
}
>
<SelectV2
appearance="inline"
data-action="settings-theme"
options={themeOptions()}
current={themeOptions().find((o) => o.id === theme.themeId())}
placement="bottom-end"
gutter={6}
value={(o) => o.id}
label={(o) => o.name}
onSelect={(option) => {
if (!option) return
theme.setTheme(option.id)
}}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.uiFont.title")}
description={language.t("settings.general.row.uiFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action="settings-ui-font"
type="text"
appearance="base"
value={sans()}
onInput={(event) => settings.appearance.setUIFont(event.currentTarget.value)}
placeholder={sansDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.general.row.uiFont.title")}
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.font.title")}
description={language.t("settings.general.row.font.description")}
>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action="settings-code-font"
type="text"
appearance="base"
value={mono()}
onInput={(event) => settings.appearance.setFont(event.currentTarget.value)}
placeholder={monoDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.general.row.font.title")}
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.terminalFont.title")}
description={language.t("settings.general.row.terminalFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action="settings-terminal-font"
type="text"
appearance="base"
value={terminal()}
onInput={(event) => settings.appearance.setTerminalFont(event.currentTarget.value)}
placeholder={terminalDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.general.row.terminalFont.title")}
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
/>
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const NotificationsSection = () => ( const NotificationsSection = () => (
<div class="settings-v2-section"> <div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3> <h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
@@ -486,6 +576,68 @@ export const SettingsGeneralV2: Component<{
</div> </div>
) )
const SoundsSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
<SelectV2
appearance="inline"
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agentEnabled(),
() => settings.sounds.agent(),
(value) => settings.sounds.setAgentEnabled(value),
(id) => settings.sounds.setAgent(id),
)}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<SelectV2
appearance="inline"
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissionsEnabled(),
() => settings.sounds.permissions(),
(value) => settings.sounds.setPermissionsEnabled(value),
(id) => settings.sounds.setPermissions(id),
)}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<SelectV2
appearance="inline"
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errorsEnabled(),
() => settings.sounds.errors(),
(value) => settings.sounds.setErrorsEnabled(value),
(id) => settings.sounds.setErrors(id),
)}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const UpdatesSection = () => ( const UpdatesSection = () => (
<div class="settings-v2-section"> <div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3> <h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
@@ -507,7 +659,7 @@ export const SettingsGeneralV2: Component<{
title={language.t("settings.updates.row.check.title")} title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")} description={language.t("settings.updates.row.check.description")}
> >
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={() => updater.run()}> <ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={updater.run}>
{language.t(updater.action().label)} {language.t(updater.action().label)}
</ButtonV2> </ButtonV2>
</SettingsRowV2> </SettingsRowV2>
@@ -552,11 +704,11 @@ export const SettingsGeneralV2: Component<{
<GeneralSection /> <GeneralSection />
<AppearanceSection controller={appearance} /> <AppearanceSection />
<NotificationsSection /> <NotificationsSection />
<SoundsSection controller={sounds} /> <SoundsSection />
<Show when={desktop()}> <Show when={desktop()}>
<UpdatesSection /> <UpdatesSection />
@@ -5,12 +5,9 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { type Component, For, Show } from "solid-js" import { type Component, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { useServerSDK } from "@/context/server-sdk"
import { popularProviders } from "@/hooks/use-providers" import { popularProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row" import { SettingsRowV2 } from "./parts/row"
import "./settings-v2.css" import "./settings-v2.css"
@@ -22,11 +19,6 @@ const PROVIDER_ICON_SIZE = 16
export const SettingsModelsV2: Component = () => { export const SettingsModelsV2: Component = () => {
const language = useLanguage() const language = useLanguage()
const models = useModels() const models = useModels()
const serverSdk = useServerSDK()
const [store, setStore] = persisted(
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
createStore({ collapsed: {} as Record<string, boolean> }),
)
const list = useFilteredList<ModelItem>({ const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(), items: (_filter) => models.list(),
@@ -102,82 +94,41 @@ export const SettingsModelsV2: Component = () => {
} }
> >
<For each={list.grouped.latest}> <For each={list.grouped.latest}>
{(group) => { {(group) => (
const searching = () => list.filter().length > 0 <div class="settings-v2-section" data-component="settings-models-provider">
const expanded = () => searching() || !store.collapsed[group.category] <div class="settings-v2-models-group-header">
<ProviderIcon
return ( id={group.category}
<div width={PROVIDER_ICON_SIZE}
class="settings-v2-section" height={PROVIDER_ICON_SIZE}
data-component="settings-models-provider" class="settings-v2-models-provider-icon shrink-0"
data-expanded={expanded() ? "" : undefined} />
> <h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
<h3 class="settings-v2-models-group-header">
<button
type="button"
class="settings-v2-models-group-trigger"
aria-expanded={expanded()}
disabled={searching()}
onClick={() => setStore("collapsed", group.category, expanded())}
>
<span class="settings-v2-models-group-chevron">
<Show
when={expanded()}
fallback={
<svg width="5" height="6" viewBox="0 0 5 6" fill="none" aria-hidden="true">
<path
d="M0.75194 5.31663C0.41861 5.51103 0 5.27063 0 4.88473V0.500754C0 0.114854 0.41861 -0.125577 0.75194 0.0688635L4.5096 2.26084C4.8404 2.45378 4.8404 2.93168 4.5096 3.12462L0.75194 5.31663Z"
fill="currentColor"
/>
</svg>
}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M5.37624 6.75194C5.18184 6.41861 5.42224 6 5.80814 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43203 10.5096C8.23909 10.8404 7.76119 10.8404 7.56825 10.5096L5.37624 6.75194Z"
fill="currentColor"
/>
</svg>
</Show>
</span>
<span class="settings-v2-models-group-label">
<ProviderIcon
id={group.category}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-models-provider-icon shrink-0"
/>
<span class="settings-v2-section-title">{group.items[0].provider.name}</span>
</span>
</button>
</h3>
<Show when={expanded()}>
<SettingsListV2>
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<SettingsRowV2 title={item.name} description="">
<div>
<Switch
checked={models.visible(key)}
onChange={(checked) => {
models.setVisibility(key, checked)
}}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRowV2>
)
}}
</For>
</SettingsListV2>
</Show>
</div> </div>
) <SettingsListV2>
}} <For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<SettingsRowV2 title={item.name} description="">
<div>
<Switch
checked={models.visible(key)}
onChange={(checked) => {
models.setVisibility(key, checked)
}}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRowV2>
)
}}
</For>
</SettingsListV2>
</div>
)}
</For> </For>
</Show> </Show>
</Show> </Show>
@@ -287,7 +287,7 @@
} }
.settings-v2-provider-env-hint { .settings-v2-provider-env-hint {
padding-inline-end: 12px; padding-right: 12px;
font-size: 13px; font-size: 13px;
font-weight: 440; font-weight: 440;
line-height: 1; line-height: 1;
@@ -311,7 +311,7 @@
line-height: 1; line-height: 1;
color: var(--v2-text-text-accent); color: var(--v2-text-text-accent);
cursor: pointer; cursor: pointer;
text-align: start; text-align: left;
} }
.settings-v2-providers-view-all:hover { .settings-v2-providers-view-all:hover {
@@ -361,76 +361,26 @@
} }
.settings-v2-tab-search [data-slot="text-input-v2-input"] { .settings-v2-tab-search [data-slot="text-input-v2-input"] {
padding-inline-end: 28px; padding-right: 28px;
} }
.settings-v2-tab-search-clear { .settings-v2-tab-search-clear {
position: absolute; position: absolute;
top: 50%; top: 50%;
inset-inline-end: 6px; right: 6px;
z-index: 1; z-index: 1;
transform: translateY(-50%); transform: translateY(-50%);
} }
.settings-v2-models { .settings-v2-models {
gap: 12px; gap: 24px;
}
.settings-v2-models .settings-v2-section {
gap: 12px;
}
.settings-v2-models .settings-v2-section[data-expanded] {
padding-bottom: 12px;
} }
.settings-v2-models-group-header { .settings-v2-models-group-header {
display: flex;
align-items: center;
height: 28px;
}
.settings-v2-models-group-trigger {
display: flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 4px 8px 4px 4px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-text-text-base);
}
.settings-v2-models-group-trigger:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
@media (hover: hover) {
.settings-v2-models-group-trigger:not(:disabled):hover {
background-color: var(--v2-background-bg-layer-02);
}
}
.settings-v2-models-group-trigger:disabled {
cursor: default;
}
.settings-v2-models-group-chevron {
display: flex;
width: 20px;
height: 20px;
flex-shrink: 0;
align-items: center;
justify-content: center;
color: var(--v2-icon-icon-muted);
}
.settings-v2-models-group-label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding-bottom: 8px;
} }
.settings-v2-models .settings-v2-section-title { .settings-v2-models .settings-v2-section-title {
@@ -444,10 +394,6 @@
color: var(--v2-icon-icon-base); color: var(--v2-icon-icon-base);
} }
.settings-v2-models [data-component="settings-v2-list"] {
padding-inline: 16px;
}
.settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] { .settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
margin-top: 0; margin-top: 0;
} }
@@ -1,21 +1,11 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
hasNonBlockingServiceIssue,
hasServiceNeedingAttention,
serverStatusDotClass,
} from "./status-popover-indicator"
describe("serverStatusDotClass", () => { describe("serverStatusDotClass", () => {
test("uses the success token while the server and services are healthy", () => { test("uses the success token while the server and services are healthy", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base") expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
}) })
test("uses the session attention token when a service needs attention", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe(
"bg-v2-background-bg-accent",
)
})
test("uses the warning token for non-blocking issues while the server is online", () => { test("uses the warning token for non-blocking issues while the server is online", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base") expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
}) })
@@ -44,15 +34,3 @@ describe("hasNonBlockingServiceIssue", () => {
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false) expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
}) })
}) })
describe("hasServiceNeedingAttention", () => {
test("detects MCP states that need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
})
test("ignores states that do not need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["failed"] })).toBe(false)
expect(hasServiceNeedingAttention({ mcp: ["connected", "pending", "disabled"] })).toBe(false)
})
})
@@ -1,10 +1,6 @@
import type { LspStatus } from "@opencode-ai/sdk/v2/client" import type { LspStatus } from "@opencode-ai/sdk/v2/client"
import type { McpServer } from "@opencode-ai/client/promise" import type { McpServer } from "@opencode-ai/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
}
export function hasNonBlockingServiceIssue(input: { export function hasNonBlockingServiceIssue(input: {
mcp: Array<McpServer["status"]["status"]> mcp: Array<McpServer["status"]["status"]>
lsp: Array<LspStatus["status"]> lsp: Array<LspStatus["status"]>
@@ -15,15 +11,9 @@ export function hasNonBlockingServiceIssue(input: {
) )
} }
export function serverStatusDotClass(input: { export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
ready: boolean
serverHealth: boolean | undefined
attention?: boolean
issue: boolean
}) {
if (input.serverHealth === false) return "bg-icon-critical-base" if (input.serverHealth === false) return "bg-icon-critical-base"
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base" if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
if (input.attention) return "bg-v2-background-bg-accent"
if (input.issue) return "bg-icon-warning-base" if (input.issue) return "bg-icon-warning-base"
if (input.serverHealth === true) return "bg-icon-success-base" if (input.serverHealth === true) return "bg-icon-success-base"
return "bg-border-weak-base" return "bg-border-weak-base"
+1 -19
View File
@@ -9,11 +9,7 @@ import { ServerConnection, useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
hasNonBlockingServiceIssue,
hasServiceNeedingAttention,
serverStatusDotClass,
} from "./status-popover-indicator"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody }))) const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
@@ -26,11 +22,6 @@ export function StatusPopover() {
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const attention = createMemo(() =>
hasServiceNeedingAttention({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
}),
)
const issue = createMemo(() => const issue = createMemo(() =>
hasNonBlockingServiceIssue({ hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
@@ -58,7 +49,6 @@ export function StatusPopover() {
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({ class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
ready: ready(), ready: ready(),
serverHealth: serverHealth(), serverHealth: serverHealth(),
attention: attention(),
issue: issue(), issue: issue(),
})}`} })}`}
/> />
@@ -95,11 +85,6 @@ function DirectoryStatusPopover() {
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const attention = createMemo(() =>
hasServiceNeedingAttention({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
}),
)
const issue = createMemo(() => const issue = createMemo(() =>
hasNonBlockingServiceIssue({ hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
@@ -110,7 +95,6 @@ function DirectoryStatusPopover() {
shown: shown(), shown: shown(),
ready: ready(), ready: ready(),
serverHealth: serverHealth(), serverHealth: serverHealth(),
attention: attention(),
issue: issue(), issue: issue(),
label: language.t("status.popover.trigger"), label: language.t("status.popover.trigger"),
onOpenChange: setShown, onOpenChange: setShown,
@@ -134,7 +118,6 @@ function ServerStatusPopover() {
shown: shown(), shown: shown(),
ready: serverHealth() !== undefined, ready: serverHealth() !== undefined,
serverHealth: serverHealth(), serverHealth: serverHealth(),
attention: false,
issue: false, issue: false,
label: language.t("status.popover.trigger"), label: language.t("status.popover.trigger"),
onOpenChange: setShown, onOpenChange: setShown,
@@ -152,7 +135,6 @@ type StatusPopoverState = {
shown: boolean shown: boolean
ready: boolean ready: boolean
serverHealth: boolean | undefined serverHealth: boolean | undefined
attention: boolean
issue: boolean issue: boolean
label: string label: string
onOpenChange: (value: boolean) => void onOpenChange: (value: boolean) => void
+4 -8
View File
@@ -382,11 +382,7 @@ export const Terminal = (props: TerminalProps) => {
event.preventDefault() event.preventDefault()
event.stopImmediatePropagation() event.stopImmediatePropagation()
if (URL.canParse(text) && new URL(text).protocol === "file:" && platform.openLocalFile) { platform.openLink(text)
platform.openLocalFile(text)
return
}
platform.openExternal(text)
} }
onMount(() => { onMount(() => {
@@ -573,8 +569,9 @@ export const Terminal = (props: TerminalProps) => {
if (!result) return if (!result) return
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket 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 === 404 || result.response.status === 405) return
if (result.response.status === 403) throw new Error(language.t("terminal.connectTicket.csrfError")) if (result.response.status === 403)
throw new Error(language.t("terminal.connectTicket.statusError", { status: result.response.status })) 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}`)
} }
// return sdk() // return sdk()
// .api.pty.connectToken({ // .api.pty.connectToken({
@@ -741,7 +738,6 @@ export const Terminal = (props: TerminalProps) => {
<div <div
ref={container} ref={container}
data-component="terminal" data-component="terminal"
dir="ltr"
data-prevent-autofocus data-prevent-autofocus
tabIndex={-1} tabIndex={-1}
style={{ "background-color": terminalColors().background }} style={{ "background-color": terminalColors().background }}
@@ -12,6 +12,6 @@ export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTM
if (typeof ref === "function") ref(element) if (typeof ref === "function") ref(element)
} }
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, pending: boolean) { export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
return !dragging && !editing && !pending return !dragging && !editing && !committing
} }
@@ -1,7 +1,7 @@
[data-titlebar-tab] [data-slot="tab-close"] { [data-titlebar-tab] [data-slot="tab-close"] {
position: absolute; position: absolute;
top: 4px; top: 4px;
inset-inline-end: 4px; right: 4px;
display: flex; display: flex;
height: 20px; height: 20px;
width: 20px; width: 20px;
@@ -9,108 +9,58 @@
justify-content: center; justify-content: center;
} }
[data-titlebar-tab] {
--tab-base: var(--v2-background-bg-deep);
--tab-overlay: transparent;
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
[data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not(
[data-dragging="true"]
):not([data-editing="true"]) {
--tab-base: var(--v2-background-bg-layer-02);
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
}
[data-titlebar-tab]:is([data-state="pressed"], [data-dragging="true"], [data-editing="true"]) {
--tab-base: var(--v2-background-bg-layer-02);
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
}
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] { [data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
display: none; display: none;
} }
[data-titlebar-tab-list] { [data-titlebar-tab-list] {
gap: 6px; gap: 13.5px;
} }
[data-titlebar-tab-slot] { [data-titlebar-tab-slot] {
--tab-separator: var(--v2-background-bg-layer-03);
position: relative; position: relative;
} }
[data-color-scheme="dark"] [data-titlebar-tab-slot] {
--tab-separator: var(--v2-background-bg-layer-02);
}
[data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before { [data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before {
content: ""; content: "";
position: absolute; position: absolute;
top: 8px; top: 8px;
inset-inline-start: -3.75px; left: -6.75px;
width: 1.5px; width: 1.5px;
height: 12px; height: 12px;
border-radius: 9999px; border-radius: 9999px;
background: var(--tab-separator); background: var(--v2-background-bg-layer-02);
} }
[data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before { [data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before {
display: none; display: none;
} }
[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before, [data-titlebar-tab] [data-slot="tab-close"]::before {
[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + [data-titlebar-tab-slot]::before { content: "";
position: absolute;
top: 0;
height: 28px;
width: 16px;
pointer-events: none;
background: linear-gradient(to right, transparent, var(--tab-bg));
display: none; display: none;
} }
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] { [data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before {
--tab-title-fade-offset: 4px; display: block;
-webkit-mask-image: linear-gradient( right: 0;
to right,
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 right,
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"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] { [data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before,
-webkit-mask-image: linear-gradient( [data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"])
to left, [data-slot="tab-close"]::before {
black 0, right: 100%;
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;
} }
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"]) [data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] { [data-slot="tab-link"] {
padding-inline-end: 0; padding-right: 0;
} }
[data-titlebar-tab] [data-slot="tab-title"] { [data-titlebar-tab] [data-slot="tab-title"] {
+111 -89
View File
@@ -1,7 +1,6 @@
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js" import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
@@ -9,6 +8,7 @@ import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server" import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers" import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { showToast } from "@/utils/toast"
import type { Session } from "@opencode-ai/sdk/v2" import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover" import { TabPreviewPopover } from "./titlebar-tab-popover"
@@ -23,7 +23,8 @@ export function TabNavItem(props: {
server: ServerConnection.Key server: ServerConnection.Key
session: () => Session | undefined session: () => Session | undefined
fallbackTitle?: string fallbackTitle?: string
onRename: (title: string) => Promise<void> onTitleChange?: (title: string) => void
onTitleChangeFailed?: (title: string) => void
onClose: () => void onClose: () => void
onNavigate: () => void onNavigate: () => void
active?: boolean active?: boolean
@@ -33,12 +34,13 @@ export function TabNavItem(props: {
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
}) { }) {
const language = useLanguage()
const [editing, setEditing] = createSignal(false) const [editing, setEditing] = createSignal(false)
const [titleOverflowing, setTitleOverflowing] = createSignal(false) const [titleOverflowing, setTitleOverflowing] = createSignal(false)
let tabRoot!: HTMLDivElement let tabRoot!: HTMLDivElement
let titleEl!: HTMLSpanElement let titleEl!: HTMLSpanElement
let committing = false
let measureFrame: number | undefined let measureFrame: number | undefined
const rename = createMutation(() => ({ mutationFn: props.onRename }))
const closeTab = (event: MouseEvent) => { const closeTab = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
@@ -114,20 +116,40 @@ export function TabNavItem(props: {
selection?.addRange(range) selection?.addRange(range)
} }
const rename = async (title: string) => {
const ctx = serverCtx()
const session = props.session()
if (!ctx || !session) return
await ctx.sdk.api.session.rename({ sessionID: session.id, title })
}
const closeRename = async (save: boolean) => { const closeRename = async (save: boolean) => {
if (rename.isPending || !editing()) return if (committing || !editing()) return
committing = true
const original = props.session()?.title ?? "" const original = props.session()?.title ?? ""
const next = (titleEl.textContent ?? "").trim() const next = (titleEl.textContent ?? "").trim()
titleEl.scrollLeft = 0 titleEl.scrollLeft = 0
if (save && next && next !== original) props.onTitleChange?.(next)
setEditing(false) setEditing(false)
if (!save || !next || next === original) { if (!save || !next || next === original) {
committing = false
return return
} }
await rename.mutateAsync(next) try {
await rename(next)
} catch (err) {
props.onTitleChangeFailed?.(original)
showToast({
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : undefined,
})
}
committing = false
} }
createEffect(() => { createEffect(() => {
@@ -141,7 +163,7 @@ export function TabNavItem(props: {
const openRename = (event: MouseEvent) => { const openRename = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return if (!canOpenTabRename(props.dragging, editing(), committing)) return
const session = props.session() const session = props.session()
if (!session) return if (!session) return
titleEl.textContent = session.title titleEl.textContent = session.title
@@ -181,11 +203,11 @@ export function TabNavItem(props: {
data-slot="titlebar-tab-item" data-slot="titlebar-tab-item"
data-title-overflow={titleOverflowing()} data-title-overflow={titleOverflowing()}
data-editing={editing()} data-editing={editing()}
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]" class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
classList={{ invisible: props.hidden }} classList={{ invisible: props.hidden }}
data-active={props.active} data-active={props.active}
data-dragging={props.dragging} data-dragging={props.dragging}
data-state={props.active || props.pressed ? "pressed" : undefined} data-pressed={props.pressed}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault() event.preventDefault()
@@ -196,89 +218,90 @@ export function TabNavItem(props: {
closeTab(event) closeTab(event)
}} }}
> >
<a <Show when={title() !== undefined}>
data-slot="tab-link" <a
data-titlebar-tab-link data-slot="tab-link"
href={props.href} data-titlebar-tab-link
draggable={false} href={props.href}
onDragStart={(event) => { draggable={false}
event.preventDefault() onDragStart={(event) => {
event.stopPropagation()
}}
onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
onClick={(event) => {
event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<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" />
}
>
{(session) => (
<SessionTabAvatar
project={project()}
directory={session.directory}
sessionId={session.id}
server={props.server}
/>
)}
</Show>
</span>
<span
ref={(el) => {
titleEl = el
titleEl.textContent = title() ?? ""
}}
data-slot="tab-title"
data-titlebar-tab-title
class="min-w-0 flex-1 outline-none leading-4"
classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(),
"select-text": editing(),
}}
contenteditable={editing() ? true : undefined}
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void closeRename(true)
return
}
if (event.key !== "Escape") return
event.preventDefault() event.preventDefault()
titleEl.textContent = props.session()?.title ?? ""
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
onPointerDown={(event) => {
if (!editing()) return
event.stopPropagation() event.stopPropagation()
}} }}
onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
onClick={(event) => { onClick={(event) => {
if (!editing()) return
event.preventDefault() event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}} }}
/> class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
</a> >
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
<Show
when={props.session()}
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
}
>
{(session) => (
<SessionTabAvatar
project={project()}
directory={session().directory}
sessionId={session().id}
server={props.server}
/>
)}
</Show>
</span>
<span
ref={(el) => {
titleEl = el
titleEl.textContent = title() ?? ""
}}
data-slot="tab-title"
data-titlebar-tab-title
class="min-w-0 flex-1 outline-none leading-4"
classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(),
"select-text": editing(),
}}
contenteditable={editing() ? true : undefined}
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void closeRename(true)
return
}
if (event.key !== "Escape") return
event.preventDefault()
titleEl.textContent = props.session()?.title ?? ""
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
onPointerDown={(event) => {
if (!editing()) return
event.stopPropagation()
}}
onClick={(event) => {
if (!editing()) return
event.preventDefault()
}}
/>
</a>
</Show>
<div data-slot="tab-close"> <div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<IconButtonV2 <IconButtonV2
size="small" size="small"
variant="ghost-muted" variant="ghost-muted"
@@ -324,7 +347,6 @@ export function DraftTabItem(props: {
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
}) { }) {
const language = useLanguage()
const closeTab = (event: MouseEvent) => { const closeTab = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@@ -337,8 +359,8 @@ export function DraftTabItem(props: {
data-slot="titlebar-tab-item" data-slot="titlebar-tab-item"
data-active={props.active} data-active={props.active}
data-dragging={props.dragging} data-dragging={props.dragging}
data-state={props.active || props.pressed ? "pressed" : undefined} data-pressed={props.pressed}
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap" class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
classList={{ invisible: props.hidden }} classList={{ invisible: props.hidden }}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return if (event.button !== MIDDLE_MOUSE_BUTTON) return
@@ -384,7 +406,7 @@ export function DraftTabItem(props: {
{props.title} {props.title}
</span> </span>
</a> </a>
<div data-slot="tab-close"> <div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<IconButtonV2 <IconButtonV2
size="small" size="small"
variant="ghost-muted" variant="ghost-muted"
@@ -399,7 +421,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" class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
onClick={closeTab} onClick={closeTab}
icon={<IconV2 name="xmark-small" />} icon={<IconV2 name="xmark-small" />}
aria-label={language.t("common.closeTab")} aria-label="Close tab"
/> />
</div> </div>
</div> </div>
@@ -1,23 +0,0 @@
import { describe, expect, test } from "bun:test"
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
describe("adjacentTabKey", () => {
test("follows the visible left-to-right order", () => {
expect(adjacentTabKey(["c", "a", "b"], "c", 1)).toBe("a")
expect(adjacentTabKey(["c", "a", "b"], "a", -1)).toBe("c")
})
test("skips tabs omitted from the visible order", () => {
expect(adjacentTabKey(["a", "c"], "a", 1)).toBe("c")
expect(adjacentTabKey(["a", "c"], "c", 1)).toBe("a")
})
})
test("merges reordered visible tabs around hidden tabs", () => {
expect(mergeVisibleTabOrder(["a", "hidden", "b", "c"], ["a", "b", "c"], ["c", "a", "b"])).toEqual([
"c",
"hidden",
"a",
"b",
])
})
@@ -1,12 +0,0 @@
export function adjacentTabKey(order: string[], current: string | undefined, offset: -1 | 1) {
if (!current || order.length === 0) return
const index = order.indexOf(current)
if (index === -1) return
return order[(index + offset + order.length) % order.length]
}
export function mergeVisibleTabOrder(all: string[], current: string[], next: string[]) {
const visible = new Set(current)
const reordered = next.values()
return all.map((key) => (visible.has(key) ? (reordered.next().value ?? key) : key))
}
@@ -1,5 +1,4 @@
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount, Show } from "solid-js" import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable" import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
@@ -16,10 +15,7 @@ import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { createTabPromptState } from "@/context/prompt" import { createTabPromptState } from "@/context/prompt"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { showToast } from "@/utils/toast"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
import type { Session } from "@opencode-ai/sdk/v2"
function SessionTabSlot(props: { function SessionTabSlot(props: {
tab: SessionTab tab: SessionTab
@@ -27,12 +23,12 @@ function SessionTabSlot(props: {
index: () => number index: () => number
active: () => boolean active: () => boolean
forceTruncate: boolean forceTruncate: boolean
session: () => Session | undefined serverCtx: () => ServerCtx | undefined
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onNavigate: (element: HTMLDivElement) => void onNavigate: (element: HTMLDivElement) => void
onClose: () => void onClose: () => void
}) { }) {
const tabs = useTabs()
const language = useLanguage()
const sortable = useSortable({ const sortable = useSortable({
get id() { get id() {
return props.id return props.id
@@ -42,47 +38,6 @@ function SessionTabSlot(props: {
}, },
}) })
let ref!: HTMLDivElement let ref!: HTMLDivElement
return (
<div
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
>
<TabNavItem
ref={(el) => {
ref = el
}}
href={tabHref(props.tab)}
server={props.tab.server}
session={props.session}
fallbackTitle={props.fallbackTitle}
onRename={props.onRename}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active()}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
/>
</div>
)
}
function SessionTabEntry(props: {
tab: SessionTab
id: string
index: () => number
active: () => boolean
forceTruncate: boolean
serverCtx: () => ServerCtx | undefined
onVisibleChange: (visible: boolean) => void
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
}) {
const tabs = useTabs()
const language = useLanguage()
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null) const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId)) const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id]) const persisted = createMemo(() => tabs.info[props.id])
@@ -95,30 +50,8 @@ function SessionTabEntry(props: {
) )
const session = createMemo(() => cachedSession() ?? loadedSession()) const session = createMemo(() => cachedSession() ?? loadedSession())
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session()) const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
let prefetched = false let prefetched = false
const rename = async (title: string) => {
const value = session()
const ctx = props.serverCtx()
if (!value || !ctx) return
ctx.sync.session.remember({ ...value, title })
try {
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
} catch (err) {
const current = session()
const currentCtx = props.serverCtx()
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
showToast({
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : undefined,
})
}
}
createEffect(() => props.onVisibleChange(visible()))
createEffect(() => { createEffect(() => {
const ctx = props.serverCtx() const ctx = props.serverCtx()
const value = session() const value = session()
@@ -150,20 +83,39 @@ function SessionTabEntry(props: {
}) })
return ( return (
<Show when={visible()}> <div
<SessionTabSlot ref={sortable.ref}
tab={props.tab} data-titlebar-tab-slot
id={props.id} data-tab-key={props.id}
index={props.index} data-active={props.active()}
active={props.active} class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
forceTruncate={props.forceTruncate} classList={{ hidden: !session() && !missingSession() && !persisted()?.title }}
>
<TabNavItem
ref={(el) => {
ref = el
}}
href={tabHref(props.tab)}
server={props.tab.server}
session={session} session={session}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)} fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
onRename={rename} onTitleChange={(title) => {
onNavigate={props.onNavigate} const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onTitleChangeFailed={(title) => {
const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose} onClose={props.onClose}
active={props.active()}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
/> />
</Show> </div>
) )
} }
@@ -220,39 +172,11 @@ export function TitlebarTabStrip(props: {
}) { }) {
const global = useGlobal() const global = useGlobal()
const language = useLanguage() const language = useLanguage()
const command = useCommand()
let scrollRef!: HTMLDivElement let scrollRef!: HTMLDivElement
let listRef!: HTMLDivElement let listRef!: HTMLDivElement
let resizeFrame: number | undefined let resizeFrame: number | undefined
const [visibility, setVisibility] = createStore<Record<string, boolean>>({})
const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)]))
const visibleTabIds = () => visibleTabs().map(tabKey)
command.register("titlebar-tab-cycle", () => [ const tabIds = () => props.tabs.map(tabKey)
{
id: `tab.prev`,
category: "tab",
title: "",
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
hidden: true,
onSelect: () => selectAdjacentTab(-1),
},
{
id: `tab.next`,
category: "tab",
title: "",
keybind: `mod+option+ArrowRight,ctrl+tab`,
hidden: true,
onSelect: () => selectAdjacentTab(1),
},
])
function selectAdjacentTab(offset: -1 | 1) {
const current = props.currentTab()
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
const next = props.tabs.find((tab) => tabKey(tab) === key)
if (next) props.onNavigate(next)
}
function refreshOverflow() { function refreshOverflow() {
if (!scrollRef) return if (!scrollRef) return
@@ -280,7 +204,7 @@ export function TitlebarTabStrip(props: {
createEffect(() => { createEffect(() => {
props.tabs.length props.tabs.length
visibleTabIds() tabIds()
refreshOverflow() refreshOverflow()
}) })
@@ -316,29 +240,22 @@ export function TitlebarTabStrip(props: {
props.onNavigate(tab, tabEl ?? undefined) props.onNavigate(tab, tabEl ?? undefined)
}} }}
onDragEnd={(event) => { onDragEnd={(event) => {
const current = visibleTabIds() const current = tabIds()
const source = event.operation.source const source = event.operation.source
if (event.canceled || !isSortable(source)) return if (event.canceled || !isSortable(source)) return
const { initialIndex, index } = source const { initialIndex, index } = source
if (initialIndex !== index) { if (initialIndex !== index) {
props.onReorder( props.onReorder(arrayMove(current, source.initialIndex, source.index))
mergeVisibleTabOrder(
props.tabs.map(tabKey),
current,
arrayMove(current, source.initialIndex, source.index),
),
)
} }
}} }}
> >
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}> <div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
<For each={props.tabs}> <For each={props.tabs}>
{(tab) => { {(tab, index) => {
const id = tabKey(tab) const id = tabKey(tab)
let ref!: HTMLDivElement let ref!: HTMLDivElement
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id) useTabShortcut(index, () => props.onNavigate(tab, ref))
useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
const serverCtx = createMemo(() => { const serverCtx = createMemo(() => {
if (tab.type !== "session") return if (tab.type !== "session") return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server) const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
@@ -347,14 +264,13 @@ export function TitlebarTabStrip(props: {
if (tab.type === "session") { if (tab.type === "session") {
return ( return (
<SessionTabEntry <SessionTabSlot
tab={tab} tab={tab}
id={id} id={id}
index={visibleIndex} index={index}
active={() => props.currentTab() === tab} active={() => props.currentTab() === tab}
forceTruncate={props.forceTruncate} forceTruncate={props.forceTruncate}
serverCtx={serverCtx} serverCtx={serverCtx}
onVisibleChange={(visible) => setVisibility(id, visible)}
onNavigate={(element) => { onNavigate={(element) => {
ref = element ref = element
props.onNavigate(tab, element) props.onNavigate(tab, element)
@@ -368,7 +284,7 @@ export function TitlebarTabStrip(props: {
<DraftTabSlot <DraftTabSlot
tab={tab} tab={tab}
id={id} id={id}
index={visibleIndex} index={index}
active={() => props.currentTab() === tab} active={() => props.currentTab() === tab}
title={language.t("command.session.new")} title={language.t("command.session.new")}
onNavigate={(element) => { onNavigate={(element) => {
@@ -402,7 +318,7 @@ function useTabShortcut(index: () => number, onSelect: () => void) {
command.register(() => { command.register(() => {
const number = index() + 1 const number = index() + 1
if (number < 1 || number > 9) return [] if (number > 9) return []
return [ return [
{ {
id: `tab.${number}`, id: `tab.${number}`,
+62 -41
View File
@@ -1,15 +1,4 @@
import { import { createEffect, createMemo, createResource, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js"
createEffect,
createMemo,
createResource,
createSignal,
Match,
on,
onMount,
Show,
Switch,
untrack,
} from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -53,11 +42,8 @@ export type TitlebarUpdate = {
} }
export function useTitlebarRightMount() { export function useTitlebarRightMount() {
const language = useLanguage()
const [mount, setMount] = createSignal<HTMLElement | null>(null) const [mount, setMount] = createSignal<HTMLElement | null>(null)
const sync = () => setMount(document.getElementById("opencode-titlebar-right")) onMount(() => setMount(document.getElementById("opencode-titlebar-right")))
onMount(sync)
createEffect(on(language.direction, sync, { defer: true }))
return mount return mount
} }
@@ -127,9 +113,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
return { return {
visible: version !== undefined || installing, visible: version !== undefined || installing,
installing, installing,
label: language.t("titlebar.update"), label: "Update",
ariaLabel: language.t("toast.update.action.installRestart"), ariaLabel: language.t("toast.update.action.installRestart"),
title: version ? language.t("titlebar.updateVersion", { version }) : undefined, title: version ? `Update ${version}` : undefined,
onInstall: () => props.update?.install(), onInstall: () => props.update?.install(),
} }
}) })
@@ -183,8 +169,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
"padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0, "padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0,
width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined, width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined, "max-width": windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
// Native Windows caption controls remain on the physical right in both writing directions. "align-self": windows() ? "flex-start" : undefined,
"margin-right": windows() ? "auto" : undefined,
}} }}
data-tauri-drag-region data-tauri-drag-region
> >
@@ -353,6 +338,40 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
keybind: "mod+shift+t", keybind: "mod+shift+t",
onSelect: () => tabsStoreActions.reopenClosedTab(), onSelect: () => tabsStoreActions.reopenClosedTab(),
}, },
{
id: `tab.prev`,
category: "tab",
title: "",
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
hidden: true,
onSelect: () => {
let index = tabsStore.findIndex((tab) => tab === currentTab())
if (index === -1) return
index -= 1
if (index === -1) index = tabsStore.length - 1
const next = tabsStore[index]
if (next) tabs.select(next)
},
},
{
id: `tab.next`,
category: "tab",
title: "",
keybind: `mod+option+ArrowRight,ctrl+tab`,
hidden: true,
onSelect: () => {
let index = tabsStore.findIndex((tab) => tab === currentTab())
if (index === -1) return
index += 1
if (index === tabsStore.length) index = 0
const next = tabsStore[index]
if (next) tabs.select(next)
},
},
].filter((v) => v !== undefined) ].filter((v) => v !== undefined)
}) })
@@ -410,25 +429,27 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}} }}
onReorder={(keys) => tabsStoreActions.reorder(keys)} onReorder={(keys) => tabsStoreActions.reorder(keys)}
/> />
<TooltipV2 <Show when={!creating()}>
placement="bottom" <TooltipV2
value={ placement="bottom"
<> value={
{language.t("command.session.new")} <>
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" /> {language.t("command.session.new")}
</> <KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
} </>
> }
<IconButtonV2 >
type="button" <IconButtonV2
variant="ghost-muted" type="button"
size="large" variant="ghost-muted"
class="shrink-0" size="large"
icon={<IconV2 name="plus" />} class="shrink-0"
onClick={openNewTab} icon={<IconV2 name="plus" />}
aria-label={language.t("command.session.new")} onClick={openNewTab}
/> aria-label={language.t("command.session.new")}
</TooltipV2> />
</TooltipV2>
</Show>
<div class="flex-1" /> <div class="flex-1" />
<TitlebarV2Right state={v2RightState()} /> <TitlebarV2Right state={v2RightState()} />
</div> </div>
@@ -530,7 +551,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
<div <div
class="flex items-center shrink-0" class="flex items-center shrink-0"
classList={{ classList={{
"ltr:-translate-x-[36px] rtl:translate-x-[36px]": layout.sidebar.opened() && !!params.dir, "-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(), "duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(), "duration-180 ease-in": layout.sidebar.opened(),
}} }}
@@ -628,7 +649,7 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
aria-label={props.state.ariaLabel} aria-label={props.state.ariaLabel}
> >
<span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-within:opacity-100 group-focus-within:translate-x-0 motion-reduce:translate-x-0"> <span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-within:opacity-100 group-focus-within:translate-x-0 motion-reduce:translate-x-0">
{props.state.label} Update
</span> </span>
<span class="flex size-5 shrink-0 items-center justify-center"> <span class="flex size-5 shrink-0 items-center justify-center">
<Show <Show
+1 -1
View File
@@ -55,7 +55,7 @@ const DrawerContent = <T extends ValidComponent = "div">(props: DynamicProps<T,
<DrawerPrimitive.Content <DrawerPrimitive.Content
class={props.class} class={props.class}
classList={{ classList={{
"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, "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,
}} }}
{...rest} {...rest}
> >
@@ -8,7 +8,6 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu" import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
export function WindowsAppMenu(props: { export function WindowsAppMenu(props: {
command: ReturnType<typeof useCommand> command: ReturnType<typeof useCommand>
@@ -16,7 +15,6 @@ export function WindowsAppMenu(props: {
variant?: "legacy" | "v2" variant?: "legacy" | "v2"
}) { }) {
let lastFocused: HTMLElement | undefined let lastFocused: HTMLElement | undefined
const language = useLanguage()
const rememberFocus = () => { const rememberFocus = () => {
const active = document.activeElement const active = document.activeElement
@@ -45,7 +43,7 @@ export function WindowsAppMenu(props: {
runAction(entry.action) runAction(entry.action)
return return
} }
if (entry.href) props.platform.openExternal(entry.href) if (entry.href) props.platform.openLink(entry.href)
} }
return ( return (
@@ -60,7 +58,7 @@ export function WindowsAppMenu(props: {
variant="ghost-muted" variant="ghost-muted"
size="large" size="large"
icon={<IconV2 name="menu" />} icon={<IconV2 name="menu" />}
aria-label={language.t("desktop.menu.ariaLabel")} aria-label="OpenCode menu"
onPointerDown={rememberFocus} onPointerDown={rememberFocus}
onKeyDown={rememberFocus} onKeyDown={rememberFocus}
/> />
@@ -71,7 +69,7 @@ export function WindowsAppMenu(props: {
icon="menu" icon="menu"
variant="ghost" variant="ghost"
class="titlebar-icon rounded-md shrink-0" class="titlebar-icon rounded-md shrink-0"
aria-label={language.t("desktop.menu.ariaLabel")} aria-label="OpenCode menu"
onPointerDown={rememberFocus} onPointerDown={rememberFocus}
onKeyDown={rememberFocus} onKeyDown={rememberFocus}
/> />
@@ -81,7 +79,7 @@ export function WindowsAppMenu(props: {
<DropdownMenu.Group> <DropdownMenu.Group>
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel> <DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => ( {DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
<DesktopMenuSubmenu label={language.t(menu.labelKey)}> <DesktopMenuSubmenu label={menu.label}>
{menu.items {menu.items
?.filter((entry) => desktopMenuVisible(entry, "windows")) ?.filter((entry) => desktopMenuVisible(entry, "windows"))
.map((entry) => .map((entry) =>
@@ -89,7 +87,7 @@ export function WindowsAppMenu(props: {
<DropdownMenu.Separator /> <DropdownMenu.Separator />
) : ( ) : (
<DesktopMenuItem <DesktopMenuItem
label={entry.labelKey ? language.t(entry.labelKey) : ""} label={entry.label ?? ""}
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows} keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
disabled={entry.command ? commandDisabled(entry.command) : false} disabled={entry.command ? commandDisabled(entry.command) : false}
onSelect={() => runEntry(entry)} onSelect={() => runEntry(entry)}
+2 -2
View File
@@ -83,7 +83,7 @@ export const ACCEPTED_FILE_EXTENSIONS = Array.from(
), ),
).sort() ).sort()
export function filePickerFilters(name: string, ext?: string[]) { export function filePickerFilters(ext?: string[]) {
if (!ext || ext.length === 0) return undefined if (!ext || ext.length === 0) return undefined
return [{ name, extensions: ext }] return [{ name: "Files", extensions: ext }]
} }
@@ -21,29 +21,6 @@ describe("file path helpers", () => {
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts") expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
}) })
test("normalizes Windows directory separators", () => {
const path = createPathHelpers(() => "C:\\repo")
expect(path.normalizeDir("frontend\\")).toBe("frontend")
expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src")
expect(path.normalizeDir("C:\\repo\\frontend\\")).toBe("frontend")
})
test("normalizes separators for Windows roots written with forward slashes", () => {
const path = createPathHelpers(() => "C:/repo")
expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src")
})
test("normalizes separators for Windows UNC roots", () => {
const path = createPathHelpers(() => "\\\\server\\share")
expect(path.normalizeDir("\\\\server\\share\\frontend\\")).toBe("frontend")
})
test("preserves backslashes in POSIX directory names", () => {
const path = createPathHelpers(() => "/repo")
expect(path.normalizeDir("literal\\name\\")).toBe("literal\\name\\")
expect(path.normalizeDir("literal\\name/")).toBe("literal\\name")
})
test("keeps query/hash stripping behavior stable", () => { test("keeps query/hash stripping behavior stable", () => {
expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts") expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts")
expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts") expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts")
+1 -6
View File
@@ -140,12 +140,7 @@ export function createPathHelpers(scope: () => string) {
return normalize(tabValue) return normalize(tabValue)
} }
const normalizeDir = (input: string) => { const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "")
const path = normalize(input)
const root = scope()
const windows = /^[A-Za-z]:/.test(root) || root.startsWith("\\\\")
return (windows ? path.replace(/\\/g, "/") : path).replace(/\/+$/, "")
}
return { return {
normalize, normalize,
@@ -8,7 +8,6 @@ import {
bootstrapDirectory, bootstrapDirectory,
loadAgentsQuery, loadAgentsQuery,
loadCommands, loadCommands,
loadGlobalConfigQuery,
loadPathQuery, loadPathQuery,
loadProjectsQuery, loadProjectsQuery,
loadProvidersQuery, loadProvidersQuery,
@@ -77,7 +76,6 @@ function directoryState() {
describe("bootstrapDirectory", () => { describe("bootstrapDirectory", () => {
test("uses legacy MCP endpoints while refreshing a v1 directory", async () => { test("uses legacy MCP endpoints while refreshing a v1 directory", async () => {
const legacyConfigReads: string[] = []
const mcpReads: string[] = [] const mcpReads: string[] = []
const [store, setStore] = directoryState() const [store, setStore] = directoryState()
@@ -93,12 +91,7 @@ describe("bootstrapDirectory", () => {
}, },
sdk: { sdk: {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: { config: { get: async () => ({ data: {} }) },
get: async () => {
legacyConfigReads.push("directory")
return { data: {} }
},
},
session: { status: async () => ({ data: {} }) }, session: { status: async () => ({ data: {} }) },
vcs: { get: async () => ({ data: undefined }) }, vcs: { get: async () => ({ data: undefined }) },
command: { command: {
@@ -141,88 +134,8 @@ describe("bootstrapDirectory", () => {
await new Promise((resolve) => setTimeout(resolve, 80)) await new Promise((resolve) => setTimeout(resolve, 80))
expect(store.status).toBe("complete") expect(store.status).toBe("complete")
expect(legacyConfigReads).toEqual(["directory"])
expect(mcpReads.sort()).toEqual(["command", "resource", "status"]) 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", () => { describe("query keys", () => {
@@ -105,13 +105,10 @@ function showErrors(input: {
}) })
} }
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient, protocol?: Promise<ServerProtocol>) => export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: [scope, "config"], queryKey: [scope, "config"],
queryFn: async () => { queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
if ((await protocol) !== "v1") return {}
return retry(() => sdk.global.config.get().then((x) => x.data!))
},
}) })
type ProjectApi = { type ProjectApi = {
@@ -152,7 +149,7 @@ export async function bootstrapGlobal(input: {
queryClient: QueryClient queryClient: QueryClient
}) { }) {
const slow = [ const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK, input.protocol)), () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
() => () =>
input.queryClient.fetchQuery( input.queryClient.fetchQuery(
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol), loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
@@ -379,10 +376,7 @@ export async function bootstrapDirectory(input: {
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol)) .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
.then((data) => input.setStore("agent", data)), .then((data) => input.setStore("agent", data)),
() => () =>
retry(async () => { retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
if ((await input.protocol) !== "v1") return
return input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))
}),
() => () =>
retry(() => retry(() =>
(async () => { (async () => {
@@ -15,12 +15,12 @@ const rootSession = (input: { id: string; parentID?: string; archived?: number }
}, },
}) as Session }) as Session
const userMessage = (id: string, sessionID: string, created = 1) => const userMessage = (id: string, sessionID: string) =>
({ ({
id, id,
sessionID, sessionID,
role: "user", role: "user",
time: { created }, time: { created: 1 },
agent: "assistant", agent: "assistant",
model: { providerID: "openai", modelID: "gpt" }, model: { providerID: "openai", modelID: "gpt" },
}) as Message }) as Message
@@ -370,13 +370,13 @@ describe("applyDirectoryEvent", () => {
const sessionID = "ses_1" const sessionID = "ses_1"
const [store, setStore] = createStore( const [store, setStore] = createStore(
baseState({ baseState({
message: { [sessionID]: [userMessage("msg_z", sessionID, 1), userMessage("msg_b", sessionID, 3)] }, message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_3", sessionID)] },
part: { msg_a: [textPart("prt_1", sessionID, "msg_a")] }, part: { msg_2: [textPart("prt_1", sessionID, "msg_2")] },
}), }),
) )
applyDirectoryEvent({ applyDirectoryEvent({
event: { type: "message.updated", properties: { info: userMessage("msg_a", sessionID, 2) } }, event: { type: "message.updated", properties: { info: userMessage("msg_2", sessionID) } },
store, store,
setStore, setStore,
push() {}, push() {},
@@ -384,14 +384,14 @@ describe("applyDirectoryEvent", () => {
loadLsp() {}, loadLsp() {},
}) })
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a", "msg_b"]) expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2", "msg_3"])
applyDirectoryEvent({ applyDirectoryEvent({
event: { event: {
type: "message.updated", type: "message.updated",
properties: { properties: {
info: { info: {
...userMessage("msg_a", sessionID, 2), ...userMessage("msg_2", sessionID),
role: "assistant", role: "assistant",
} as Message, } as Message,
}, },
@@ -403,10 +403,10 @@ describe("applyDirectoryEvent", () => {
loadLsp() {}, loadLsp() {},
}) })
expect(store.message[sessionID]?.find((x) => x.id === "msg_a")?.role).toBe("assistant") expect(store.message[sessionID]?.find((x) => x.id === "msg_2")?.role).toBe("assistant")
applyDirectoryEvent({ applyDirectoryEvent({
event: { type: "message.removed", properties: { sessionID, messageID: "msg_a" } }, event: { type: "message.removed", properties: { sessionID, messageID: "msg_2" } },
store, store,
setStore, setStore,
push() {}, push() {},
@@ -414,8 +414,8 @@ describe("applyDirectoryEvent", () => {
loadLsp() {}, loadLsp() {},
}) })
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_b"]) expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_3"])
expect(store.part.msg_a).toBeUndefined() expect(store.part.msg_2).toBeUndefined()
}) })
test("upserts and prunes message parts", () => { test("upserts and prunes message parts", () => {
@@ -15,7 +15,6 @@ import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache" import { dropSessionCaches } from "./session-cache"
import { diffs as list, message as clean } from "@/utils/diffs" import { diffs as list, message as clean } from "@/utils/diffs"
import { messageKey } from "@/utils/session-message"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const SESSION_CONTENT_EVENTS = new Set([ const SESSION_CONTENT_EVENTS = new Set([
@@ -276,7 +275,7 @@ export function applyDirectoryEvent(input: {
input.setStore("message", info.sessionID, [info]) input.setStore("message", info.sessionID, [info])
break break
} }
const result = Binary.search(messages, messageKey(info), messageKey) const result = Binary.search(messages, info.id, (m) => m.id)
if (result.found) { if (result.found) {
input.setStore("message", info.sessionID, result.index, reconcile(info)) input.setStore("message", info.sessionID, result.index, reconcile(info))
break break
@@ -296,8 +295,8 @@ export function applyDirectoryEvent(input: {
produce((draft) => { produce((draft) => {
const messages = draft.message[props.sessionID] const messages = draft.message[props.sessionID]
if (messages) { if (messages) {
const index = messages.findIndex((message) => message.id === props.messageID) const result = Binary.search(messages, props.messageID, (m) => m.id)
if (index >= 0) messages.splice(index, 1) if (result.found) messages.splice(result.index, 1)
} }
const parts = draft.part[props.messageID] const parts = draft.part[props.messageID]
if (parts) { if (parts) {
@@ -323,7 +322,7 @@ export function applyDirectoryEvent(input: {
input.setStore("part", part.messageID, [part]) input.setStore("part", part.messageID, [part])
break break
} }
const result = Binary.search(parts, part.id, (item) => item.id) const result = Binary.search(parts, part.id, (p) => p.id)
if (result.found) { if (result.found) {
input.setStore("part", part.messageID, result.index, reconcile(part)) input.setStore("part", part.messageID, result.index, reconcile(part))
break break
@@ -346,13 +345,13 @@ export function applyDirectoryEvent(input: {
) )
const parts = input.store.part[props.messageID] const parts = input.store.part[props.messageID]
if (!parts) break if (!parts) break
const result = Binary.search(parts, props.partID, (part) => part.id) const result = Binary.search(parts, props.partID, (p) => p.id)
if (result.found) { if (result.found) {
input.setStore( input.setStore(
produce((draft) => { produce((draft) => {
const list = draft.part[props.messageID] const list = draft.part[props.messageID]
if (!list) return if (!list) return
const next = Binary.search(list, props.partID, (part) => part.id) const next = Binary.search(list, props.partID, (p) => p.id)
if (!next.found) return if (!next.found) return
list.splice(next.index, 1) list.splice(next.index, 1)
if (list.length === 0) delete draft.part[props.messageID] if (list.length === 0) delete draft.part[props.messageID]
@@ -365,7 +364,7 @@ export function applyDirectoryEvent(input: {
const props = event.properties as { messageID: string; partID: string; field: string; delta: string } const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
const parts = input.store.part[props.messageID] const parts = input.store.part[props.messageID]
if (!parts) break if (!parts) break
const result = Binary.search(parts, props.partID, (part) => part.id) const result = Binary.search(parts, props.partID, (p) => p.id)
if (!result.found) break if (!result.found) break
const field = props.field as keyof (typeof parts)[number] const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field] const current = parts[result.index]?.[field]
@@ -103,6 +103,7 @@ describe("normalizeProviderList", () => {
) )
expect(result.connected).toEqual(["openai"]) expect(result.connected).toEqual(["openai"])
expect(result.defaultModel).toEqual({ providerID: "openai", modelID: "gpt-5" })
expect(result.default).toEqual({ openai: "gpt-5" }) expect(result.default).toEqual({ openai: "gpt-5" })
expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined() expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined()
expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({ expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({
@@ -113,6 +114,10 @@ describe("normalizeProviderList", () => {
variants: { high: {} }, variants: { high: {} },
}) })
}) })
test("preserves an empty current default", () => {
expect(normalizeProviderList([] as ProviderListOutput["data"], [], null).defaultModel).toBeNull()
})
}) })
describe("directoryKey", () => { describe("directoryKey", () => {
@@ -139,6 +139,7 @@ export function normalizeProviderList(
return { return {
all, all,
connected: providers.map((provider) => provider.id), connected: providers.map((provider) => provider.id),
defaultModel: defaultModel ? { providerID: defaultModel.providerID, modelID: defaultModel.id } : null,
default: Object.fromEntries( default: Object.fromEntries(
providers.flatMap((provider) => { providers.flatMap((provider) => {
const model = const model =
+119 -118
View File
@@ -2,46 +2,100 @@ import * as i18n from "@solid-primitives/i18n"
import { createEffect, createMemo, createResource } from "solid-js" import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { dict as en } from "@/i18n/en" import { dict as en } from "@/i18n/en"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en" import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
import {
createDesktopNativeBundle,
detectDesktopNativeLocale,
DESKTOP_NATIVE_ENGLISH,
DESKTOP_NATIVE_LABELS,
DESKTOP_NATIVE_LOCALES,
DESKTOP_NATIVE_LOCALE_TAGS,
type DesktopNativeBundle,
type DesktopNativeLocale,
} from "@/i18n/desktop-native"
export type Locale = DesktopNativeLocale export type Locale =
export type Direction = "ltr" | "rtl" | "en"
| "zh"
const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "ur", "pa", "fa", "dv"]) | "zht"
| "ko"
function localeDirection(locale: Locale): Direction { | "de"
return RTL_LOCALES.has(locale) ? "rtl" : "ltr" | "es"
} | "fr"
| "da"
| "ja"
| "pl"
| "ru"
| "uk"
| "ar"
| "no"
| "br"
| "th"
| "bs"
| "tr"
type RawDictionary = typeof en & typeof uiEn type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary> type Dictionary = i18n.Flatten<RawDictionary>
type PluralKey =
| UiI18nPluralKey
| "session.question.pending"
| "session.followupDock.summary"
| "session.revertDock.summary"
type Source = { dict: Record<string, string> } type Source = { dict: Record<string, string> }
function cookie(locale: Locale) { function cookie(locale: Locale) {
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
} }
const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES const LOCALES: readonly Locale[] = [
"en",
"zh",
"zht",
"ko",
"de",
"es",
"fr",
"da",
"ja",
"pl",
"ru",
"uk",
"bs",
"ar",
"no",
"br",
"th",
"tr",
]
const INTL = DESKTOP_NATIVE_LOCALE_TAGS const INTL: Record<Locale, string> = {
en: "en",
zh: "zh-Hans",
zht: "zh-Hant",
ko: "ko",
de: "de",
es: "es",
fr: "fr",
da: "da",
ja: "ja",
pl: "pl",
ru: "ru",
uk: "uk",
ar: "ar",
no: "nb-NO",
br: "pt-BR",
th: "th",
bs: "bs",
tr: "tr",
}
const LABEL_KEY: Record<Locale, keyof Dictionary> = {
en: "language.en",
zh: "language.zh",
zht: "language.zht",
ko: "language.ko",
de: "language.de",
es: "language.es",
fr: "language.fr",
da: "language.da",
ja: "language.ja",
pl: "language.pl",
ru: "language.ru",
uk: "language.uk",
ar: "language.ar",
no: "language.no",
br: "language.br",
th: "language.th",
bs: "language.bs",
tr: "language.tr",
}
const base = i18n.flatten({ ...en, ...uiEn }) const base = i18n.flatten({ ...en, ...uiEn })
const dicts = new Map<Locale, Dictionary>([["en", base]]) const dicts = new Map<Locale, Dictionary>([["en", base]])
@@ -67,50 +121,6 @@ const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")), th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")), bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")), 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")),
am: () => merge(import("@/i18n/am"), import("@opencode-ai/ui/i18n/am")),
bg: () => merge(import("@/i18n/bg"), import("@opencode-ai/ui/i18n/bg")),
bn: () => merge(import("@/i18n/bn"), import("@opencode-ai/ui/i18n/bn")),
ca: () => merge(import("@/i18n/ca"), import("@opencode-ai/ui/i18n/ca")),
cs: () => merge(import("@/i18n/cs"), import("@opencode-ai/ui/i18n/cs")),
dv: () => merge(import("@/i18n/dv"), import("@opencode-ai/ui/i18n/dv")),
dz: () => merge(import("@/i18n/dz"), import("@opencode-ai/ui/i18n/dz")),
el: () => merge(import("@/i18n/el"), import("@opencode-ai/ui/i18n/el")),
et: () => merge(import("@/i18n/et"), import("@opencode-ai/ui/i18n/et")),
fa: () => merge(import("@/i18n/fa"), import("@opencode-ai/ui/i18n/fa")),
fo: () => merge(import("@/i18n/fo"), import("@opencode-ai/ui/i18n/fo")),
hr: () => merge(import("@/i18n/hr"), import("@opencode-ai/ui/i18n/hr")),
hu: () => merge(import("@/i18n/hu"), import("@opencode-ai/ui/i18n/hu")),
hy: () => merge(import("@/i18n/hy"), import("@opencode-ai/ui/i18n/hy")),
is: () => merge(import("@/i18n/is"), import("@opencode-ai/ui/i18n/is")),
ka: () => merge(import("@/i18n/ka"), import("@opencode-ai/ui/i18n/ka")),
km: () => merge(import("@/i18n/km"), import("@opencode-ai/ui/i18n/km")),
lo: () => merge(import("@/i18n/lo"), import("@opencode-ai/ui/i18n/lo")),
lt: () => merge(import("@/i18n/lt"), import("@opencode-ai/ui/i18n/lt")),
lv: () => merge(import("@/i18n/lv"), import("@opencode-ai/ui/i18n/lv")),
mk: () => merge(import("@/i18n/mk"), import("@opencode-ai/ui/i18n/mk")),
mn: () => merge(import("@/i18n/mn"), import("@opencode-ai/ui/i18n/mn")),
ms: () => merge(import("@/i18n/ms"), import("@opencode-ai/ui/i18n/ms")),
my: () => merge(import("@/i18n/my"), import("@opencode-ai/ui/i18n/my")),
ne: () => merge(import("@/i18n/ne"), import("@opencode-ai/ui/i18n/ne")),
ro: () => merge(import("@/i18n/ro"), import("@opencode-ai/ui/i18n/ro")),
si: () => merge(import("@/i18n/si"), import("@opencode-ai/ui/i18n/si")),
sk: () => merge(import("@/i18n/sk"), import("@opencode-ai/ui/i18n/sk")),
sl: () => merge(import("@/i18n/sl"), import("@opencode-ai/ui/i18n/sl")),
sq: () => merge(import("@/i18n/sq"), import("@opencode-ai/ui/i18n/sq")),
sr: () => merge(import("@/i18n/sr"), import("@opencode-ai/ui/i18n/sr")),
tg: () => merge(import("@/i18n/tg"), import("@opencode-ai/ui/i18n/tg")),
tk: () => merge(import("@/i18n/tk"), import("@opencode-ai/ui/i18n/tk")),
uz: () => merge(import("@/i18n/uz"), import("@opencode-ai/ui/i18n/uz")),
} }
function loadDict(locale: Locale) { function loadDict(locale: Locale) {
@@ -128,9 +138,42 @@ export function loadLocaleDict(locale: Locale) {
return loadDict(locale).then(() => undefined) return loadDict(locale).then(() => undefined)
} }
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: "zh", match: (language) => language.startsWith("zh") },
{ locale: "ko", match: (language) => language.startsWith("ko") },
{ locale: "de", match: (language) => language.startsWith("de") },
{ locale: "es", match: (language) => language.startsWith("es") },
{ locale: "fr", match: (language) => language.startsWith("fr") },
{ locale: "da", match: (language) => language.startsWith("da") },
{ locale: "ja", match: (language) => language.startsWith("ja") },
{ locale: "pl", match: (language) => language.startsWith("pl") },
{ locale: "ru", match: (language) => language.startsWith("ru") },
{ locale: "uk", match: (language) => language.startsWith("uk") },
{ locale: "ar", match: (language) => language.startsWith("ar") },
{
locale: "no",
match: (language) => language.startsWith("no") || language.startsWith("nb") || language.startsWith("nn"),
},
{ locale: "br", match: (language) => language.startsWith("pt") },
{ locale: "th", match: (language) => language.startsWith("th") },
{ locale: "bs", match: (language) => language.startsWith("bs") },
{ locale: "tr", match: (language) => language.startsWith("tr") },
]
function detectLocale(): Locale { function detectLocale(): Locale {
if (typeof navigator !== "object") return "en" if (typeof navigator !== "object") return "en"
return detectDesktopNativeLocale(navigator.languages?.length ? navigator.languages : [navigator.language])
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
if (!language) continue
const normalized = language.toLowerCase()
const match = localeMatchers.find((entry) => entry.match(normalized))
if (match) return match.locale
}
return "en"
} }
export function normalizeLocale(value: string): Locale { export function normalizeLocale(value: string): Locale {
@@ -151,22 +194,12 @@ function readStoredLocale() {
} }
const warm = readStoredLocale() ?? detectLocale() const warm = readStoredLocale() ?? detectLocale()
const initialLocale = if (warm !== "en") void loadDict(warm)
warm === "en"
? Promise.resolve(warm)
: loadDict(warm).then(
() => warm,
() => "en" as const,
)
export function loadInitialLocale() {
return initialLocale
}
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language", name: "Language",
gate: false, gate: false,
init: (props: { locale?: Locale; onNativeTranslations?: (bundle: DesktopNativeBundle) => void }) => { init: (props: { locale?: Locale }) => {
const initial = props.locale ?? readStoredLocale() ?? detectLocale() const initial = props.locale ?? readStoredLocale() ?? detectLocale()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.global("language", ["language.v1"]), Persist.global("language", ["language.v1"]),
@@ -177,13 +210,6 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
const locale = createMemo<Locale>(() => normalizeLocale(store.locale)) const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
const intl = createMemo(() => INTL[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, { const [dict] = createResource(locale, loadDict, {
initialValue: dicts.get(initial) ?? base, initialValue: dicts.get(initial) ?? base,
@@ -194,49 +220,24 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
params?: Record<string, string | number | boolean>, params?: Record<string, string | number | boolean>,
) => string ) => string
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => { const label = (value: Locale) => t(LABEL_KEY[value])
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) => DESKTOP_NATIVE_LABELS[value]
createEffect(() => { createEffect(() => {
if (typeof document !== "object") return if (typeof document !== "object") return
const value = locale() document.documentElement.lang = locale()
document.documentElement.lang = intl() document.cookie = cookie(locale())
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 { return {
ready, ready,
locale, locale,
intl, intl,
direction,
layoutLocale,
locales: LOCALES, locales: LOCALES,
label, label,
t, t,
plural,
setLocale(next: Locale) { setLocale(next: Locale) {
setStore("locale", normalizeLocale(next)) setStore("locale", normalizeLocale(next))
}, },
setDirection(next: Direction) {
setLayout("direction", next === localeDirection(locale()) ? undefined : next)
},
} }
}, },
}) })
+3 -4
View File
@@ -6,6 +6,7 @@ import { createStore } from "solid-js/store"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { resolveDefaultModel } from "@/hooks/provider-catalog"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { hasCustomAgent, resolveAgent } from "./local-agent" import { hasCustomAgent, resolveAgent } from "./local-agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
@@ -149,10 +150,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}) })
const configuredModel = () => { const configuredModel = () => {
const configured = sync().data.config.model const model = resolveDefaultModel(providers.defaultModel(), sync().data.config.model)
if (!configured) return if (!model) return
const [providerID, modelID] = configured.split("/")
const model = { providerID, modelID }
if (validModel(model)) return model if (validModel(model)) return model
} }
+3 -8
View File
@@ -1,6 +1,6 @@
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { useParams, useSearchParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { ServerSDK } from "./server-sdk" import type { ServerSDK } from "./server-sdk"
import type { ServerSync } from "./server-sync" import type { ServerSync } from "./server-sync"
@@ -118,7 +118,6 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const global = useGlobal() const global = useGlobal()
const server = useServer() const server = useServer()
const tabs = useTabs() const tabs = useTabs()
const navigate = useNavigate()
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings() const settings = useSettings()
const language = useLanguage() const language = useLanguage()
@@ -154,7 +153,6 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
platform, platform,
settings, settings,
language, language,
navigate,
}), }),
}), }),
owner ?? undefined, owner ?? undefined,
@@ -219,7 +217,6 @@ function createServerNotificationState(input: {
platform: ReturnType<typeof usePlatform> platform: ReturnType<typeof usePlatform>
settings: ReturnType<typeof useSettings> settings: ReturnType<typeof useSettings>
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
navigate: (href: string) => void
}) { }) {
const serverSDK = () => input.sdk const serverSDK = () => input.sdk
const serverSync = () => input.sync const serverSync = () => input.sync
@@ -356,9 +353,7 @@ function createServerNotificationState(input: {
const href = `/${base64Encode(directory)}/session/${sessionID}` const href = `/${base64Encode(directory)}/session/${sessionID}`
if (settings.notifications.agent()) { if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () => void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
input.navigate(href),
)
} }
}) })
} }
@@ -391,7 +386,7 @@ function createServerNotificationState(input: {
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription")) (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}` const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
if (settings.notifications.errors()) { if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, () => input.navigate(href)) void platform.notify(language.t("notification.session.error.title"), description, href)
} }
}) })
} }
+13 -11
View File
@@ -5,7 +5,6 @@ import type { DesktopMenuAction } from "../desktop-menu"
import { ServerConnection } from "./server" import { ServerConnection } from "./server"
import type { WslServersPlatform } from "../wsl/types" import type { WslServersPlatform } from "../wsl/types"
import type { UpdaterPlatform } from "../updater" import type { UpdaterPlatform } from "../updater"
import type { DraftStore } from "@/utils/draft-store"
type PickerPaths = string | string[] | null type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -32,23 +31,26 @@ type PlatformBase = {
/** App version */ /** App version */
version?: string version?: string
/** Open a web or mail URL in the default system application */ /** Open a URL in the default browser */
openExternal(url: string): void openLink(url: string): void
/** Open a local path in a local app (desktop only) */ /** Open a local path in a local app (desktop only) */
openPath?(path: string, app?: string): Promise<void> 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) */ /** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
revealPath?(path: string): Promise<boolean> revealPath?(path: string): Promise<boolean>
/** Restart the app */ /** Restart the app */
restart(): Promise<void> restart(): Promise<void>
/** Send a system notification */ /** Navigate back in history */
notify(title: string, description?: string, onClick?: () => void): Promise<void> back(): void
/** Navigate forward in history */
forward(): void
/** Send a system notification (optional deep link) */
notify(title: string, description?: string, href?: string): Promise<void>
/** Open a native attachment picker and read selected files sequentially (desktop only) */ /** Open a native attachment picker and read selected files sequentially (desktop only) */
openAttachmentPickerDialog?( openAttachmentPickerDialog?(
@@ -65,9 +67,6 @@ type PlatformBase = {
/** Storage mechanism, defaults to localStorage */ /** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage storage?: (name?: string) => SyncStorage | AsyncStorage
/** Prompt drafts, history, and their blobs. */
draftStore?: DraftStore
/** Stable platform window identity for window-scoped persistence */ /** Stable platform window identity for window-scoped persistence */
windowID?: string windowID?: string
@@ -92,6 +91,9 @@ type PlatformBase = {
/** Set the preferred display backend (desktop only) */ /** Set the preferred display backend (desktop only) */
setDisplayBackend?(backend: DisplayBackend): Promise<void> 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) */ /** Webview zoom level (desktop only) */
webviewZoom?: Accessor<number> webviewZoom?: Accessor<number>
+8 -15
View File
@@ -5,8 +5,6 @@ import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import type { BlobReference } from "@/utils/draft-store"
import type { Platform } from "@/context/platform"
interface PartBase { interface PartBase {
content: string content: string
@@ -39,7 +37,7 @@ export interface ImageAttachmentPart {
filename: string filename: string
sourcePath?: string sourcePath?: string
mime: string mime: string
blob: BlobReference dataUrl: string
} }
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
@@ -170,9 +168,9 @@ function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
} }
function promptTarget(serverScope: ServerScope, scope: PromptScope) { function promptTarget(serverScope: ServerScope, scope: PromptScope) {
if ("draftID" in scope) return Persist.prompt(Persist.draft(scope.draftID, "prompt")) if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
return Persist.prompt(Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])) return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
} }
function promptStore(initial?: InitialPrompt): PromptStore { function promptStore(initial?: InitialPrompt): PromptStore {
@@ -237,22 +235,17 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<P
return value return value
} }
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt, platform?: Platform) { function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt) {
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)), platform) const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)))
return { ready, ...createPromptStateValue(store, setStore) } return { ready, ...createPromptStateValue(store, setStore) }
} }
export function createPromptSession( export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) {
serverScope: ServerScope, return createPersistedPrompt(promptTarget(serverScope, scope), initial)
scope: PromptScope,
initial?: InitialPrompt,
platform?: Platform,
) {
return createPersistedPrompt(promptTarget(serverScope, scope), initial, platform)
} }
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) { export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
return createPersistedPrompt(Persist.prompt(Persist.draft(draftID, "prompt")), initial) return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial)
} }
export type PromptSession = ReturnType<typeof createPromptSession> export type PromptSession = ReturnType<typeof createPromptSession>
@@ -264,7 +264,6 @@ describe("server session", () => {
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }]) expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
}) })
test("extends a current page to include the user for split assistant turns", async () => { test("extends a current page to include the user for split assistant turns", async () => {
@@ -1498,7 +1497,7 @@ describe("server session", () => {
await store.sync("child", { force: true }) await store.sync("child", { force: true })
expect(store.data.message.child).toEqual([older, boundary]) expect(store.data.message.child).toEqual([boundary, older])
}) })
test("preserves a part update for a message being loaded from history", async () => { test("preserves a part update for a message being loaded from history", async () => {
+29 -28
View File
@@ -18,7 +18,7 @@ import { message as cleanMessage } from "@/utils/diffs"
import { sessionNotFoundError } from "@/utils/server-errors" import { sessionNotFoundError } from "@/utils/server-errors"
import { rootSession } from "@/utils/session-route" import { rootSession } from "@/utils/session-route"
import { normalizeSessionInfo } from "@/utils/session" import { normalizeSessionInfo } from "@/utils/session"
import { compareMessages, messageKey, normalizeSessionMessages } from "@/utils/session-message" import { normalizeSessionMessages } from "@/utils/session-message"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer" import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
@@ -26,6 +26,7 @@ import type { ServerApi } from "@/utils/server"
type MessageApi = ServerApi["message"] type MessageApi = ServerApi["message"]
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) 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 SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 20 const initialMessagePageSize = 20
const historyMessagePageSize = 200 const historyMessagePageSize = 200
@@ -63,7 +64,7 @@ type MessagePage = {
function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] { function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] {
return items return items
.slice() .slice()
.sort((a, b) => compareMessages(a.info, b.info)) .sort((a, b) => cmp(a.info.id, b.info.id))
.map((item) => { .map((item) => {
if (item.info.role === "user") { if (item.info.role === "user") {
return { return {
@@ -110,16 +111,17 @@ function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
const part = new Map(page.part.map((item) => [item.id, item.part])) const part = new Map(page.part.map((item) => [item.id, item.part]))
const observed: { messageID: string; parts: Part[] }[] = [] const observed: { messageID: string; parts: Part[] }[] = []
for (const item of items) { for (const item of items) {
const result = Binary.search(session, messageKey(item.message), messageKey) const result = Binary.search(session, item.message.id, (message) => message.id)
const found = result.found if (!result.found) session.splice(result.index, 0, item.message)
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id) const current = part.get(item.message.id)
const confirmed = found ? item.parts.filter((part) => current?.some((value) => value.id === part.id)) : [] const confirmed = result.found
if (found) observed.push({ messageID: item.message.id, parts: confirmed }) ? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found)
: []
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
part.set( part.set(
item.message.id, item.message.id,
merge( merge(
found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []), result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
item.parts.filter((part) => !confirmed.includes(part)), item.parts.filter((part) => !confirmed.includes(part)),
), ),
) )
@@ -156,7 +158,6 @@ function reconcileFetched<T extends { id: string }>(
retained?: ReadonlySet<string> retained?: ReadonlySet<string>
removed?: ReadonlySet<string> removed?: ReadonlySet<string>
preserveUnfetched?: boolean | ((item: T) => boolean) preserveUnfetched?: boolean | ((item: T) => boolean)
compare?: (a: T, b: T) => number
} = {}, } = {},
) { ) {
const result = new Map(fetched.map((item) => [item.id, item])) const result = new Map(fetched.map((item) => [item.id, item]))
@@ -179,8 +180,7 @@ function reconcileFetched<T extends { id: string }>(
if (!item) result.delete(id) if (!item) result.delete(id)
} }
for (const id of options.removed ?? emptyIDs) result.delete(id) for (const id of options.removed ?? emptyIDs) result.delete(id)
const items = [...result.values()] return [...result.values()].sort((a, b) => cmp(a.id, b.id))
return options.compare ? items.sort(options.compare) : items
} }
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> } type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
@@ -413,7 +413,8 @@ export function createServerSession(
if (!load) return if (!load) return
// A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata. // A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata.
const messages = data.message[sessionID] const messages = data.message[sessionID]
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID) if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
const parts = load.touchedParts.get(messageID) const parts = load.touchedParts.get(messageID)
if (parts) { if (parts) {
parts.add(partID) parts.add(partID)
@@ -436,14 +437,16 @@ export function createServerSession(
load.touchedParts.set(messageID, new Set(parts)) load.touchedParts.set(messageID, new Set(parts))
load.carriedDeltaParts.set(messageID, new Set(parts)) load.carriedDeltaParts.set(messageID, new Set(parts))
const messages = data.message[sessionID] const messages = data.message[sessionID]
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID) if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
} }
for (const [messageID, parts] of load.removedParts) { for (const [messageID, parts] of load.removedParts) {
const touched = load.touchedParts.get(messageID) ?? new Set<string>() const touched = load.touchedParts.get(messageID) ?? new Set<string>()
parts.forEach((partID) => touched.add(partID)) parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched) load.touchedParts.set(messageID, touched)
const messages = data.message[sessionID] const messages = data.message[sessionID]
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID) if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
} }
for (const [messageID, parts] of load.optimisticParts) { for (const [messageID, parts] of load.optimisticParts) {
load.removedMessages.delete(messageID) load.removedMessages.delete(messageID)
@@ -552,7 +555,7 @@ export function createServerSession(
const source = pages.flatMap((page) => page.data).toReversed() const source = pages.flatMap((page) => page.data).toReversed()
const normalized = normalizeSessionMessages(sessionID, source) const normalized = normalizeSessionMessages(sessionID, source)
return { return {
session: normalized.messages.sort(compareMessages), session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
part: [...normalized.parts.entries()] part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) })) .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)), .sort((a, b) => cmp(a.id, b.id)),
@@ -569,7 +572,7 @@ export function createServerSession(
}) })
const items = (response.data ?? []).filter((item) => !!item?.info?.id) const items = (response.data ?? []).filter((item) => !!item?.info?.id)
return { return {
session: items.map((item) => cleanMessage(item.info)).sort(compareMessages), session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
part: items.map((item) => ({ part: items.map((item) => ({
id: item.info.id, id: item.info.id,
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
@@ -693,7 +696,7 @@ export function createServerSession(
const normalized = normalizeSessionMessages(sessionID, source) const normalized = normalizeSessionMessages(sessionID, source)
return { return {
...page, ...page,
session: normalized.messages.sort(compareMessages), session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
part: [...normalized.parts.entries()] part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) })) .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)), .sort((a, b) => cmp(a.id, b.id)),
@@ -710,7 +713,6 @@ export function createServerSession(
retained: load?.retainedMessages, retained: load?.retainedMessages,
removed: load?.removedMessages, removed: load?.removedMessages,
preserveUnfetched, preserveUnfetched,
compare: compareMessages,
}) })
batch(() => { batch(() => {
if (source) setData("session_message", sessionID, reconcile(source)) if (source) setData("session_message", sessionID, reconcile(source))
@@ -752,7 +754,7 @@ export function createServerSession(
try { try {
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load)) const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
const first = page.session.reduce<Message | undefined>( const first = page.session.reduce<Message | undefined>(
(oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest), (oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
undefined, undefined,
) )
if (generations.get(sessionID) !== active) return if (generations.get(sessionID) !== active) return
@@ -802,15 +804,14 @@ export function createServerSession(
session: merge( session: merge(
page.session, page.session,
parents.map((parent) => parent.message), parents.map((parent) => parent.message),
).sort(compareMessages), ),
part: merge( part: merge(
page.part, page.part,
parents.map((parent) => ({ id: parent.message.id, part: parent.parts })), parents.map((parent) => ({ id: parent.message.id, part: parent.parts })),
), ),
} }
const preserveUnfetched = const preserveUnfetched =
mode === "prepend" || mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
(!result.complete && (!first || ((message: Message) => compareMessages(message, first) < 0)))
applyMessagePage( applyMessagePage(
sessionID, sessionID,
result, result,
@@ -927,7 +928,7 @@ export function createServerSession(
.message({ sessionID, messageID }) .message({ sessionID, messageID })
.then((message) => { .then((message) => {
const current = data.session_message[sessionID] ?? [] const current = data.session_message[sessionID] ?? []
const messages = [...current.filter((item) => item.id !== message.id), message].sort(compareMessages) const messages = [...current.filter((item) => item.id !== message.id), message].sort((a, b) => cmp(a.id, b.id))
projectV2({ sessionID, messages, touched: [message.id] }) projectV2({ sessionID, messages, touched: [message.id] })
}) })
.catch(() => {}) .catch(() => {})
@@ -1050,7 +1051,7 @@ export function createServerSession(
setData("message", info.sessionID, [info]) setData("message", info.sessionID, [info])
return return
} }
const result = Binary.search(messages, messageKey(info), messageKey) const result = Binary.search(messages, info.id, (message) => message.id)
if (result.found) setData("message", info.sessionID, result.index, reconcile(info)) if (result.found) setData("message", info.sessionID, result.index, reconcile(info))
if (!result.found) if (!result.found)
setData("message", info.sessionID, (value = []) => { setData("message", info.sessionID, (value = []) => {
@@ -1083,8 +1084,8 @@ export function createServerSession(
produce((draft) => { produce((draft) => {
const messages = draft.message[props.sessionID] const messages = draft.message[props.sessionID]
if (messages) { if (messages) {
const index = messages.findIndex((message) => message.id === props.messageID) const result = Binary.search(messages, props.messageID, (message) => message.id)
if (index >= 0) messages.splice(index, 1) if (result.found) messages.splice(result.index, 1)
} }
deleteMessageParts(draft, props.messageID) deleteMessageParts(draft, props.messageID)
}), }),
@@ -1096,7 +1097,7 @@ export function createServerSession(
if (SKIP_PARTS.has(part.type)) return if (SKIP_PARTS.has(part.type)) return
const messages = data.message[part.sessionID] const messages = data.message[part.sessionID]
const load = messageLoads.get(part.sessionID) const load = messageLoads.get(part.sessionID)
const missing = !messages?.some((message) => message.id === part.messageID) const missing = !messages || !Binary.search(messages, part.messageID, (message) => message.id).found
// Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan. // Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan.
if ( if (
missing && missing &&
@@ -1340,7 +1341,7 @@ export function createServerSession(
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] }) if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
if (!items) if (!items)
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]])) optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages)) setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
setData( setData(
"part_text_accum_delta", "part_text_accum_delta",
produce((draft) => { produce((draft) => {
+1 -7
View File
@@ -184,7 +184,7 @@ function makeQueryOptionsApi(
protocol: Promise<"v1" | "v2">, protocol: Promise<"v1" | "v2">,
) { ) {
return { return {
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK(), protocol), globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
projects: () => loadProjectsQuery(scope, serverAPI.project), projects: () => loadProjectsQuery(scope, serverAPI.project),
providers: (directory: PathKey | null) => providers: (directory: PathKey | null) =>
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol), loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
@@ -290,10 +290,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server)) const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
const refreshProviders = () =>
queryClient.refetchQueries({
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
})
let bootedAt = 0 let bootedAt = 0
let bootingRoot = false let bootingRoot = false
@@ -541,7 +537,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
homeSessions.apply(event) homeSessions.apply(event)
} }
homeSessions.refresh(event.type) homeSessions.refresh(event.type)
if (eventType === "integration.connection.updated") void refreshProviders()
if (directory === "global") { if (directory === "global") {
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching) if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
@@ -683,7 +678,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
peek: children.peek, peek: children.peek,
disableMcp: children.disableMcp, disableMcp: children.disableMcp,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
refreshProviders,
// bootstrap, // bootstrap,
updateConfig: updateConfigMutation.mutateAsync, updateConfig: updateConfigMutation.mutateAsync,
project: projectApi, project: projectApi,
-17
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { import {
hasExistingWebState, hasExistingWebState,
initialAgentVisibility,
isAppUpgrade, isAppUpgrade,
layoutTransitionState, layoutTransitionState,
maximumSunsetTimeout, maximumSunsetTimeout,
@@ -12,22 +11,6 @@ import {
shouldEnableNewLayout, shouldEnableNewLayout,
} from "./settings" } from "./settings"
describe("agent visibility", () => {
test("shows the picker for existing profiles and hides it for first-time installs", () => {
expect(initialAgentVisibility(undefined, true)).toBe(true)
expect(initialAgentVisibility(undefined, false)).toBe(false)
})
test("shows the picker when updating from a recent release", () => {
expect(initialAgentVisibility(undefined, false, "1.18.8")).toBe(true)
})
test("preserves the preference after initialization", () => {
expect(initialAgentVisibility(true, true, "1.18.8")).toBeUndefined()
expect(initialAgentVisibility(true, false)).toBeUndefined()
})
})
describe("layout transition", () => { describe("layout transition", () => {
test("blank profiles default to the new layout", () => { test("blank profiles default to the new layout", () => {
expect(newLayoutDesignsDefault).toBe(true) expect(newLayoutDesignsDefault).toBe(true)
+3 -19
View File
@@ -1,5 +1,5 @@
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { persisted } from "@/utils/persist" import { persisted } from "@/utils/persist"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
@@ -36,7 +36,6 @@ export interface Settings {
mobileTitlebarPosition: "top" | "bottom" mobileTitlebarPosition: "top" | "bottom"
newLayoutDesigns?: boolean newLayoutDesigns?: boolean
layoutTransitionEligible?: boolean layoutTransitionEligible?: boolean
agentVisibilityInitialized?: boolean
newInterfaceNoticeDismissed?: boolean newInterfaceNoticeDismissed?: boolean
shouldDisplayTabsToast?: boolean shouldDisplayTabsToast?: boolean
} }
@@ -94,11 +93,6 @@ export function hasExistingWebState(settings: Promise<string> | string | null, p
return settings !== null || previousVersion !== undefined return settings !== null || previousVersion !== undefined
} }
export function initialAgentVisibility(initialized: boolean | undefined, existing: boolean, previousVersion?: string) {
if (initialized === true) return
return existing || previousVersion !== undefined
}
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) { export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
if (!current) return false if (!current) return false
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff) const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
@@ -277,14 +271,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
) )
}) })
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference()) const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
const initializeAgentVisibility = (existing: boolean) => {
const initial = initialAgentVisibility(store.general?.agentVisibilityInitialized, existing, launchState.previous)
if (initial === undefined) return
batch(() => {
setStore("general", "showCustomAgents", initial)
setStore("general", "agentVisibilityInitialized", true)
})
}
if (sunset && !oldInterfaceRetired()) { if (sunset && !oldInterfaceRetired()) {
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined } const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -313,9 +299,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => { createEffect(() => {
if (!ready() || !launchState.classified || platform.platform !== "web") return if (!ready() || !launchState.classified || platform.platform !== "web") return
const existing = hasExistingWebState(settingsInit, launchState.previous) if (layoutTransitionClassified()) return
if (!layoutTransitionClassified()) setStore("general", "layoutTransitionEligible", existing) setStore("general", "layoutTransitionEligible", hasExistingWebState(settingsInit, launchState.previous))
initializeAgentVisibility(existing)
}) })
createEffect(() => { createEffect(() => {
@@ -441,7 +426,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
if (typeof current === "boolean") return if (typeof current === "boolean") return
setStore("general", "layoutTransitionEligible", eligible) setStore("general", "layoutTransitionEligible", eligible)
}, },
initializeAgentVisibility,
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available), layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice), newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
dismissNewInterfaceNotice() { dismissNewInterfaceNotice() {
@@ -4,11 +4,11 @@ import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "
type Text = Extract<Part, { type: "text" }> type Text = Extract<Part, { type: "text" }>
const userMessage = (id: string, sessionID: string, created = 1): Message => ({ const userMessage = (id: string, sessionID: string): Message => ({
id, id,
sessionID, sessionID,
role: "user", role: "user",
time: { created }, time: { created: 1 },
agent: "assistant", agent: "assistant",
model: { providerID: "openai", modelID: "gpt" }, model: { providerID: "openai", modelID: "gpt" },
}) })
@@ -22,21 +22,21 @@ const textPart = (id: string, sessionID: string, messageID: string): Text => ({
}) })
describe("sync optimistic reducers", () => { describe("sync optimistic reducers", () => {
test("applyOptimisticAdd inserts by creation time", () => { test("applyOptimisticAdd inserts message in sorted order and stores parts", () => {
const sessionID = "ses_1" const sessionID = "ses_1"
const draft = { const draft = {
message: { [sessionID]: [userMessage("msg_z", sessionID, 1)] }, message: { [sessionID]: [userMessage("msg_2", sessionID)] },
part: {} as Record<string, Part[] | undefined>, part: {} as Record<string, Part[] | undefined>,
} }
applyOptimisticAdd(draft, { applyOptimisticAdd(draft, {
sessionID, sessionID,
message: userMessage("msg_a", sessionID, 2), message: userMessage("msg_1", sessionID),
parts: [textPart("prt_2", sessionID, "msg_a"), textPart("prt_1", sessionID, "msg_a")], parts: [textPart("prt_2", sessionID, "msg_1"), textPart("prt_1", sessionID, "msg_1")],
}) })
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a"]) expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
expect(draft.part.msg_a?.map((x) => x.id)).toEqual(["prt_1", "prt_2"]) expect(draft.part.msg_1?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
}) })
test("applyOptimisticRemove removes message and part entries", () => { test("applyOptimisticRemove removes message and part entries", () => {
@@ -60,33 +60,19 @@ describe("sync optimistic reducers", () => {
const sessionID = "ses_1" const sessionID = "ses_1"
const page = mergeOptimisticPage( const page = mergeOptimisticPage(
{ {
session: [userMessage("msg_z", sessionID, 1)], session: [userMessage("msg_1", sessionID)],
part: [{ id: "msg_z", part: [textPart("prt_1", sessionID, "msg_z")] }], part: [{ id: "msg_1", part: [textPart("prt_1", sessionID, "msg_1")] }],
complete: true, complete: true,
}, },
[{ message: userMessage("msg_a", sessionID, 2), parts: [textPart("prt_2", sessionID, "msg_a")] }], [{ message: userMessage("msg_2", sessionID), parts: [textPart("prt_2", sessionID, "msg_2")] }],
) )
expect(page.session.map((x) => x.id)).toEqual(["msg_z", "msg_a"]) expect(page.session.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
expect(page.part.find((x) => x.id === "msg_a")?.part.map((x) => x.id)).toEqual(["prt_2"]) expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_2"])
expect(page.confirmed).toEqual([]) expect(page.confirmed).toEqual([])
expect(page.complete).toBe(true) expect(page.complete).toBe(true)
}) })
test("mergeOptimisticPage uses IDs only to break equal-time ties", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_z", sessionID, 1)],
part: [],
complete: true,
},
[{ message: userMessage("msg_a", sessionID, 1), parts: [] }],
)
expect(page.session.map((message) => message.id)).toEqual(["msg_a", "msg_z"])
})
test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => { test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
const sessionID = "ses_1" const sessionID = "ses_1"
const page = mergeOptimisticPage( const page = mergeOptimisticPage(
+4 -5
View File
@@ -3,7 +3,6 @@ import { createMemo } from "solid-js"
import { useServerSync } from "./server-sync" import { useServerSync } from "./server-sync"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { messageKey } from "@/utils/session-message"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -68,7 +67,7 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[])
const confirmed: string[] = [] const confirmed: string[] = []
for (const item of items) { for (const item of items) {
const result = Binary.search(session, messageKey(item.message), messageKey) const result = Binary.search(session, item.message.id, (message) => message.id)
const found = result.found const found = result.found
if (!found) session.splice(result.index, 0, item.message) if (!found) session.splice(result.index, 0, item.message)
@@ -93,7 +92,7 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[])
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) { export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID] const messages = draft.message[input.sessionID]
if (messages) { if (messages) {
const result = Binary.search(messages, messageKey(input.message), messageKey) const result = Binary.search(messages, input.message.id, (m) => m.id)
messages.splice(result.index, 0, input.message) messages.splice(result.index, 0, input.message)
} else { } else {
draft.message[input.sessionID] = [input.message] draft.message[input.sessionID] = [input.message]
@@ -104,8 +103,8 @@ export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddI
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) { export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID] const messages = draft.message[input.sessionID]
if (messages) { if (messages) {
const index = messages.findIndex((message) => message.id === input.messageID) const result = Binary.search(messages, input.messageID, (m) => m.id)
if (index >= 0) messages.splice(index, 1) if (result.found) messages.splice(result.index, 1)
} }
delete draft.part[input.messageID] delete draft.part[input.messageID]
} }
-23
View File
@@ -1,23 +0,0 @@
import type { ServerConnection } from "./server"
import type { Tab } from "./tabs"
export function migrateTabs(value: unknown, fallback: ServerConnection.Key): Tab[] {
if (!Array.isArray(value)) return []
return value.flatMap<Tab>((tab) => {
if (!tab || typeof tab !== "object") return []
if ("server" in tab && typeof tab.server !== "string") return []
const server = ("server" in tab ? tab.server : fallback) as ServerConnection.Key
if (tab.type === "session" && typeof tab.sessionId === "string") {
return [{ type: tab.type, server, sessionId: tab.sessionId }]
}
if (
tab.type === "draft" &&
typeof tab.draftID === "string" &&
typeof tab.directory === "string" &&
(tab.worktree === undefined || typeof tab.worktree === "string")
) {
return [{ type: tab.type, server, draftID: tab.draftID, directory: tab.directory, worktree: tab.worktree }]
}
return []
})
}
-18
View File
@@ -3,7 +3,6 @@ import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./tab-memory" import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import type { SessionTab, Tab } from "./tabs" import type { SessionTab, Tab } from "./tabs"
import { migrateTabs } from "./tab-migration"
import type { ServerConnection } from "./server" import type { ServerConnection } from "./server"
const server = "local\nhttp://localhost:4096" as ServerConnection.Key const server = "local\nhttp://localhost:4096" as ServerConnection.Key
@@ -12,23 +11,6 @@ function sessionTab(sessionId: string): SessionTab {
return { type: "session", server, sessionId } return { type: "session", server, sessionId }
} }
describe("tab migration", () => {
test("drops null and malformed persisted tabs", () => {
expect(
migrateTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"], server),
).toEqual([sessionTab("a")])
})
test("adds the fallback server to valid legacy tabs", () => {
expect(migrateTabs([{ type: "session", sessionId: "a", dirBase64: "legacy" }], server)).toEqual([sessionTab("a")])
})
test("replaces invalid top-level persisted data", () => {
expect(migrateTabs(null, server)).toEqual([])
expect(migrateTabs({}, server)).toEqual([])
})
})
describe("tab memory", () => { describe("tab memory", () => {
test("keeps state until its tab is removed", () => { test("keeps state until its tab is removed", () => {
createRoot((dispose) => { createRoot((dispose) => {
+8 -6
View File
@@ -12,7 +12,6 @@ import { sessionHref } from "@/utils/session-route"
import { createTabMemory } from "./tab-memory" import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import { createDraftPromptSession, type PromptModel } from "./prompt-state" import { createDraftPromptSession, type PromptModel } from "./prompt-state"
import { migrateTabs } from "./tab-migration"
export type SessionTab = { export type SessionTab = {
type: "session" type: "session"
@@ -60,7 +59,13 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
{ {
...Persist.window("tabs"), ...Persist.window("tabs"),
migrate: (value: unknown) => migrateTabs(value, fallback), migrate: (value: unknown) => {
if (!Array.isArray(value)) return value
return value.map((tab) => {
if (!tab || typeof tab !== "object" || "server" in tab) return tab
return { ...tab, server: fallback }
})
},
}, },
createStore<Tab[]>([]), createStore<Tab[]>([]),
) )
@@ -101,10 +106,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
} }
const removeDraftPersisted = (draftID: string) => { const removeDraftPersisted = (draftID: string) => {
for (const key of draftPersistedKeys()) { for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
const target = Persist.draft(draftID, key)
removePersisted(key === "prompt" ? Persist.prompt(target) : target, platform)
}
} }
const removeInfo = (key: string) => { const removeInfo = (key: string) => {
+1 -11
View File
@@ -4,20 +4,10 @@ import { DESKTOP_MENU } from "./desktop-menu"
describe("desktop menu", () => { describe("desktop menu", () => {
test("exports logs through the desktop command registry", () => { test("exports logs through the desktop command registry", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter( const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs", (item) => item.type === "item" && item.label === "Export Logs...",
) )
expect(items).toHaveLength(2) expect(items).toHaveLength(2)
expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true) 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)
})
}) })
+50 -129
View File
@@ -1,5 +1,3 @@
import type { DesktopNativeKey } from "./i18n/desktop-native"
export type DesktopMenuPlatform = "macos" | "windows" export type DesktopMenuPlatform = "macos" | "windows"
export type DesktopMenuAction = export type DesktopMenuAction =
@@ -46,7 +44,7 @@ export type DesktopMenuRole =
export type DesktopMenuItem = { export type DesktopMenuItem = {
type: "item" type: "item"
labelKey?: DesktopNativeKey label?: string
command?: string command?: string
action?: DesktopMenuAction action?: DesktopMenuAction
role?: DesktopMenuRole role?: DesktopMenuRole
@@ -65,7 +63,7 @@ export type DesktopMenuEntry = DesktopMenuItem | DesktopMenuSeparator
export type DesktopMenu = { export type DesktopMenu = {
id: string id: string
labelKey: DesktopNativeKey label: string
role?: DesktopMenuRole role?: DesktopMenuRole
items?: DesktopMenuEntry[] items?: DesktopMenuEntry[]
platforms?: DesktopMenuPlatform[] platforms?: DesktopMenuPlatform[]
@@ -74,20 +72,15 @@ export type DesktopMenu = {
export const DESKTOP_MENU: DesktopMenu[] = [ export const DESKTOP_MENU: DesktopMenu[] = [
{ {
id: "app", id: "app",
labelKey: "desktop.menu.app", label: "OpenCode",
platforms: ["macos"], platforms: ["macos"],
items: [ items: [
{ type: "item", role: "about" }, { type: "item", role: "about" },
{ { type: "item", label: "Check for Updates...", action: "app.checkForUpdates", enabled: "updater" },
type: "item", { type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
labelKey: "desktop.menu.checkForUpdates", { type: "item", label: "Reload Webview", action: "view.reload" },
action: "app.checkForUpdates", { type: "item", label: "Restart", action: "app.relaunch" },
enabled: "updater", { type: "item", label: "Export Logs...", command: "logs.export" },
},
{ 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: "separator" },
{ type: "item", role: "hide" }, { type: "item", role: "hide" },
{ type: "item", role: "hideOthers" }, { type: "item", role: "hideOthers" },
@@ -98,81 +91,46 @@ export const DESKTOP_MENU: DesktopMenu[] = [
}, },
{ {
id: "file", id: "file",
labelKey: "desktop.menu.file", label: "File",
items: [ items: [
{ {
type: "item", type: "item",
labelKey: "desktop.menu.newSession", label: "New Session",
command: "session.new", command: "session.new",
accelerator: { macos: "Shift+Cmd+S" }, accelerator: { macos: "Shift+Cmd+S" },
}, },
{ type: "item", label: "Open Project...", command: "project.open", accelerator: { macos: "Cmd+O" } },
{ {
type: "item", type: "item",
labelKey: "desktop.menu.openProject", label: "Settings",
command: "project.open",
accelerator: { macos: "Cmd+O" },
},
{
type: "item",
labelKey: "desktop.menu.settings",
command: "settings.open", command: "settings.open",
accelerator: { windows: "Ctrl+," }, accelerator: { windows: "Ctrl+," },
platforms: ["windows"], platforms: ["windows"],
}, },
{ {
type: "item", type: "item",
labelKey: "desktop.menu.newWindow", label: "New Window",
action: "window.new", action: "window.new",
accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" }, accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" },
}, },
{ type: "separator" }, { type: "separator" },
{ type: "item", labelKey: "desktop.menu.closeWindow", action: "window.close", role: "close" }, { type: "item", label: "Close Window", action: "window.close", role: "close" },
], ],
}, },
{ {
id: "edit", id: "edit",
labelKey: "desktop.menu.edit", label: "Edit",
items: [ items: [
{ { type: "item", label: "Undo", action: "edit.undo", role: "undo", accelerator: { windows: "Ctrl+Z" } },
type: "item", { type: "item", label: "Redo", action: "edit.redo", role: "redo", accelerator: { windows: "Ctrl+Y" } },
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: "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", type: "item",
labelKey: "desktop.menu.cut", label: "Select All",
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", action: "edit.selectAll",
role: "selectAll", role: "selectAll",
accelerator: { windows: "Ctrl+A" }, accelerator: { windows: "Ctrl+A" },
@@ -181,84 +139,47 @@ export const DESKTOP_MENU: DesktopMenu[] = [
}, },
{ {
id: "view", id: "view",
labelKey: "desktop.menu.view", label: "View",
items: [ items: [
{ type: "item", labelKey: "desktop.menu.toggleSidebar", command: "sidebar.toggle" }, { type: "item", label: "Toggle Sidebar", command: "sidebar.toggle" },
{ { type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } },
type: "item", { type: "item", label: "Toggle File Tree", command: "fileTree.toggle" },
labelKey: "desktop.menu.toggleTerminal",
command: "terminal.toggle",
accelerator: { macos: "Ctrl+`" },
},
{ type: "item", labelKey: "desktop.menu.toggleFileTree", command: "fileTree.toggle" },
{ type: "separator" }, { type: "separator" },
{ type: "item", labelKey: "desktop.menu.reload", action: "view.reload", role: "reload" }, { 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.toggleDeveloperTools",
action: "view.toggleDevTools",
role: "toggleDevTools",
},
{ type: "separator" }, { type: "separator" },
{ {
type: "item", type: "item",
labelKey: "desktop.menu.actualSize", label: "Actual Size",
action: "view.resetZoom", action: "view.resetZoom",
role: "resetZoom", role: "resetZoom",
accelerator: { windows: "Ctrl+0" }, accelerator: { windows: "Ctrl+0" },
}, },
{ { type: "item", label: "Zoom In", action: "view.zoomIn", role: "zoomIn", accelerator: { windows: "Ctrl++" } },
type: "item", { type: "item", label: "Zoom Out", action: "view.zoomOut", role: "zoomOut", accelerator: { windows: "Ctrl+-" } },
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: "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", id: "go",
labelKey: "desktop.menu.go", label: "Go",
items: [ items: [
{ type: "item", labelKey: "desktop.menu.back", command: "common.goBack", accelerator: { macos: "Cmd+[" } }, { type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
{ type: "item", labelKey: "desktop.menu.forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } }, { type: "item", label: "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: "separator" }, { type: "separator" },
{ {
type: "item", type: "item",
labelKey: "desktop.menu.previousSession", label: "Previous Project",
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", command: "project.previous",
accelerator: { macos: "Cmd+Option+Up" }, accelerator: { macos: "Cmd+Option+Up" },
}, },
{ {
type: "item", type: "item",
labelKey: "desktop.menu.nextProject", label: "Next Project",
command: "project.next", command: "project.next",
accelerator: { macos: "Cmd+Option+Down" }, accelerator: { macos: "Cmd+Option+Down" },
}, },
@@ -266,31 +187,31 @@ export const DESKTOP_MENU: DesktopMenu[] = [
}, },
{ {
id: "window", id: "window",
labelKey: "desktop.menu.window", label: "Window",
role: "windowMenu", role: "windowMenu",
items: [ items: [
{ type: "item", labelKey: "desktop.menu.minimize", action: "window.minimize" }, { type: "item", label: "Minimize", action: "window.minimize" },
{ type: "item", labelKey: "desktop.menu.maximize", action: "window.toggleMaximize" }, { type: "item", label: "Maximize", action: "window.toggleMaximize" },
{ type: "separator" }, { type: "separator" },
{ type: "item", labelKey: "desktop.menu.closeWindow", action: "window.close" }, { type: "item", label: "Close Window", action: "window.close" },
], ],
}, },
{ {
id: "help", id: "help",
labelKey: "desktop.menu.help", label: "Help",
items: [ items: [
{ type: "item", labelKey: "desktop.menu.documentation", href: "https://opencode.ai/docs" }, { type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" },
{ type: "item", labelKey: "desktop.menu.supportForum", href: "https://discord.com/invite/opencode" }, { type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" },
{ type: "item", labelKey: "desktop.menu.exportLogs", command: "logs.export" }, { type: "item", label: "Export Logs...", command: "logs.export" },
{ type: "separator" }, { type: "separator" },
{ {
type: "item", type: "item",
labelKey: "desktop.menu.shareFeedback", label: "Share Feedback",
href: "https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml", href: "https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml",
}, },
{ {
type: "item", type: "item",
labelKey: "desktop.menu.reportBug", label: "Report a Bug",
href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml", href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml",
}, },
], ],
+41 -39
View File
@@ -3,11 +3,10 @@
import * as Sentry from "@sentry/solid" import * as Sentry from "@sentry/solid"
import { render } from "solid-js/web" import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app" import { AppBaseProviders, AppInterface } from "@/app"
import { loadInitialLocale } from "@/context/language"
import { type Platform, PlatformProvider } from "@/context/platform" import { type Platform, PlatformProvider } from "@/context/platform"
import { createBrowserDraftStore } from "@/utils/draft-store"
import { dict as en } from "@/i18n/en" import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh" import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click"
import { authFromToken } from "@/utils/server" import { authFromToken } from "@/utils/server"
import pkg from "../package.json" import pkg from "../package.json"
import { ServerConnection } from "./context/server" import { ServerConnection } from "./context/server"
@@ -55,7 +54,7 @@ const setStorage = (key: string, value: string | null) => {
const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY) const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY)
const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url) const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url)
const notify: Platform["notify"] = async (title, description, onClick) => { const notify: Platform["notify"] = async (title, description, href) => {
if (!("Notification" in window)) return if (!("Notification" in window)) return
const permission = const permission =
@@ -74,17 +73,21 @@ const notify: Platform["notify"] = async (title, description, onClick) => {
}) })
notification.onclick = () => { notification.onclick = () => {
window.focus() handleNotificationClick(href)
onClick?.()
notification.close() notification.close()
} }
} }
const openExternal: Platform["openExternal"] = (value) => { const openLink: Platform["openLink"] = (url) => {
if (!URL.canParse(value)) return window.open(url, "_blank")
const url = new URL(value) }
if (url.protocol !== "http:" && url.protocol !== "https:" && url.protocol !== "mailto:") return
window.open(url.href, "_blank", "noopener,noreferrer") const back: Platform["back"] = () => {
window.history.back()
}
const forward: Platform["forward"] = () => {
window.history.forward()
} }
const restart: Platform["restart"] = async () => { const restart: Platform["restart"] = async () => {
@@ -118,9 +121,10 @@ const clearAuthToken = () => {
const platform: Platform = { const platform: Platform = {
platform: "web", platform: "web",
draftStore: createBrowserDraftStore(),
version: pkg.version, version: pkg.version,
openExternal, openLink,
back,
forward,
restart, restart,
notify, notify,
getDefaultServer: async () => { getDefaultServer: async () => {
@@ -150,31 +154,29 @@ if (import.meta.env.VITE_SENTRY_DSN) {
} }
if (root instanceof HTMLElement) { if (root instanceof HTMLElement) {
void loadInitialLocale().then((locale) => { const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token")) clearAuthToken()
clearAuthToken() const server: ServerConnection.Http = {
const server: ServerConnection.Http = { type: "http",
type: "http", authToken: !!auth,
authToken: !!auth, http: {
http: { url: getCurrentUrl(),
url: getCurrentUrl(), ...auth,
...auth, },
}, }
} render(
render( () => (
() => ( <PlatformProvider value={platform}>
<PlatformProvider value={platform}> <AppBaseProviders>
<AppBaseProviders locale={locale}> <AppInterface
<AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())}
defaultServer={ServerConnection.Key.make(getDefaultUrl())} canonicalLocalServer={ServerConnection.key(server)}
canonicalLocalServer={ServerConnection.key(server)} servers={[server]}
servers={[server]} disableHealthCheck
disableHealthCheck />
/> </AppBaseProviders>
</AppBaseProviders> </PlatformProvider>
</PlatformProvider> ),
), root,
root, )
)
})
} }
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { selectProviderCatalog } from "./provider-catalog" import { resolveDefaultModel, selectProviderCatalog } from "./provider-catalog"
const catalog = (id: string): NormalizedProviderListResponse => ({ const catalog = (id: string): NormalizedProviderListResponse => ({
all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]), all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
@@ -57,3 +57,21 @@ test("falls back to the global catalog for route consumers", () => {
}), }),
).toBe(global) ).toBe(global)
}) })
test("uses the current server default model", () => {
expect(resolveDefaultModel({ providerID: "openai", modelID: "gpt-5" }, "anthropic/claude")).toEqual({
providerID: "openai",
modelID: "gpt-5",
})
})
test("does not use legacy config when the current server has no default", () => {
expect(resolveDefaultModel(null, "anthropic/claude")).toBeUndefined()
})
test("uses config for legacy servers", () => {
expect(resolveDefaultModel(undefined, "anthropic/claude")).toEqual({
providerID: "anthropic",
modelID: "claude",
})
})
@@ -25,3 +25,13 @@ export function selectProviderCatalog(input: ProviderCatalogInput) {
if (input.explicit) return emptyProviderCatalog if (input.explicit) return emptyProviderCatalog
return input.global return input.global
} }
export function resolveDefaultModel(
current: NormalizedProviderListResponse["defaultModel"],
legacy: string | undefined,
) {
if (current !== undefined) return current ?? undefined
if (!legacy) return undefined
const [providerID, modelID] = legacy.split("/")
return { providerID, modelID }
}
+1
View File
@@ -41,6 +41,7 @@ export function useProviders(directory: Accessor<string | undefined>) {
return { return {
all: () => providers().all, all: () => providers().all,
default: () => providers().default, default: () => providers().default,
defaultModel: () => providers().defaultModel,
popular: () => popular: () =>
pipe( pipe(
providers().all, providers().all,
File diff suppressed because it is too large Load Diff
+82 -245
View File
@@ -1,102 +1,4 @@
export const dict = { export const dict = {
"desktop.menu.app": "OpenCode",
"desktop.menu.file": "ملف",
"desktop.menu.edit": "تحرير",
"desktop.menu.view": "عرض",
"desktop.menu.go": "انتقال",
"desktop.menu.window": "نافذة",
"desktop.menu.help": "مساعدة",
"desktop.menu.checkForUpdates": "التحقق من وجود تحديثات...",
"desktop.menu.settings": "الإعدادات",
"desktop.menu.reloadWebview": "إعادة تحميل Webview",
"desktop.menu.restart": "إعادة التشغيل",
"desktop.menu.exportLogs": "تصدير السجلات...",
"desktop.menu.newSession": "جلسة جديدة",
"desktop.menu.openProject": "فتح مشروع...",
"desktop.menu.newWindow": "نافذة جديدة",
"desktop.menu.closeWindow": "إغلاق النافذة",
"desktop.menu.undo": "تراجع",
"desktop.menu.redo": "إعادة",
"desktop.menu.cut": "قص",
"desktop.menu.copy": "نسخ",
"desktop.menu.paste": "لصق",
"desktop.menu.delete": "حذف",
"desktop.menu.selectAll": "تحديد الكل",
"desktop.menu.toggleSidebar": "إظهار الشريط الجانبي أو إخفاؤه",
"desktop.menu.toggleTerminal": "إظهار المحطة الطرفية أو إخفاؤها",
"desktop.menu.toggleFileTree": "إظهار شجرة الملفات أو إخفاؤها",
"desktop.menu.reload": "إعادة التحميل",
"desktop.menu.toggleDeveloperTools": "إظهار أدوات المطور أو إخفاؤها",
"desktop.menu.actualSize": "الحجم الفعلي",
"desktop.menu.zoomIn": "تكبير",
"desktop.menu.zoomOut": "تصغير",
"desktop.menu.toggleFullScreen": "الدخول إلى ملء الشاشة أو الخروج منه",
"desktop.menu.back": "رجوع",
"desktop.menu.forward": "تقدم",
"desktop.menu.previousSession": "الجلسة السابقة",
"desktop.menu.nextSession": "الجلسة التالية",
"desktop.menu.previousProject": "المشروع السابق",
"desktop.menu.nextProject": "المشروع التالي",
"desktop.menu.minimize": "تصغير النافذة",
"desktop.menu.maximize": "تكبير النافذة",
"desktop.menu.documentation": "وثائق OpenCode",
"desktop.menu.supportForum": "منتدى الدعم",
"desktop.menu.shareFeedback": "إرسال الملاحظات",
"desktop.menu.reportBug": "الإبلاغ عن خلل",
"desktop.menu.ariaLabel": "قائمة OpenCode",
"desktop.updater.dialog.checkFailed.message": "فشل التحقق من وجود تحديثات.",
"desktop.updater.dialog.checkFailed.title": "خطأ في التحديث",
"desktop.updater.dialog.upToDate.message": "لديك أحدث إصدار.",
"desktop.updater.dialog.upToDate.title": "لا توجد تحديثات",
"desktop.updater.dialog.ready.message": "تم تنزيل التحديث {{version}}. هل تريد إعادة التشغيل الآن؟",
"desktop.updater.dialog.ready.title": "التحديث جاهز",
"desktop.updater.dialog.restart": "إعادة التشغيل",
"desktop.updater.dialog.later": "لاحقًا",
"desktop.recovery.action.relaunch": "إعادة تشغيل التطبيق",
"desktop.recovery.action.exportLogs": "تصدير السجلات",
"desktop.recovery.action.keepWaiting": "متابعة الانتظار",
"desktop.recovery.action.quit": "إنهاء",
"desktop.recovery.loadFailed": "فشل تحميل OpenCode",
"desktop.recovery.terminated": "توقفت نافذة OpenCode بشكل غير متوقع",
"desktop.recovery.unresponsive": "OpenCode لا يستجيب",
"desktop.recovery.unresponsive.detail": "يمكنك إعادة تشغيل التطبيق أو فتح السجلات أو متابعة الانتظار.",
"desktop.recovery.loadFailed.detail": "النافذة: {{window}}\nعنوان URL: {{url}}\nالخطأ: {{code}} {{description}}",
"desktop.recovery.terminated.detail": "النافذة: {{window}}\nالسبب: {{reason}}\nالرمز: {{code}}",
"desktop.recovery.unknown": "<غير معروف>",
"desktop.dialog.chooseFolder": "اختيار مجلد",
"desktop.dialog.chooseFile": "اختيار ملف",
"desktop.dialog.saveFile": "حفظ الملف",
"desktop.dialog.files": "الملفات",
"desktop.server.local": "الخادم المحلي",
"desktop.wsl.error.windowsOnly": "لا يتوفر WSL إلا على Windows",
"desktop.wsl.error.unavailable": "WSL غير متاح",
"desktop.wsl.error.listInstalled": "فشل سرد توزيعات WSL المثبتة",
"desktop.wsl.error.listOnline": "فشل سرد توزيعات WSL المتاحة عبر الإنترنت",
"desktop.wsl.error.executeDistro": "تعذر تنفيذ الأوامر في التوزيعة",
"desktop.wsl.error.installWsl": "فشل تثبيت WSL",
"desktop.wsl.error.installDistro": "فشل تثبيت التوزيعة: {{distro}}",
"desktop.wsl.error.installOpencode": "فشل تثبيت OpenCode",
"desktop.wsl.error.alreadyAdded": "تمت إضافة {{distro}} بالفعل",
"desktop.wsl.error.opencodeMissing": "opencode غير مثبت في هذه التوزيعة",
"desktop.wsl.error.opencodeCannotRun": "opencode مثبت، لكن تعذر تشغيله",
"desktop.wsl.error.opencodeNotInstalled": "OpenCode غير مثبت في {{distro}}",
"desktop.wsl.error.updateVersion":
"اكتمل تحديث OpenCode، لكن {{distro}} ما زالت تعرض الإصدار {{installed}}؛ الإصدار المتوقع هو {{expected}}",
"desktop.wsl.error.noVersion": "لا يوجد إصدار",
"desktop.wsl.error.serverExited": "خرج خادم WSL بعد بدء التشغيل (code={{code}} signal={{signal}})",
"desktop.wsl.error.serverExitedBeforeHealthy":
"خرج خادم WSL قبل أن يصبح سليمًا (code={{code}} signal={{signal}}){{output}}",
"desktop.wsl.error.healthTimeout": "انتهت مهلة فحص سلامة العملية المصاحبة للتوزيعة {{distro}} بعد {{timeout}}ms",
"desktop.wsl.error.commandTimeout": "انتهت مهلة {{command}} {{args}} بعد {{timeout}}ms",
"desktop.wsl.error.failedPort": "فشل الحصول على المنفذ",
"desktop.picker.error.notSelected": "لم يحدد منتقي الملفات ملفًا",
"desktop.picker.error.sizeLimit": "تتجاوز المرفقات المحددة الحد البالغ {{limit}} MB",
"command.category.suggested": "مقترح", "command.category.suggested": "مقترح",
"command.category.view": "عرض", "command.category.view": "عرض",
"command.category.project": "مشروع", "command.category.project": "مشروع",
@@ -114,7 +16,6 @@ export const dict = {
"command.category.permissions": "أذونات", "command.category.permissions": "أذونات",
"command.category.workspace": "مساحة عمل", "command.category.workspace": "مساحة عمل",
"command.category.settings": "إعدادات", "command.category.settings": "إعدادات",
"command.logs.export": "تصدير السجلات",
"theme.scheme.system": "نظام", "theme.scheme.system": "نظام",
"theme.scheme.light": "فاتح", "theme.scheme.light": "فاتح",
"theme.scheme.dark": "داكن", "theme.scheme.dark": "داكن",
@@ -158,16 +59,16 @@ export const dict = {
"command.message.next.description": "انتقل إلى رسالة المستخدم التالية", "command.message.next.description": "انتقل إلى رسالة المستخدم التالية",
"command.model.choose": "اختيار نموذج", "command.model.choose": "اختيار نموذج",
"command.model.choose.description": "حدد نموذجًا مختلفًا", "command.model.choose.description": "حدد نموذجًا مختلفًا",
"command.mcp.toggle": "تبديل خوادم MCP", "command.mcp.toggle": "تبديل MCPs",
"command.mcp.toggle.description": "تبديل خوادم MCP", "command.mcp.toggle.description": "تبديل MCPs",
"command.agent.cycle": "تغيير الوكيل", "command.agent.cycle": "تغيير الوكيل",
"command.agent.cycle.description": "التبديل إلى الوكيل التالي", "command.agent.cycle.description": "التبديل إلى الوكيل التالي",
"command.agent.cycle.reverse": "تغيير الوكيل للخلف", "command.agent.cycle.reverse": "تغيير الوكيل للخلف",
"command.agent.cycle.reverse.description": "التبديل إلى الوكيل السابق", "command.agent.cycle.reverse.description": "التبديل إلى الوكيل السابق",
"command.model.variant.cycle": "تغيير جهد الاستدلال", "command.model.variant.cycle": "تغيير جهد التفكير",
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي", "command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
"command.prompt.mode.shell": "Shell", "command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "موجّه", "command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "قبول الأذونات تلقائيًا", "command.permissions.autoaccept.enable": "قبول الأذونات تلقائيًا",
"command.permissions.autoaccept.disable": "إيقاف قبول الأذونات تلقائيًا", "command.permissions.autoaccept.disable": "إيقاف قبول الأذونات تلقائيًا",
"command.workspace.toggle": "تبديل مساحات العمل", "command.workspace.toggle": "تبديل مساحات العمل",
@@ -184,9 +85,6 @@ export const dict = {
"command.session.share.description": "مشاركة هذه الجلسة ونسخ الرابط إلى الحافظة", "command.session.share.description": "مشاركة هذه الجلسة ونسخ الرابط إلى الحافظة",
"command.session.unshare": "إلغاء مشاركة الجلسة", "command.session.unshare": "إلغاء مشاركة الجلسة",
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة", "command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
"command.session.export": "تصدير الجلسة",
"command.session.export.description": "تصدير النص الكامل للجلسة بصيغة JSON",
"palette.search.placeholder": "البحث في الملفات والأوامر والجلسات", "palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
"palette.search.placeholder.home": "البحث في الأوامر والجلسات", "palette.search.placeholder.home": "البحث في الأوامر والجلسات",
"palette.empty": "لا توجد نتائج", "palette.empty": "لا توجد نتائج",
@@ -196,14 +94,13 @@ export const dict = {
"dialog.provider.empty": "لم يتم العثور على موفرين", "dialog.provider.empty": "لم يتم العثور على موفرين",
"dialog.provider.group.popular": "شائع", "dialog.provider.group.popular": "شائع",
"dialog.provider.group.other": "آخر", "dialog.provider.group.other": "آخر",
"dialog.provider.custom.label": "موفر مخصص متوافق مع OpenAI",
"dialog.provider.tag.recommended": "موصى به", "dialog.provider.tag.recommended": "موصى به",
"dialog.provider.opencode.note": "نماذج مختارة تتضمن Claude و GPT و Gemini والمزيد", "dialog.provider.opencode.note": "نماذج مختارة تتضمن Claude و GPT و Gemini والمزيد",
"dialog.provider.opencode.tagline": "نماذج موثوقة ومحسنة", "dialog.provider.opencode.tagline": "نماذج موثوقة ومحسنة",
"dialog.provider.opencodeGo.tagline": "اشتراك منخفض التكلفة للجميع", "dialog.provider.opencodeGo.tagline": "اشتراك منخفض التكلفة للجميع",
"dialog.provider.anthropic.note": "وصول مباشر إلى نماذج Claude، بما فيها Pro و Max", "dialog.provider.anthropic.note": "اتصل باستخدام Claude Pro/Max أو مفتاح API",
"dialog.provider.copilot.note": "نماذج ذكاء اصطناعي للمساعدة في البرمجة عبر GitHub Copilot", "dialog.provider.copilot.note": "اتصل باستخدام Copilot أو مفتاح API",
"dialog.provider.openai.note": "نماذج GPT لمهام الذكاء الاصطناعي العامة السريعة والمتقدمة", "dialog.provider.openai.note": "اتصل باستخدام ChatGPT Pro/Plus أو مفتاح API",
"dialog.provider.google.note": "نماذج Gemini لاستجابات سريعة ومنظمة", "dialog.provider.google.note": "نماذج Gemini لاستجابات سريعة ومنظمة",
"dialog.provider.openrouter.note": "الوصول إلى جميع النماذج المدعومة من موفر واحد", "dialog.provider.openrouter.note": "الوصول إلى جميع النماذج المدعومة من موفر واحد",
"dialog.provider.vercel.note": "وصول موحد إلى نماذج الذكاء الاصطناعي مع توجيه ذكي", "dialog.provider.vercel.note": "وصول موحد إلى نماذج الذكاء الاصطناعي مع توجيه ذكي",
@@ -215,20 +112,17 @@ export const dict = {
"dialog.model.manage.provider.toggle": "تبديل جميع نماذج {{provider}}", "dialog.model.manage.provider.toggle": "تبديل جميع نماذج {{provider}}",
"dialog.model.unpaid.freeModels.title": "نماذج مجانية مقدمة من OpenCode", "dialog.model.unpaid.freeModels.title": "نماذج مجانية مقدمة من OpenCode",
"dialog.model.unpaid.addMore.title": "إضافة المزيد من النماذج من موفرين مشهورين", "dialog.model.unpaid.addMore.title": "إضافة المزيد من النماذج من موفرين مشهورين",
"dialog.model.unpaid.viewMoreProviders": "عرض أكثر من 70 موفرًا إضافيًا",
"dialog.provider.viewAll": "عرض المزيد من الموفرين", "dialog.provider.viewAll": "عرض المزيد من الموفرين",
"provider.connect.title": "الاتصال بـ {{provider}}", "provider.connect.title": "اتصال {{provider}}",
"provider.connect.title.anthropicProMax": "تسجيل الدخول باستخدام Claude Pro/Max", "provider.connect.title.anthropicProMax": "تسجيل الدخول باستخدام Claude Pro/Max",
"provider.connect.selectMethod": "حدد طريقة تسجيل الدخول لـ {{provider}}.", "provider.connect.selectMethod": "حدد طريقة تسجيل الدخول لـ {{provider}}.",
"provider.connect.method.apiKey": "مفتاح API", "provider.connect.method.apiKey": "مفتاح API",
"provider.connect.method.browser": "المتصفح",
"provider.connect.method.headless": "بدون واجهة رسومية",
"provider.connect.status.inProgress": "جارٍ التفويض...", "provider.connect.status.inProgress": "جارٍ التفويض...",
"provider.connect.status.waiting": "في انتظار التفويض...", "provider.connect.status.waiting": "في انتظار التفويض...",
"provider.connect.status.failed": "فشل التفويض: {{error}}", "provider.connect.status.failed": "فشل التفويض: {{error}}",
"provider.connect.apiKey.description": "provider.connect.apiKey.description":
"أدخل مفتاح API الخاص بـ {{provider}} لتوصيل حسابك واستخدام نماذج {{provider}} في OpenCode.", "أدخل مفتاح واجهة برمجة تطبيقات {{provider}} الخاص بك لتوصيل حسابك واستخدام نماذج {{provider}} في OpenCode.",
"provider.connect.apiKey.label": "مفتاح API لـ {{provider}}", "provider.connect.apiKey.label": "مفتاح واجهة برمجة تطبيقات {{provider}}",
"provider.connect.apiKey.placeholder": "مفتاح API", "provider.connect.apiKey.placeholder": "مفتاح API",
"provider.connect.apiKey.required": "مفتاح API مطلوب", "provider.connect.apiKey.required": "مفتاح API مطلوب",
"provider.connect.opencodeZen.line1": "provider.connect.opencodeZen.line1":
@@ -254,7 +148,6 @@ export const dict = {
"provider.connect.toast.connected.title": "تم توصيل {{provider}}", "provider.connect.toast.connected.title": "تم توصيل {{provider}}",
"provider.connect.toast.connected.description": "نماذج {{provider}} متاحة الآن للاستخدام.", "provider.connect.toast.connected.description": "نماذج {{provider}} متاحة الآن للاستخدام.",
"provider.custom.title": "موفر مخصص", "provider.custom.title": "موفر مخصص",
"provider.custom.unavailable": "الموفرون المخصصون غير متاحين على هذا الخادم",
"provider.custom.description.prefix": "تكوين موفر متوافق مع OpenAI. راجع ", "provider.custom.description.prefix": "تكوين موفر متوافق مع OpenAI. راجع ",
"provider.custom.description.link": "وثائق تكوين الموفر", "provider.custom.description.link": "وثائق تكوين الموفر",
"provider.custom.description.suffix": ".", "provider.custom.description.suffix": ".",
@@ -303,26 +196,26 @@ export const dict = {
"model.input.image": "صورة", "model.input.image": "صورة",
"model.input.audio": "صوت", "model.input.audio": "صوت",
"model.input.video": "فيديو", "model.input.video": "فيديو",
"model.input.pdf": "PDF", "model.input.pdf": "pdf",
"model.tooltip.allows": "يسمح: {{inputs}}", "model.tooltip.allows": "يسمح: {{inputs}}",
"model.tooltip.context.label": "السياق", "model.tooltip.context.label": "السياق",
"model.tooltip.inputs": "المدخلات", "model.tooltip.inputs": "المدخلات",
"model.tooltip.model": "النموذج", "model.tooltip.model": "النموذج",
"model.tooltip.provider": "الموفر", "model.tooltip.provider": "الموفر",
"model.tooltip.reasoning": "الاستدلال", "model.tooltip.reasoning": "الاستنتاج",
"model.tooltip.reasoning.allowed": دعم الاستدلال", "model.tooltip.reasoning.allowed": سمح بالاستنتاج",
"model.tooltip.reasoning.none": "بدون استدلال", "model.tooltip.reasoning.none": "بدون استنتاج",
"model.tooltip.context": "حد السياق {{limit}}", "model.tooltip.context": "حد السياق {{limit}}",
"common.search.placeholder": "بحث", "common.search.placeholder": "بحث",
"common.goBack": "رجوع", "common.goBack": "رجوع",
"common.goForward": "التقدم", "common.goForward": "انتقل للأمام",
"common.loading": "جارٍ التحميل", "common.loading": "جارٍ التحميل",
"common.loading.ellipsis": "...", "common.loading.ellipsis": "...",
"common.cancel": "إلغاء", "common.cancel": "إلغاء",
"common.clear": "مسح", "common.clear": "مسح",
"common.connect": "اتصال", "common.connect": "اتصال",
"common.disconnect": "قطع الاتصال", "common.disconnect": "قطع الاتصال",
"common.continue": "متابعة", "common.continue": "إرسال",
"common.submit": "إرسال", "common.submit": "إرسال",
"common.save": "حفظ", "common.save": "حفظ",
"common.saving": "جارٍ الحفظ...", "common.saving": "جارٍ الحفظ...",
@@ -334,7 +227,7 @@ export const dict = {
"prompt.placeholder.summarizeComments": "لخّص التعليقات…", "prompt.placeholder.summarizeComments": "لخّص التعليقات…",
"prompt.placeholder.summarizeComment": "لخّص التعليق…", "prompt.placeholder.summarizeComment": "لخّص التعليق…",
"prompt.mode.shell": "Shell", "prompt.mode.shell": "Shell",
"prompt.mode.normal": "موجّه", "prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc للخروج", "prompt.mode.shell.exit": "esc للخروج",
"session.child.promptDisabled": "لا يمكن إرسال موجّهات إلى جلسات الوكلاء الفرعيين.", "session.child.promptDisabled": "لا يمكن إرسال موجّهات إلى جلسات الوكلاء الفرعيين.",
"session.child.backToParent": "العودة إلى الجلسة الرئيسية.", "session.child.backToParent": "العودة إلى الجلسة الرئيسية.",
@@ -356,17 +249,17 @@ export const dict = {
"prompt.example.16": "إضافة معالجة الأخطاء لهذه الدالة", "prompt.example.16": "إضافة معالجة الأخطاء لهذه الدالة",
"prompt.example.17": "اشرح نمط regex هذا", "prompt.example.17": "اشرح نمط regex هذا",
"prompt.example.18": "تحويل هذا إلى TypeScript", "prompt.example.18": "تحويل هذا إلى TypeScript",
"prompt.example.19": "إضافة التسجيل في جميع أنحاء قاعدة التعليمات البرمجية", "prompt.example.19": "إضافة تسجيل الدخول (logging) في جميع أنحاء قاعدة التعليمات البرمجية",
"prompt.example.20": "ما هي التبعيات القديمة؟", "prompt.example.20": "ما هي التبعيات القديمة؟",
"prompt.example.21": "ساعدني في كتابة برنامج نصي للترحيل", "prompt.example.21": "ساعدني في كتابة برنامج نصي للهجرة",
"prompt.example.22": "تنفيذ التخزين المؤقت لنقطة النهاية هذه", "prompt.example.22": "تنفيذ التخزين المؤقت لهذه النقطة النهائية",
"prompt.example.23": "إضافة ترقيم الصفحات إلى هذه القائمة", "prompt.example.23": "إضافة ترقيم الصفحات إلى هذه القائمة",
"prompt.example.24": "إنشاء أمر CLI لـ...", "prompt.example.24": "إنشاء أمر CLI لـ...",
"prompt.example.25": "كيف تعمل متغيرات البيئة هنا؟", "prompt.example.25": "كيف تعمل متغيرات البيئة هنا؟",
"prompt.popover.emptyResults": "لا توجد نتائج مطابقة", "prompt.popover.emptyResults": "لا توجد نتائج مطابقة",
"prompt.popover.emptyCommands": "لا توجد أوامر مطابقة", "prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF أو الملفات النصية هنا", "prompt.dropzone.label": "أفلت الصور أو ملفات PDF أو الملفات النصية هنا",
"prompt.dropzone.file.label": "أفلت الملف للإشارة إليه باستخدام @", "prompt.dropzone.file.label": "أفلت لإشارة @ للملف",
"prompt.slash.badge.custom": "مخصص", "prompt.slash.badge.custom": "مخصص",
"prompt.slash.badge.skill": "مهارة", "prompt.slash.badge.skill": "مهارة",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@@ -374,7 +267,7 @@ export const dict = {
"prompt.context.includeActiveFile": "تضمين الملف النشط", "prompt.context.includeActiveFile": "تضمين الملف النشط",
"prompt.context.removeActiveFile": "إزالة الملف النشط من السياق", "prompt.context.removeActiveFile": "إزالة الملف النشط من السياق",
"prompt.context.removeFile": "إزالة الملف من السياق", "prompt.context.removeFile": "إزالة الملف من السياق",
"prompt.action.attachFile": ضافة ملفات", "prompt.action.attachFile": رفاق ملف",
"prompt.menu.addImagesAndFiles": "إضافة ملفات والمزيد", "prompt.menu.addImagesAndFiles": "إضافة ملفات والمزيد",
"prompt.menu.imagesAndFiles": "الصور والملفات", "prompt.menu.imagesAndFiles": "الصور والملفات",
"prompt.menu.commands": "الأوامر", "prompt.menu.commands": "الأوامر",
@@ -382,9 +275,8 @@ export const dict = {
"prompt.menu.shellCommand": "أمر shell", "prompt.menu.shellCommand": "أمر shell",
"prompt.attachment.remove": "إزالة المرفق", "prompt.attachment.remove": "إزالة المرفق",
"prompt.action.send": "إرسال", "prompt.action.send": "إرسال",
"prompt.action.stop": "إيقاف", "prompt.action.stop": "توقف",
"prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم", "prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم",
"prompt.toast.attachmentDuplicate.title": "تم تحميل هذا الملف بالفعل",
"prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.", "prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.",
"prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً", "prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً",
"prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.", "prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.",
@@ -394,10 +286,10 @@ export const dict = {
"prompt.toast.commandSendFailed.title": "فشل إرسال الأمر", "prompt.toast.commandSendFailed.title": "فشل إرسال الأمر",
"prompt.toast.promptSendFailed.title": "فشل إرسال الموجه", "prompt.toast.promptSendFailed.title": "فشل إرسال الموجه",
"prompt.toast.promptSendFailed.description": "تعذر استرداد الجلسة", "prompt.toast.promptSendFailed.description": "تعذر استرداد الجلسة",
"dialog.mcp.title": "خوادم MCP", "dialog.mcp.title": "MCPs",
"dialog.mcp.description": "تم تفعيل {{enabled}} من أصل {{total}}", "dialog.mcp.description": "{{enabled}} من {{total}} مفعل",
"dialog.mcp.empty": "لم يتم تكوين أي خوادم MCP", "dialog.mcp.empty": "لم يتم تكوين MCPs",
"dialog.lsp.empty": ُكتشف خوادم LSP تلقائيًا من أنواع الملفات", "dialog.lsp.empty": م الكشف تلقائيًا عن LSPs من أنواع الملفات",
"dialog.plugins.empty": "الإضافات المكونة في opencode.json", "dialog.plugins.empty": "الإضافات المكونة في opencode.json",
"mcp.status.connected": "متصل", "mcp.status.connected": "متصل",
"mcp.status.failed": "فشل", "mcp.status.failed": "فشل",
@@ -508,21 +400,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "سطح المكتب {{version}}", "wsl.onboarding.desktopVersion": "سطح المكتب {{version}}",
"wsl.onboarding.versionMismatch": "لا يتطابق الإصدار المثبت مع إصدار تطبيق سطح المكتب.", "wsl.onboarding.versionMismatch": "لا يتطابق الإصدار المثبت مع إصدار تطبيق سطح المكتب.",
"wsl.onboarding.adding": "جارٍ الإضافة...", "wsl.onboarding.adding": "جارٍ الإضافة...",
"help.tabs.toast.ariaLabel": "نقدّم علامات التبويب. نظّم عملك وجلساتك النشطة باستخدام علامات التبويب",
"help.tabs.toast.dismiss": "إغلاق معلومات علامات التبويب",
"help.tabs.title": "نقدّم علامات التبويب",
"help.tabs.description": "نظّم عملك وجلساتك النشطة باستخدام علامات التبويب",
"help.tabs.date": "14 يوليو",
"help.tabs.introduction": "أصبح OpenCode Desktop الآن قائمًا على علامات التبويب.",
"help.tabs.sessions":
"ابدأ جلسة جديدة في علامة تبويب، أو افتح جلسة حالية من أي من مشاريعك. افتح علامة تبويب جديدة عندما تبدأ عملاً جديدًا، وأغلقها عند الانتهاء.",
"help.tabs.organize":
"يسهّل إبقاء بضع علامات تبويب مفتوحة تنظيم جلساتك النشطة. أعد تسمية علامات التبويب بأسماء يسهل تذكرها إذا كنت تنوي إبقاءها مفتوحة.",
"help.tabs.home":
"ستجد جميع جلساتك ومشاريعك في شاشة الصفحة الرئيسية الجديدة. يؤدي تحديد جلسة إلى فتحها في علامة تبويب.",
"help.tabs.persistence": "عندما تعيد فتح التطبيق، تظل علامات تبويبك مفتوحة.",
"help.tabs.worktrees":
"لا يدعم التصميم الجديد أشجار عمل Git حتى الآن، وستتوفر قريبًا. لذلك، إذا كنت تفضل مواصلة استخدام التخطيط السابق، فيمكنك التبديل بين التخطيطات في الإعدادات. ضع في اعتبارك أن التخطيط الجديد سيصبح دائمًا خلال بضعة أسابيع.",
"dialog.project.edit.title": "تحرير المشروع", "dialog.project.edit.title": "تحرير المشروع",
"dialog.project.edit.name": "الاسم", "dialog.project.edit.name": "الاسم",
"dialog.project.edit.icon": "أيقونة", "dialog.project.edit.icon": "أيقونة",
@@ -531,39 +408,35 @@ export const dict = {
"dialog.project.edit.icon.recommended": "موصى به: 128x128px", "dialog.project.edit.icon.recommended": "موصى به: 128x128px",
"dialog.project.edit.color": "لون", "dialog.project.edit.color": "لون",
"dialog.project.edit.color.select": "اختر لون {{color}}", "dialog.project.edit.color.select": "اختر لون {{color}}",
"dialog.project.edit.worktree.startup": "برنامج نصي لبدء تشغيل مساحة العمل", "dialog.project.edit.worktree.startup": "سكريبت بدء تشغيل مساحة العمل",
"dialog.project.edit.worktree.startup.description": "يتم تشغيله بعد إنشاء مساحة عمل جديدة (شجرة عمل).", "dialog.project.edit.worktree.startup.description": "يتم تشغيله بعد إنشاء مساحة عمل جديدة (شجرة عمل).",
"dialog.project.edit.worktree.startup.placeholder": "مثال: bun install", "dialog.project.edit.worktree.startup.placeholder": "مثال: bun install",
"dialog.usageExceeded.dontShowAgain": "عدم الإظهار مرة أخرى",
"context.breakdown.title": "تفصيل السياق", "context.breakdown.title": "تفصيل السياق",
"context.breakdown.note": 'تفصيل تقريبي لرموز الإدخال المميزة. يشمل "أخرى" تعريفات الأدوات والأعباء الإضافية.', "context.breakdown.note": 'تفصيل تقريبي لرموز الإدخال. يشمل "أخرى" تعريفات الأدوات والنفقات العامة.',
"context.breakdown.system": "النظام", "context.breakdown.system": "النظام",
"context.breakdown.user": "المستخدم", "context.breakdown.user": "المستخدم",
"context.breakdown.assistant": "المساعد", "context.breakdown.assistant": "المساعد",
"context.breakdown.tool": "استدعاءات الأدوات", "context.breakdown.tool": "استدعاءات الأداة",
"context.breakdown.other": "أخرى", "context.breakdown.other": "أخرى",
"context.systemPrompt.title": "موجّه النظام", "context.systemPrompt.title": "موجه النظام",
"context.rawMessages.title": "الرسائل الخام", "context.rawMessages.title": "الرسائل الخام",
"context.export.session": "تصدير الجلسة",
"context.stats.session": "جلسة", "context.stats.session": "جلسة",
"context.stats.messages": "رسائل", "context.stats.messages": "رسائل",
"context.stats.provider": "موفر", "context.stats.provider": "موفر",
"context.stats.model": "نموذج", "context.stats.model": "نموذج",
"context.stats.limit": "حد السياق", "context.stats.limit": "حد السياق",
"context.stats.totalTokens": "إجمالي الرموز المميزة", "context.stats.totalTokens": "إجمالي الرموز",
"context.stats.usage": "استخدام", "context.stats.usage": "استخدام",
"context.stats.inputTokens": "رموز الإدخال المميزة", "context.stats.inputTokens": "رموز الإدخال",
"context.stats.outputTokens": "رموز الإخراج المميزة", "context.stats.outputTokens": "رموز الإخراج",
"context.stats.reasoningTokens": "رموز الاستدلال المميزة", "context.stats.reasoningTokens": "رموز الاستنتاج",
"context.stats.cacheTokens": "رموز التخزين المؤقت المميزة (قراءة/كتابة)", "context.stats.cacheTokens": "رموز التخزين المؤقت (قراءة/كتابة)",
"context.stats.userMessages": "رسائل المستخدم", "context.stats.userMessages": "رسائل المستخدم",
"context.stats.assistantMessages": "رسائل المساعد", "context.stats.assistantMessages": "رسائل المساعد",
"context.stats.totalCost": "التكلفة الإجمالية", "context.stats.totalCost": "التكلفة الإجمالية",
"context.stats.sessionCreated": "تم إنشاء الجلسة", "context.stats.sessionCreated": "تم إنشاء الجلسة",
"context.stats.lastActivity": "آخر نشاط", "context.stats.lastActivity": "آخر نشاط",
"context.usage.tokens": "الرموز المميزة", "context.usage.tokens": "رموز",
"context.usage.usage": "استخدام", "context.usage.usage": "استخدام",
"context.usage.cost": "تكلفة", "context.usage.cost": "تكلفة",
"context.usage.clickToView": "انقر لعرض السياق", "context.usage.clickToView": "انقر لعرض السياق",
@@ -591,9 +464,9 @@ export const dict = {
"toast.theme.title": "تم تبديل السمة", "toast.theme.title": "تم تبديل السمة",
"toast.scheme.title": "مخطط الألوان", "toast.scheme.title": "مخطط الألوان",
"toast.workspace.enabled.title": "تم تمكين مساحات العمل", "toast.workspace.enabled.title": "تم تمكين مساحات العمل",
"toast.workspace.enabled.description": "تُعرض الآن عدة أشجار عمل في الشريط الجانبي", "toast.workspace.enabled.description": "الآن يتم عرض عدة worktrees في الشريط الجانبي",
"toast.workspace.disabled.title": "تم تعطيل مساحات العمل", "toast.workspace.disabled.title": "تم تعطيل مساحات العمل",
"toast.workspace.disabled.description": "تُعرض شجرة العمل الرئيسية فقط في الشريط الجانبي", "toast.workspace.disabled.description": "يتم عرض worktree الرئيسي فقط في الشريط الجانبي",
"toast.permissions.autoaccept.on.title": "يتم قبول الأذونات تلقائيًا", "toast.permissions.autoaccept.on.title": "يتم قبول الأذونات تلقائيًا",
"toast.permissions.autoaccept.on.description": "ستتم الموافقة على طلبات الأذونات تلقائيًا", "toast.permissions.autoaccept.on.description": "ستتم الموافقة على طلبات الأذونات تلقائيًا",
"toast.permissions.autoaccept.off.title": "تم إيقاف قبول الأذونات تلقائيًا", "toast.permissions.autoaccept.off.title": "تم إيقاف قبول الأذونات تلقائيًا",
@@ -613,14 +486,9 @@ export const dict = {
"toast.session.unshare.success.description": "تم إلغاء مشاركة الجلسة بنجاح!", "toast.session.unshare.success.description": "تم إلغاء مشاركة الجلسة بنجاح!",
"toast.session.unshare.failed.title": "فشل إلغاء مشاركة الجلسة", "toast.session.unshare.failed.title": "فشل إلغاء مشاركة الجلسة",
"toast.session.unshare.failed.description": "حدث خطأ أثناء إلغاء مشاركة الجلسة", "toast.session.unshare.failed.description": "حدث خطأ أثناء إلغاء مشاركة الجلسة",
"toast.session.export.success.title": "تم تصدير الجلسة",
"toast.session.export.success.description": "تم حفظ الجلسة في \u2068{{filename}}\u2069",
"toast.session.export.failed.title": "فشل تصدير الجلسة",
"toast.session.export.failed.description": "حدث خطأ أثناء تصدير الجلسة",
"toast.session.listFailed.title": "فشل تحميل الجلسات لـ {{project}}", "toast.session.listFailed.title": "فشل تحميل الجلسات لـ {{project}}",
"toast.update.title": "تحديث متاح", "toast.update.title": "تحديث متاح",
"toast.update.description": "يتوفر الآن إصدار جديد من OpenCode ({{version}}) للتثبيت.", "toast.update.description": "نسخة جديدة من OpenCode ({{version}}) متاحة الآن للتثبيت.",
"toast.update.action.installRestart": "تثبيت وإعادة تشغيل", "toast.update.action.installRestart": "تثبيت وإعادة تشغيل",
"toast.update.action.notYet": "ليس الآن", "toast.update.action.notYet": "ليس الآن",
"error.page.title": "حدث خطأ ما", "error.page.title": "حدث خطأ ما",
@@ -640,7 +508,7 @@ export const dict = {
"error.dev.rootNotFound": "error.dev.rootNotFound":
"لم يتم العثور على العنصر الجذري. هل نسيت إضافته إلى index.html؟ أو ربما تمت كتابة سمة id بشكل خاطئ؟", "لم يتم العثور على العنصر الجذري. هل نسيت إضافته إلى index.html؟ أو ربما تمت كتابة سمة id بشكل خاطئ؟",
"error.serverSync.connectFailed": "تعذر الاتصال بالخادم. هل هناك خادم يعمل في `{{url}}`؟", "error.serverSync.connectFailed": "تعذر الاتصال بالخادم. هل هناك خادم يعمل في `{{url}}`؟",
"directory.error.invalidUrl": "مجلد غير صالح في عنوان URL.", "directory.error.invalidUrl": ليل غير صالح في عنوان URL.",
"error.chain.unknown": "خطأ غير معروف", "error.chain.unknown": "خطأ غير معروف",
"error.chain.causedBy": "بسبب:", "error.chain.causedBy": "بسبب:",
"error.chain.apiError": "خطأ API", "error.chain.apiError": "خطأ API",
@@ -668,7 +536,7 @@ export const dict = {
"notification.session.responseReady.title": "الاستجابة جاهزة", "notification.session.responseReady.title": "الاستجابة جاهزة",
"notification.session.error.title": "خطأ في الجلسة", "notification.session.error.title": "خطأ في الجلسة",
"notification.session.error.fallbackDescription": "حدث خطأ", "notification.session.error.fallbackDescription": "حدث خطأ",
"home.recentProjects": "المشاريع الأخيرة", "home.recentProjects": "المشاريع الحديثة",
"home.empty.title": "لا توجد مشاريع حديثة", "home.empty.title": "لا توجد مشاريع حديثة",
"home.empty.description": "ابدأ بفتح مشروع محلي", "home.empty.description": "ابدأ بفتح مشروع محلي",
"home.title": "الرئيسية", "home.title": "الرئيسية",
@@ -686,7 +554,7 @@ export const dict = {
"home.sessions.group.today": "اليوم", "home.sessions.group.today": "اليوم",
"home.sessions.group.yesterday": "أمس", "home.sessions.group.yesterday": "أمس",
"home.sessions.group.older": "الأقدم", "home.sessions.group.older": "الأقدم",
"home.providerTip": "اتصل بأكثر من 75 موفرًا لاستخدام نماذج أخرى، بما فيها Claude وGPT وGemini وغيرها", "home.providerTip": "اتصل بأكثر من 75 مزودًا لاستخدام نماذج أخرى، بما فيها Claude وGPT وGemini وغيرها",
"session.tab.session": "جلسة", "session.tab.session": "جلسة",
"session.tab.review": "مراجعة", "session.tab.review": "مراجعة",
"session.tab.context": "سياق", "session.tab.context": "سياق",
@@ -696,7 +564,7 @@ export const dict = {
"session.error.notFound.description": "يشير هذا التبويب إلى جلسة لم تعد موجودة على هذا الخادم.", "session.error.notFound.description": "يشير هذا التبويب إلى جلسة لم تعد موجودة على هذا الخادم.",
"session.error.notFound.closeTab": "إغلاق علامة التبويب", "session.error.notFound.closeTab": "إغلاق علامة التبويب",
"session.error.serverConnection": "تعذر الاتصال بهذا الخادم", "session.error.serverConnection": "تعذر الاتصال بهذا الخادم",
"session.review.filesChanged": "الملفات المتغيرة: {{count}}", "session.review.filesChanged": "تم تغيير {{count}} ملفات",
"session.review.change.one": "تغيير", "session.review.change.one": "تغيير",
"session.review.change.other": "تغييرات", "session.review.change.other": "تغييرات",
"session.review.loadingChanges": "جارٍ تحميل التغييرات...", "session.review.loadingChanges": "جارٍ تحميل التغييرات...",
@@ -704,7 +572,7 @@ export const dict = {
"session.review.noChanges": "لا توجد تغييرات", "session.review.noChanges": "لا توجد تغييرات",
"session.review.noUncommittedChanges": "لا توجد تغييرات غير مُودَعة بعد", "session.review.noUncommittedChanges": "لا توجد تغييرات غير مُودَعة بعد",
"session.review.noBranchChanges": "لا توجد تغييرات في الفرع بعد", "session.review.noBranchChanges": "لا توجد تغييرات في الفرع بعد",
"session.review.noVcs": "لم يُكتشف نظام Git للتحكم في الإصدارات، لذا لن تُعرض التغييرات", "session.review.noVcs": "لم يتم اكتشاف نظام التحكم في الإصدار Git، لن يتم عرض التغييرات",
"session.review.noSnapshot": "تم تعطيل تتبع اللقطات في التكوين، لذا فإن تغييرات الجلسة غير متوفرة", "session.review.noSnapshot": "تم تعطيل تتبع اللقطات في التكوين، لذا فإن تغييرات الجلسة غير متوفرة",
"session.files.selectToOpen": "اختر ملفًا لفتحه", "session.files.selectToOpen": "اختر ملفًا لفتحه",
"session.files.all": "كل الملفات", "session.files.all": "كل الملفات",
@@ -722,26 +590,14 @@ export const dict = {
"session.question.minimize": "تصغير السؤال", "session.question.minimize": "تصغير السؤال",
"session.question.restore": "استعادة السؤال", "session.question.restore": "استعادة السؤال",
"session.question.pending.one": "{{count}} سؤال معلق", "session.question.pending.one": "{{count}} سؤال معلق",
"session.question.pending.zero": "عدد الأسئلة المعلقة: {{count}}",
"session.question.pending.two": "عدد الأسئلة المعلقة: {{count}}",
"session.question.pending.few": "{{count}} أسئلة معلقة",
"session.question.pending.many": "{{count}} سؤالًا معلقًا",
"session.question.pending.other": "الأسئلة المعلقة: {{count}}", "session.question.pending.other": "الأسئلة المعلقة: {{count}}",
"session.followupDock.summary.one": "{{count}} رسالة في قائمة الانتظار", "session.followupDock.summary.one": "{{count}} رسالة في الانتظار",
"session.followupDock.summary.zero": "عدد الرسائل في قائمة الانتظار: {{count}}", "session.followupDock.summary.other": "{{count}} رسائل في الانتظار",
"session.followupDock.summary.two": "عدد الرسائل في قائمة الانتظار: {{count}}",
"session.followupDock.summary.few": "{{count}} رسائل في قائمة الانتظار",
"session.followupDock.summary.many": "{{count}} رسالةً في قائمة الانتظار",
"session.followupDock.summary.other": "{{count}} رسائل في قائمة الانتظار",
"session.followupDock.sendNow": "إرسال الآن", "session.followupDock.sendNow": "إرسال الآن",
"session.followupDock.edit": "تحرير", "session.followupDock.edit": "تحرير",
"session.followupDock.collapse": "طي الرسائل المنتظرة", "session.followupDock.collapse": "طي الرسائل المنتظرة",
"session.followupDock.expand": "توسيع الرسائل المنتظرة", "session.followupDock.expand": "توسيع الرسائل المنتظرة",
"session.revertDock.summary.one": "{{count}} رسالة تم التراجع عنها", "session.revertDock.summary.one": "{{count}} رسالة تم التراجع عنها",
"session.revertDock.summary.zero": "عدد الرسائل التي تم التراجع عنها: {{count}}",
"session.revertDock.summary.two": "عدد الرسائل التي تم التراجع عنها: {{count}}",
"session.revertDock.summary.few": "{{count}} رسائل تم التراجع عنها",
"session.revertDock.summary.many": "{{count}} رسالةً تم التراجع عنها",
"session.revertDock.summary.other": "{{count}} رسائل تم التراجع عنها", "session.revertDock.summary.other": "{{count}} رسائل تم التراجع عنها",
"session.revertDock.collapse": "طي الرسائل التي تم التراجع عنها", "session.revertDock.collapse": "طي الرسائل التي تم التراجع عنها",
"session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها", "session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها",
@@ -793,19 +649,12 @@ export const dict = {
"terminal.close": "إغلاق المحطة الطرفية", "terminal.close": "إغلاق المحطة الطرفية",
"terminal.connectionLost.title": "فقد الاتصال", "terminal.connectionLost.title": "فقد الاتصال",
"terminal.connectionLost.description": "انقطع اتصال المحطة الطرفية. يمكن أن يحدث هذا عند إعادة تشغيل الخادم.", "terminal.connectionLost.description": "انقطع اتصال المحطة الطرفية. يمكن أن يحدث هذا عند إعادة تشغيل الخادم.",
"terminal.connectTicket.csrfError": "رُفضت تذكرة اتصال PTY بسبب فحوصات الأصل أو CSRF. تحقق من إعداد CORS للخادم.",
"terminal.connectTicket.statusError": "فشلت تذكرة اتصال PTY بالحالة {{status}}",
"titlebar.update": "تحديث",
"titlebar.updateVersion": "تحديث {{version}}",
"common.closeTab": "إغلاق علامة التبويب", "common.closeTab": "إغلاق علامة التبويب",
"common.dismiss": "إهمال", "common.dismiss": "رفض",
"common.requestFailed": "فشل الطلب", "common.requestFailed": "فشل الطلب",
"common.moreOptions": "مزيد من الخيارات", "common.moreOptions": "مزيد من الخيارات",
"common.learnMore": "اطّلع على المزيد", "common.learnMore": "اعرف المزيد",
"common.rename": "إعادة تسمية", "common.rename": "إعادة تسمية",
"common.export": "تصدير",
"common.reset": "إعادة تعيين", "common.reset": "إعادة تعيين",
"common.archive": "أرشفة", "common.archive": "أرشفة",
"common.delete": "حذف", "common.delete": "حذف",
@@ -822,7 +671,7 @@ export const dict = {
"sidebar.gettingStarted.title": "البدء", "sidebar.gettingStarted.title": "البدء",
"sidebar.gettingStarted.line1": "يتضمن OpenCode نماذج مجانية حتى تتمكن من البدء فورًا.", "sidebar.gettingStarted.line1": "يتضمن OpenCode نماذج مجانية حتى تتمكن من البدء فورًا.",
"sidebar.gettingStarted.line2": "قم بتوصيل أي موفر لاستخدام النماذج، بما في ذلك Claude و GPT و Gemini وما إلى ذلك.", "sidebar.gettingStarted.line2": "قم بتوصيل أي موفر لاستخدام النماذج، بما في ذلك Claude و GPT و Gemini وما إلى ذلك.",
"sidebar.project.recentSessions": "الجلسات الأخيرة", "sidebar.project.recentSessions": "الجلسات الحديثة",
"sidebar.project.viewAllSessions": "عرض جميع الجلسات", "sidebar.project.viewAllSessions": "عرض جميع الجلسات",
"sidebar.project.clearNotifications": "مسح الإشعارات", "sidebar.project.clearNotifications": "مسح الإشعارات",
"sidebar.empty.title": "لا توجد مشاريع مفتوحة", "sidebar.empty.title": "لا توجد مشاريع مفتوحة",
@@ -841,7 +690,7 @@ export const dict = {
"settings.general.section.updates": "التحديثات", "settings.general.section.updates": "التحديثات",
"settings.general.section.sounds": "المؤثرات الصوتية", "settings.general.section.sounds": "المؤثرات الصوتية",
"settings.general.section.feed": "الخلاصة", "settings.general.section.feed": "الخلاصة",
"settings.general.section.display": "العرض", "settings.general.section.display": "شاشة العرض",
"settings.general.row.language.title": "اللغة", "settings.general.row.language.title": "اللغة",
"settings.general.row.language.description": "تغيير لغة العرض لـ OpenCode", "settings.general.row.language.description": "تغيير لغة العرض لـ OpenCode",
"settings.general.row.shell.title": "Shell المحطة الطرفية", "settings.general.row.shell.title": "Shell المحطة الطرفية",
@@ -878,11 +727,10 @@ export const dict = {
"settings.general.row.mobileTitlebarBottom.title": "التنقل السفلي", "settings.general.row.mobileTitlebarBottom.title": "التنقل السفلي",
"settings.general.row.mobileTitlebarBottom.description": "settings.general.row.mobileTitlebarBottom.description":
"وضع شريط العنوان وعلامات تبويب الجلسات أسفل الشاشة على الأجهزة المحمولة", "وضع شريط العنوان وعلامات تبويب الجلسات أسفل الشاشة على الأجهزة المحمولة",
"settings.general.row.showCustomAgents.title": "إظهار الوكيل", "settings.general.row.showCustomAgents.title": "الوكلاء المخصصون",
"settings.general.row.showCustomAgents.description": "settings.general.row.showCustomAgents.description": "إظهار محدد الوكلاء في محرر الرسائل",
"التبديل بين الوكلاء في محرر الرسائل. عند إخفائه، يُستخدم وكيل Build افتراضيًا.", "settings.general.row.reasoningSummaries.title": "إظهار ملخصات الاستنتاج",
"settings.general.row.reasoningSummaries.title": "إظهار ملخصات الاستدلال", "settings.general.row.reasoningSummaries.description": "عرض ملخصات استنتاج النموذج في الشريط الزمني",
"settings.general.row.reasoningSummaries.description": "عرض ملخصات استدلال النموذج في الشريط الزمني",
"settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell", "settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
"settings.general.row.shellToolPartsExpanded.description": "settings.general.row.shellToolPartsExpanded.description":
"إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني", "إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني",
@@ -895,12 +743,11 @@ export const dict = {
"استخدم علامات التبويب الجديدة وتخطيط الصفحة الرئيسية. يمكنك التبديل بين التخطيطات لفترة محدودة.", "استخدم علامات التبويب الجديدة وتخطيط الصفحة الرئيسية. يمكنك التبديل بين التخطيطات لفترة محدودة.",
"settings.general.row.newInterfaceNotice.title": "أنت تستخدم الآن التخطيط الجديد", "settings.general.row.newInterfaceNotice.title": "أنت تستخدم الآن التخطيط الجديد",
"settings.general.row.newInterfaceNotice.description": "التخطيط السابق لم يعد متاحًا", "settings.general.row.newInterfaceNotice.description": "التخطيط السابق لم يعد متاحًا",
"settings.general.row.newInterfaceNotice.dismiss": "إهمال", "settings.general.row.newInterfaceNotice.dismiss": "رفض",
"settings.general.row.pinchZoom.title": "التكبير بالمقاربة والمباعدة", "settings.general.row.pinchZoom.title": "التكبير بإيماءة القرص",
"settings.general.row.pinchZoom.description": "settings.general.row.pinchZoom.description": "السماح بإيماءة القرص على لوحة اللمس وإيماءة Ctrl-scroll للتكبير",
"السماح بإيماءات مقاربة الأصابع ومباعدتها على لوحة التعقب والتمرير مع الضغط على Ctrl للتكبير",
"settings.general.row.wayland.title": "استخدام Wayland الأصلي", "settings.general.row.wayland.title": "استخدام Wayland الأصلي",
"settings.general.row.wayland.description": "تعطيل الرجوع إلى X11 على Wayland. يتطلب إعادة التشغيل.", "settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
"على Linux مع شاشات بمعدلات تحديث مختلطة، يمكن أن يكون Wayland الأصلي أكثر استقرارًا.", "على Linux مع شاشات بمعدلات تحديث مختلطة، يمكن أن يكون Wayland الأصلي أكثر استقرارًا.",
"settings.general.row.releaseNotes.title": "ملاحظات الإصدار", "settings.general.row.releaseNotes.title": "ملاحظات الإصدار",
@@ -913,7 +760,7 @@ export const dict = {
"settings.updates.action.checking": "جارٍ التحقق...", "settings.updates.action.checking": "جارٍ التحقق...",
"settings.updates.action.downloading": "جارٍ التنزيل...", "settings.updates.action.downloading": "جارٍ التنزيل...",
"settings.updates.action.installing": "جارٍ التثبيت...", "settings.updates.action.installing": "جارٍ التثبيت...",
"settings.updates.toast.latest.title": "لديك أحدث إصدار", "settings.updates.toast.latest.title": "أنت على آخر إصدار",
"settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.", "settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.",
"sound.option.none": "بلا", "sound.option.none": "بلا",
"sound.option.alert01": "تنبيه 01", "sound.option.alert01": "تنبيه 01",
@@ -975,17 +822,17 @@ export const dict = {
"settings.general.sounds.errors.description": "تشغيل صوت عند حدوث خطأ", "settings.general.sounds.errors.description": "تشغيل صوت عند حدوث خطأ",
"settings.shortcuts.title": "اختصارات لوحة المفاتيح", "settings.shortcuts.title": "اختصارات لوحة المفاتيح",
"settings.shortcuts.reset.button": "إعادة التعيين إلى الافتراضيات", "settings.shortcuts.reset.button": "إعادة التعيين إلى الافتراضيات",
"settings.shortcuts.reset.toast.title": "أُعيد تعيين الاختصارات", "settings.shortcuts.reset.toast.title": "تم إعادة تعيين الاختصارات",
"settings.shortcuts.reset.toast.description": "أُعيد تعيين اختصارات لوحة المفاتيح إلى الإعدادات الافتراضية.", "settings.shortcuts.reset.toast.description": "تم إعادة تعيين اختصارات لوحة المفاتيح إلى الافتراضيات.",
"settings.shortcuts.conflict.title": "الاختصار قيد الاستخدام بالفعل", "settings.shortcuts.conflict.title": "الاختصار قيد الاستخدام بالفعل",
"settings.shortcuts.conflict.description": "الاختصار {{keybind}} مخصص بالفعل لـ {{titles}}.", "settings.shortcuts.conflict.description": "{{keybind}} معين بالفعل لـ {{titles}}.",
"settings.shortcuts.unassigned": "غير مخصص", "settings.shortcuts.unassigned": "غير معين",
"settings.shortcuts.pressKeys": "اضغط على المفاتيح", "settings.shortcuts.pressKeys": "اضغط على المفاتيح",
"settings.shortcuts.search.placeholder": "البحث في الاختصارات", "settings.shortcuts.search.placeholder": "البحث في الاختصارات",
"settings.shortcuts.search.empty": "لم يتم العثور على اختصارات", "settings.shortcuts.search.empty": "لم يتم العثور على اختصارات",
"settings.shortcuts.group.general": "عام", "settings.shortcuts.group.general": "عام",
"settings.shortcuts.group.session": "جلسة", "settings.shortcuts.group.session": "جلسة",
"settings.shortcuts.group.navigation": "التنقل", "settings.shortcuts.group.navigation": "تصفح",
"settings.shortcuts.group.modelAndAgent": "النموذج والوكيل", "settings.shortcuts.group.modelAndAgent": "النموذج والوكيل",
"settings.shortcuts.group.terminal": "المحطة الطرفية", "settings.shortcuts.group.terminal": "المحطة الطرفية",
"settings.shortcuts.group.prompt": "موجه", "settings.shortcuts.group.prompt": "موجه",
@@ -1010,18 +857,19 @@ export const dict = {
"settings.permissions.description": "تحكم في الأدوات التي يمكن للخادم استخدامها بشكل افتراضي.", "settings.permissions.description": "تحكم في الأدوات التي يمكن للخادم استخدامها بشكل افتراضي.",
"settings.permissions.section.tools": "الأدوات", "settings.permissions.section.tools": "الأدوات",
"settings.permissions.toast.updateFailed.title": "فشل تحديث الأذونات", "settings.permissions.toast.updateFailed.title": "فشل تحديث الأذونات",
"settings.permissions.action.allow": "السماح", "settings.permissions.action.allow": "سماح",
"settings.permissions.action.ask": "طلب الإذن", "settings.permissions.action.ask": "سؤال",
"settings.permissions.action.deny": "الرفض", "settings.permissions.action.deny": "رفض",
"settings.permissions.tool.read.title": "قراءة", "settings.permissions.tool.read.title": "قراءة",
"settings.permissions.tool.read.description": "قراءة ملف (يطابق مسار الملف)", "settings.permissions.tool.read.description": "قراءة ملف (يطابق مسار الملف)",
"settings.permissions.tool.edit.title": "تحرير", "settings.permissions.tool.edit.title": "تحرير",
"settings.permissions.tool.edit.description": "تعديل الملفات، بما في ذلك عمليات التحرير والكتابة وتطبيق الرقع", "settings.permissions.tool.edit.description":
"تعديل الملفات، بما في ذلك التحرير والكتابة والتصحيحات والتحرير المتعدد",
"settings.permissions.tool.glob.title": "Glob", "settings.permissions.tool.glob.title": "Glob",
"settings.permissions.tool.glob.description": "مطابقة الملفات باستخدام أنماط glob", "settings.permissions.tool.glob.description": "مطابقة الملفات باستخدام أنماط glob",
"settings.permissions.tool.grep.title": "Grep", "settings.permissions.tool.grep.title": "Grep",
"settings.permissions.tool.grep.description": "البحث في محتويات الملف باستخدام التعبيرات العادية", "settings.permissions.tool.grep.description": "البحث في محتويات الملف باستخدام التعبيرات العادية",
"settings.permissions.tool.list.title": "سرد", "settings.permissions.tool.list.title": "قائمة",
"settings.permissions.tool.list.description": "سرد الملفات داخل دليل", "settings.permissions.tool.list.description": "سرد الملفات داخل دليل",
"settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.title": "Bash",
"settings.permissions.tool.bash.description": "تشغيل أوامر shell", "settings.permissions.tool.bash.description": "تشغيل أوامر shell",
@@ -1039,7 +887,7 @@ export const dict = {
"settings.permissions.tool.websearch.description": "البحث في الويب", "settings.permissions.tool.websearch.description": "البحث في الويب",
"settings.permissions.tool.external_directory.title": "دليل خارجي", "settings.permissions.tool.external_directory.title": "دليل خارجي",
"settings.permissions.tool.external_directory.description": "الوصول إلى الملفات خارج دليل المشروع", "settings.permissions.tool.external_directory.description": "الوصول إلى الملفات خارج دليل المشروع",
"settings.permissions.tool.doom_loop.title": "حلقة مفرغة", "settings.permissions.tool.doom_loop.title": "حلقة الموت",
"settings.permissions.tool.doom_loop.description": "اكتشاف استدعاءات الأدوات المتكررة بمدخلات متطابقة", "settings.permissions.tool.doom_loop.description": "اكتشاف استدعاءات الأدوات المتكررة بمدخلات متطابقة",
"session.delete.failed.title": "فشل حذف الجلسة", "session.delete.failed.title": "فشل حذف الجلسة",
"session.delete.title": "حذف الجلسة", "session.delete.title": "حذف الجلسة",
@@ -1047,7 +895,7 @@ export const dict = {
"session.delete.button": "حذف الجلسة", "session.delete.button": "حذف الجلسة",
"workspace.new": "مساحة عمل جديدة", "workspace.new": "مساحة عمل جديدة",
"workspace.type.local": "محلي", "workspace.type.local": "محلي",
"workspace.type.sandbox": "بيئة معزولة", "workspace.type.sandbox": "صندوق رمل",
"workspace.create.failed.title": "فشل إنشاء مساحة العمل", "workspace.create.failed.title": "فشل إنشاء مساحة العمل",
"workspace.delete.failed.title": "فشل حذف مساحة العمل", "workspace.delete.failed.title": "فشل حذف مساحة العمل",
"workspace.resetting.title": "إعادة تعيين مساحة العمل", "workspace.resetting.title": "إعادة تعيين مساحة العمل",
@@ -1057,7 +905,7 @@ export const dict = {
"workspace.reset.success.description": "مساحة العمل تطابق الآن الفرع الافتراضي.", "workspace.reset.success.description": "مساحة العمل تطابق الآن الفرع الافتراضي.",
"workspace.error.stillPreparing": "مساحة العمل لا تزال قيد الإعداد", "workspace.error.stillPreparing": "مساحة العمل لا تزال قيد الإعداد",
"workspace.status.checking": "التحقق من التغييرات غير المدمجة...", "workspace.status.checking": "التحقق من التغييرات غير المدمجة...",
"workspace.status.error": "تعذر التحقق من حالة Git.", "workspace.status.error": "تعذر التحقق من حالة git.",
"workspace.status.clean": "لم يتم اكتشاف تغييرات غير مدمجة.", "workspace.status.clean": "لم يتم اكتشاف تغييرات غير مدمجة.",
"workspace.status.dirty": "تم اكتشاف تغييرات غير مدمجة في مساحة العمل هذه.", "workspace.status.dirty": "تم اكتشاف تغييرات غير مدمجة في مساحة العمل هذه.",
"workspace.delete.title": "حذف مساحة العمل", "workspace.delete.title": "حذف مساحة العمل",
@@ -1073,7 +921,7 @@ export const dict = {
"common.open": "فتح", "common.open": "فتح",
"dialog.releaseNotes.action.getStarted": "البدء", "dialog.releaseNotes.action.getStarted": "البدء",
"dialog.releaseNotes.action.next": "التالي", "dialog.releaseNotes.action.next": "التالي",
"dialog.releaseNotes.action.hideFuture": "عدم إظهارها مستقبلاً", "dialog.releaseNotes.action.hideFuture": "عدم إظهار هذا في المستقبل",
"dialog.releaseNotes.media.alt": "معاينة الإصدار", "dialog.releaseNotes.media.alt": "معاينة الإصدار",
"toast.project.reloadFailed.title": "فشل في إعادة تحميل {{project}}", "toast.project.reloadFailed.title": "فشل في إعادة تحميل {{project}}",
"error.server.invalidConfiguration": "تكوين غير صالح", "error.server.invalidConfiguration": "تكوين غير صالح",
@@ -1083,25 +931,22 @@ export const dict = {
"common.time.hoursAgo.short": "قبل {{count}} س", "common.time.hoursAgo.short": "قبل {{count}} س",
"common.time.daysAgo.short": "قبل {{count}} ي", "common.time.daysAgo.short": "قبل {{count}} ي",
"settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك", "settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك",
"settings.providers.custom.description": "أضف موفرًا متوافقًا مع OpenAI باستخدام عنوان URL الأساسي.", "settings.providers.custom.description": "أضف مزود متوافق مع OpenAI بواسطة عنوان URL الأساسي.",
"app.server.unreachable": "تعذر الوصول إلى {{server}}", "app.server.unreachable": "تعذر الوصول إلى {{server}}",
"app.server.retrying": "جارٍ إعادة المحاولة تلقائيًا...", "app.server.retrying": "جاري إعادة المحاولة تلقائيًا...",
"app.server.otherServers": "خوادم أخرى", "app.server.otherServers": "خوادم أخرى",
"dialog.server.add.usernamePlaceholder": "اسم المستخدم", "dialog.server.add.usernamePlaceholder": "اسم المستخدم",
"dialog.server.add.passwordPlaceholder": "كلمة المرور", "dialog.server.add.passwordPlaceholder": "كلمة المرور",
"server.row.noUsername": "لا يوجد اسم مستخدم", "server.row.noUsername": "لا يوجد اسم مستخدم",
"session.review.noVcs.createGit.title": "إنشاء مستودع Git", "session.review.noVcs.createGit.title": "إنشاء مستودع Git",
"session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع", "session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع",
"session.review.noVcs.createGit.actionLoading": "جارٍ إنشاء مستودع Git...", "session.review.noVcs.createGit.actionLoading": "جاري إنشاء مستودع Git...",
"session.review.noVcs.createGit.action": "إنشاء مستودع Git", "session.review.noVcs.createGit.action": "إنشاء مستودع Git",
"session.todo.progress": "تم إكمال {{done}} من {{total}} مهام", "session.todo.progress": "تم إكمال {{done}} من {{total}} مهام",
"session.question.progress": "{{current}} من {{total}} أسئلة", "session.question.progress": "{{current}} من {{total}} أسئلة",
"session.header.open.finder": "Finder", "session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "مستكشف الملفات", "session.header.open.fileExplorer": "مستكشف الملفات",
"session.header.reveal.finder": "إظهار في Finder",
"session.header.reveal.fileExplorer": "إظهار في مستكشف الملفات",
"session.header.reveal.containingFolder": "فتح المجلد الحاوي",
"session.header.open.fileManager": "مدير الملفات", "session.header.open.fileManager": "مدير الملفات",
"session.header.open.app.vscode": "VS Code", "session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor", "session.header.open.app.cursor": "Cursor",
@@ -1119,9 +964,9 @@ export const dict = {
"debugBar.ariaLabel": "تشخيص أداء التطوير", "debugBar.ariaLabel": "تشخيص أداء التطوير",
"debugBar.na": "غير متاح", "debugBar.na": "غير متاح",
"debugBar.nav.label": "NAV", "debugBar.nav.label": "NAV",
"debugBar.nav.tip": "آخر انتقال مكتمل للمسار يتضمن صفحة جلسة، مُقاسًا من بدء التوجيه حتى أول رسم بعد استقراره.", "debugBar.nav.tip": "آخر انتقال مكتمل للمسار يمس صفحة جلسة، مُقاسًا من بدء التوجيه حتى أول رسم بعد استقراره.",
"debugBar.fps.label": "FPS", "debugBar.fps.label": "FPS",
"debugBar.fps.tip": "متوسط متحرك لمعدل الإطارات في الثانية خلال آخر 5 ثوانٍ.", "debugBar.fps.tip": "الإطارات المتجددة في الثانية خلال آخر 5 ثوانٍ.",
"debugBar.frame.label": "FRAME", "debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "أسوأ وقت للإطار خلال آخر 5 ثوانٍ.", "debugBar.frame.tip": "أسوأ وقت للإطار خلال آخر 5 ثوانٍ.",
"debugBar.jank.label": "JANK", "debugBar.jank.label": "JANK",
@@ -1133,18 +978,10 @@ export const dict = {
"debugBar.inp.label": "INP", "debugBar.inp.label": "INP",
"debugBar.inp.tip": "مدة التفاعل التقريبية خلال آخر 5 ثوانٍ. هذا يشبه INP، وليس Web Vitals INP الرسمي.", "debugBar.inp.tip": "مدة التفاعل التقريبية خلال آخر 5 ثوانٍ. هذا يشبه INP، وليس Web Vitals INP الرسمي.",
"debugBar.cls.label": "CLS", "debugBar.cls.label": "CLS",
"debugBar.cls.tip": "التحول التخطيطي التراكمي منذ تشغيل التطبيق الحالي.", "debugBar.cls.tip": "التحول التخطيطي التراكمي لعمر التطبيق الحالي.",
"debugBar.mem.label": "MEM", "debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "كومة JS المستخدمة مقابل حد الكومة. Chromium فقط.", "debugBar.mem.tipUnavailable": "كومة JS المستخدمة مقابل حد الكومة. Chromium فقط.",
"debugBar.mem.tip": "كومة JS المستخدمة مقابل حد الكومة. {{used}} من {{limit}}.", "debugBar.mem.tip": "كومة JS المستخدمة مقابل حد الكومة. {{used}} من {{limit}}.",
"debugBar.focus.label": "FOCUS",
"debugBar.focus.tip": "فرض أنماط التركيز على جميع العناصر التفاعلية",
"debugBar.focus.on": "تشغيل",
"debugBar.focus.off": "إيقاف",
"debugBar.direction.label": "DIR",
"debugBar.direction.tip": "فرض اتجاه التخطيط على التطبيق بالكامل دون تغيير اللغة المحددة",
"debugBar.direction.ltr": "LTR",
"debugBar.direction.rtl": "RTL",
"common.key.ctrl": "Ctrl", "common.key.ctrl": "Ctrl",
"common.key.alt": "Alt", "common.key.alt": "Alt",
"common.key.shift": "Shift", "common.key.shift": "Shift",
@@ -1160,7 +997,7 @@ export const dict = {
"common.key.pageDown": "Page Down", "common.key.pageDown": "Page Down",
"common.key.insert": "Insert", "common.key.insert": "Insert",
"common.unknown": "غير معروف", "common.unknown": "غير معروف",
"error.page.circular": "[مرجع دائري]", "error.page.circular": "[دائري]",
"error.serverSDK.noServerAvailable": "لا يوجد خادم متاح", "error.serverSDK.noServerAvailable": "لا يوجد خادم متاح",
"error.serverSDK.serverNotAvailable": "الخادم غير متاح", "error.serverSDK.serverNotAvailable": "الخادم غير متاح",
"error.childStore.persistedCacheCreateFailed": "فشل إنشاء ذاكرة التخزين المؤقت الدائمة", "error.childStore.persistedCacheCreateFailed": "فشل إنشاء ذاكرة التخزين المؤقت الدائمة",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More