Compare commits

..

23 Commits

Author SHA1 Message Date
Dax Raad 0c34e6669f feat(tui): continue pending work after interrupt 2026-08-10 13:52:13 +00:00
Brendan Allan c7852ef0fd refactor(app): establish v2 session controller (#39233) 2026-08-10 08:50:24 +00:00
Brendan Allan 60cd82aec8 refactor(app): extract session timeline controller (#39232) 2026-08-10 15:49:24 +08:00
Brendan Allan c29968bf08 refactor(app): extract provider connection controller (#39230) 2026-08-10 07:20:57 +00:00
Brendan Allan b5b87deaa2 refactor(app): split server management controllers (#39229) 2026-08-10 06:16:06 +00:00
opencode-agent[bot] 5053c1bfdd chore: generate 2026-08-10 05:55:03 +00:00
Luke Parker d97aca139f fix(cli): keep OTLP failures best effort (#41479) 2026-08-10 15:53:52 +10:00
opencode-agent[bot] b2f0f45c9e chore: generate 2026-08-10 04:23:14 +00:00
Aiden Cline f64b024f46 fix(core): resolve models.dev URL env vars (#41474) 2026-08-09 23:22:01 -05:00
opencode-agent[bot] ec11d6ed03 chore: generate 2026-08-10 03:37:40 +00:00
opencode-agent[bot] 0dcedc199c chore: merge dev into v2 (#41460)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-10 03:36:24 +00:00
opencode-agent[bot] e9c55a15d4 chore: generate 2026-08-10 03:01:32 +00:00
Aiden Cline 9bc4eea79a refactor(core): replace integration prompts with forms (#40997) 2026-08-09 22:00:18 -05:00
Dax Raad 68b2b08853 fix(server): remove duplicate observability layer 2026-08-09 20:33:30 -04:00
opencode-agent[bot] 7bd6d002bb chore: generate 2026-08-10 00:20:36 +00:00
Dax Raad ab872ffb0e fix(ai): reduce message lowering trace overhead 2026-08-09 20:18:30 -04:00
Kit Langton 84fd347afa fix(codegen): write prettier-stable generated manifests (#41343) 2026-08-08 20:52:04 -04:00
opencode-agent[bot] e8f215bfbc chore: generate 2026-08-09 00:29:22 +00:00
opencode-agent[bot] 445af9ce70 docs: fix install command rendering (#41340)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
2026-08-08 20:28:07 -04:00
Kit Langton bc51baa9a4 fix(tui): align session tab shortcut labels (#41308) 2026-08-08 20:19:51 -04:00
Kit Langton ff0a0b0786 fix(tui): isolate lifecycle and theme tests (#41310) 2026-08-08 20:18:24 -04:00
Kit Langton 4eff0ee2db fix(core): flush plugin reload generations (#41309) 2026-08-08 20:18:21 -04:00
Kit Langton cc0061f88b fix(core): update recorded prompt cache key (#41307) 2026-08-08 20:18:17 -04:00
638 changed files with 82181 additions and 9765 deletions
+2 -6
View File
@@ -281,7 +281,6 @@ jobs:
build-electron: build-electron:
needs: needs:
- build-cli
- version - version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
continue-on-error: false continue-on-error: false
@@ -378,16 +377,14 @@ 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 }}
@@ -405,8 +402,7 @@ 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_LINK: ${{ secrets.APPLE_CERTIFICATE }} CSC_KEYCHAIN: build.keychain
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 }}
@@ -0,0 +1,63 @@
---
name: rtl-aware-development
description: OpenCode Desktop should be RTL-aware. Use when implementing or reviewing RTL/LTR behavior in the web app, desktop app, CSS, menus, scrolling, resizing, icons, mixed-direction text, or Electron title bars.
---
# RTL-Aware Development
Treat direction as independent from language. Test English in both directions as well as real RTL and mixed-script content.
## Guidelines
- Set `lang` and `dir` on the document, and propagate direction through component providers used by portaled menus and popovers. Do not change the selected locale merely to force RTL.
- Keep DOM and focus order semantic. Flexbox and Grid already follow `dir`; do not add `row-reverse`, CSS `order`, or reversed markup just to mirror a layout.
- Prefer logical CSS for semantic layout. Reserve physical coordinates for pointer positions, canvas geometry, native window controls, and other genuinely physical placement.
```css
/* Avoid */
padding-left: 12px;
right: 0;
border-right: 1px solid;
text-align: left;
/* Prefer */
padding-inline-start: 12px;
inset-inline-end: 0;
border-inline-end: 1px solid;
text-align: start;
```
- Isolate mixed-direction text. Use `dir="auto"` or `<bdi>` for unknown text; keep code, URLs, IDs, and filesystem paths LTR without forcing the surrounding component LTR.
```html
<span class="file-row"><bdi dir="auto">README.md</bdi></span> <bdi dir="ltr"><code>C:\src\app.ts</code></bdi>
```
- Mirror directional meaning, not every image. Back/forward, previous/next, disclosure, indentation, and directional progress may need mirroring. Do not mirror brands, clocks, media controls, charts, or text. Reverse physical gradients, `translateX`, SVG transforms, and animation deltas explicitly.
- Map interactions through direction. `clientX` remains physical; resizing a logical edge needs an RTL-aware delta. Logical previous/next keyboard controls may swap ArrowLeft/ArrowRight. Follow the relevant WAI-ARIA widget pattern.
- Do not assume LTR scrolling. RTL `scrollLeft` can start at `0` and become negative. Prefer `scrollIntoView({ inline: "nearest" })` or a tested direction-normalizing helper.
- For Electron title bars, prefer native caption controls and use `titleBarOverlay` plus `env(titlebar-area-*)` for the safe content rectangle. Keep Windows/macOS native-control avoidance and `trafficLightPosition` physical; keep app navigation inside that rectangle logical. Mark interactive titlebar children `app-region: no-drag`.
- Verify behavior, not screenshots alone. Check computed styles, pseudo-element geometry, hit zones, focus order, keyboard behavior, submenu direction, zoom/scaling, and both LTR and RTL scroll endpoints.
## Test Matrix
- English + LTR
- English + forced RTL
- A real RTL locale + RTL
- Mixed RTL/LTR content, long labels, numbers, code, and paths
- Keyboard, pointer resize, scrolling, menus/submenus, and Electron titlebar controls in both directions
## References
- [RTL Styling 101, Ahmad Shadeed](https://rtlstyling.com/posts/rtl-styling/)
- [CSS-Tricks: RTL Styling 101](https://css-tricks.com/rtl-styling-101/)
- [CSS-Tricks: CSS Logical Properties and Values](https://css-tricks.com/css-logical-properties-and-values/)
- [W3C: Structural markup and right-to-left text](https://www.w3.org/International/questions/qa-html-dir)
- [W3C: Inline bidirectional markup](https://www.w3.org/International/articles/inline-bidi-markup/)
- [MDN: CSS logical properties and values](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Logical_properties_and_values)
- [MDN: `dir`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir)
- [MDN: `scrollLeft`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft)
- [web.dev: Logical properties](https://web.dev/learn/css/logical-properties/)
- [Electron: Custom title bar](https://www.electronjs.org/docs/latest/tutorial/custom-title-bar)
- [WAI-ARIA: Window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
- [Kobalte: I18n Provider](https://kobalte.dev/docs/core/components/i18n-provider/)
-27
View File
@@ -252,33 +252,6 @@ These are not strictly enforced, they are just general guidelines:
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly. 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:
+39 -43
View File
@@ -53,7 +53,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.18.8", "version": "1.18.15",
"dependencies": { "dependencies": {
"@corvu/drawer": "catalog:", "@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0", "@dnd-kit/abstract": "0.5.0",
@@ -220,7 +220,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.18.8", "version": "1.18.15",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -256,7 +256,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.18.8", "version": "1.18.15",
"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",
@@ -283,7 +283,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.18.8", "version": "1.18.15",
"dependencies": { "dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-core": "workspace:*",
@@ -300,7 +300,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.18.8", "version": "1.18.15",
"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",
@@ -324,7 +324,7 @@
}, },
"packages/console/support": { "packages/console/support": {
"name": "@opencode-ai/console-support", "name": "@opencode-ai/console-support",
"version": "1.18.8", "version": "1.18.15",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-core": "workspace:*",
@@ -388,7 +388,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.11.1", "gitlab-ai-provider": "6.12.1",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
"htmlparser2": "8.0.2", "htmlparser2": "8.0.2",
@@ -424,16 +424,16 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.18.8", "version": "1.18.15",
"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",
@@ -478,7 +478,7 @@
}, },
"packages/effect-drizzle-sqlite": { "packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite", "name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.18.8", "version": "1.18.15",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -492,7 +492,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.18.8", "version": "1.18.15",
"dependencies": { "dependencies": {
"@hono/standard-validator": "catalog:", "@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -525,7 +525,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.18.8", "version": "1.18.15",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -541,7 +541,7 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.18.8", "version": "1.18.15",
"dependencies": { "dependencies": {
"@effect/platform-node-shared": "4.0.0-beta.101", "@effect/platform-node-shared": "4.0.0-beta.101",
}, },
@@ -587,7 +587,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.18.8", "version": "1.18.15",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@opencode-ai/ai": "workspace:*", "@opencode-ai/ai": "workspace:*",
@@ -703,7 +703,7 @@
}, },
"packages/session-ui": { "packages/session-ui": {
"name": "@opencode-ai/session-ui", "name": "@opencode-ai/session-ui",
"version": "1.18.8", "version": "1.18.15",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
@@ -718,15 +718,11 @@
"@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:",
@@ -739,7 +735,6 @@
"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:",
@@ -770,7 +765,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.18.8", "version": "1.18.15",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "1.18.5", "@opencode-ai/sdk": "1.18.5",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -783,7 +778,7 @@
}, },
"packages/stats/app": { "packages/stats/app": {
"name": "@opencode-ai/stats-app", "name": "@opencode-ai/stats-app",
"version": "1.18.8", "version": "1.18.15",
"dependencies": { "dependencies": {
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
@@ -817,7 +812,7 @@
}, },
"packages/stats/core": { "packages/stats/core": {
"name": "@opencode-ai/stats-core", "name": "@opencode-ai/stats-core",
"version": "1.18.8", "version": "1.18.15",
"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",
@@ -836,7 +831,7 @@
}, },
"packages/stats/server": { "packages/stats/server": {
"name": "@opencode-ai/stats-server", "name": "@opencode-ai/stats-server",
"version": "1.18.8", "version": "1.18.15",
"dependencies": { "dependencies": {
"@aws-sdk/client-firehose": "3.933.0", "@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
@@ -927,7 +922,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.18.8", "version": "1.18.15",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
@@ -943,7 +938,6 @@
"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",
@@ -953,6 +947,7 @@
"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": {
@@ -1024,7 +1019,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.18.8", "version": "1.18.15",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -1079,16 +1074,18 @@
"tree-sitter-bash", "tree-sitter-bash",
], ],
"patchedDependencies": { "patchedDependencies": {
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@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",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch", "effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.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",
"@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/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.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",
"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",
"@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:",
@@ -1146,7 +1143,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": "17.0.6", "marked": "18.0.7",
"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",
@@ -1156,6 +1153,7 @@
"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",
"string-width": "7.2.0", "string-width": "7.2.0",
"tailwindcss": "4.1.11", "tailwindcss": "4.1.11",
@@ -3654,7 +3652,7 @@
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="],
@@ -4192,7 +4190,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.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=="], "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=="],
"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=="],
@@ -4666,9 +4664,7 @@
"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@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], "marked": ["marked@18.0.7", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA=="],
"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=="],
@@ -5468,6 +5464,8 @@
"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=="],
@@ -6382,8 +6380,6 @@
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], "@opencode-ai/desktop/@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/session-ui/@opencode-ai/sdk": ["@opencode-ai/sdk@../app/vendor/opencode-ai-sdk-1.18.8-dev.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-C2nfk4x0sPINwE5V6DPkFSuH3PkUmKPWHPzxpXC1j+3Ui5hslLCWJbkk8WcOG1Lyt3C0+yp4ea64v/kmtYCO4w=="], "@opencode-ai/session-ui/@opencode-ai/sdk": ["@opencode-ai/sdk@../app/vendor/opencode-ai-sdk-1.18.8-dev.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-C2nfk4x0sPINwE5V6DPkFSuH3PkUmKPWHPzxpXC1j+3Ui5hslLCWJbkk8WcOG1Lyt3C0+yp4ea64v/kmtYCO4w=="],
@@ -6624,8 +6620,6 @@
"blume/katex": ["katex@0.17.0", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw=="], "blume/katex": ["katex@0.17.0", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw=="],
"blume/marked": ["marked@18.0.7", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA=="],
"blume/node-html-parser": ["node-html-parser@9.0.0", "", { "dependencies": { "css-select": "^5.1.0", "entities": "^8.0.0" } }, "sha512-MhdaHPyxnyYu/sf0TpiRvDnTrkum0UKHC7FdbDGIUQNlx3I7xzwXoyV0eMUMv/XU+lkJT1glOUzpDPq7b2p1Ew=="], "blume/node-html-parser": ["node-html-parser@9.0.0", "", { "dependencies": { "css-select": "^5.1.0", "entities": "^8.0.0" } }, "sha512-MhdaHPyxnyYu/sf0TpiRvDnTrkum0UKHC7FdbDGIUQNlx3I7xzwXoyV0eMUMv/XU+lkJT1glOUzpDPq7b2p1Ew=="],
"blume/react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], "blume/react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
@@ -6760,6 +6754,8 @@
"jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
@@ -6890,12 +6886,8 @@
"terser/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], "terser/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], "tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
"topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="], "tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="],
"tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
@@ -7718,6 +7710,8 @@
"blume/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "blume/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"blume/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"blume/node-html-parser/entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], "blume/node-html-parser/entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
"blume/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "blume/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@@ -7790,6 +7784,8 @@
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"motion/framer-motion/motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="], "motion/framer-motion/motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="],
"motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], "motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
+6 -2
View File
@@ -288,8 +288,12 @@ new sst.cloudflare.x.SolidStart("Console", {
server: { server: {
placement: { region: "aws:us-east-2" }, placement: { region: "aws:us-east-2" },
transform: { transform: {
worker: { worker: (args) => {
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }], args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [
...(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-RFek0QoEEjsgbqmTE/SxQAmPtYyzs0IPR2ugFn5Okrs=", "x86_64-linux": "sha256-uduwrM143NDSc+tXsi4lVVfoMll2a3BDHRUjuO7GB68=",
"aarch64-linux": "sha256-BmAxapY1YrAFn7mVq3/6A9+6Au5UIvSqBboHMkyJH3I=", "aarch64-linux": "sha256-6DUda78XdXY6DP86lIUkweSjys3iG4Y4mo1PiaNuXbg=",
"aarch64-darwin": "sha256-Sx3bGWQqLlgoa/RudJxanjSzhFRNklckT2ffnO2I5F4=", "aarch64-darwin": "sha256-AkJwfLULLZVwwz+XU1QcFUZoIS7oVPCn+n/MXEaxrqE=",
"x86_64-darwin": "sha256-CMOhiisHNowg06qadvgg4K+60zrynglwiT0qKYQ4NiA=" "x86_64-darwin": "sha256-hAxKGdiITTxQ2uujQt6prNjo3NxGAMMeo+9HlMWK6GU="
} }
} }
+4 -1
View File
@@ -78,7 +78,7 @@
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0", "get-east-asian-width": "1.6.0",
"luxon": "3.6.1", "luxon": "3.6.1",
"marked": "17.0.6", "marked": "18.0.7",
"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",
@@ -100,6 +100,7 @@
"@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"
} }
@@ -157,6 +158,8 @@
"effect": "catalog:" "effect": "catalog:"
}, },
"patchedDependencies": { "patchedDependencies": {
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.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",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
+1 -1
View File
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"
import * as path from "node:path" import * as path from "node:path"
const RECORDINGS_DIR = path.resolve(import.meta.dir, "..", "test", "fixtures", "recordings") const RECORDINGS_DIR = path.resolve(import.meta.dir, "..", "test", "fixtures", "recordings")
const MODELS_DEV_URL = "https://models.dev/api.json" const MODELS_DEV_URL = "https://models.opencode.ai/api.json"
type JsonRecord = Record<string, unknown> type JsonRecord = Record<string, unknown>
@@ -422,14 +422,12 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
// Tool results may carry structured text, images, and documents. Keep media as provider-native // Tool results may carry structured text, images, and documents. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string. // content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* ( const lowerToolResultContentItem = Effect.fnUntraced(function* (item: Tool.Content) {
item: Tool.Content,
) {
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }) return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
}) })
const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultContent")(function* (part: ToolResultPart) { const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart) {
// Text / json / error results stay as a string for backward compatibility // Text / json / error results stay as a string for backward compatibility
// with existing cassettes and provider expectations. // with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part) if (part.result.type !== "content") return ProviderShared.toolResultText(part)
+3 -3
View File
@@ -358,7 +358,7 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
return { type: "input_image" as const, image_url: media.dataUrl } return { type: "input_image" as const, image_url: media.dataUrl }
}) })
const lowerUserContent = Effect.fn("OpenResponses.lowerUserContent")(function* ( const lowerUserContent = Effect.fnUntraced(function* (
part: LLMRequest["messages"][number]["content"][number], part: LLMRequest["messages"][number]["content"][number],
request: LLMRequest, request: LLMRequest,
extension: Extension, extension: Extension,
@@ -370,7 +370,7 @@ const lowerUserContent = Effect.fn("OpenResponses.lowerUserContent")(function* (
// Tool results may carry structured text, images, and files. Keep media as provider-native // Tool results may carry structured text, images, and files. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string. // content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("OpenResponses.lowerToolResultContentItem")(function* ( const lowerToolResultContentItem = Effect.fnUntraced(function* (
item: Content, item: Content,
request: LLMRequest, request: LLMRequest,
extension: Extension, extension: Extension,
@@ -383,7 +383,7 @@ const lowerToolResultContentItem = Effect.fn("OpenResponses.lowerToolResultConte
) )
}) })
const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(function* ( const lowerToolResultOutput = Effect.fnUntraced(function* (
part: ToolResultPart, part: ToolResultPart,
request: LLMRequest, request: LLMRequest,
extension: Extension, extension: Extension,
@@ -1,71 +0,0 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("github-copilot")
// GitHub Copilot has no canonical public URL — callers (opencode, etc.) must
// supply `baseURL` explicitly.
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL: string
readonly endpoint?: "chat" | "responses"
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const shouldUseResponsesApi = (modelID: string | ModelID, endpoint?: LanguageModelOptions["endpoint"]) => {
if (endpoint) return endpoint === "responses"
const model = String(modelID)
const match = /^gpt-(\d+)/.exec(model)
if (!match) return false
return Number(match[1]) >= 5 && !model.startsWith("gpt-5-mini")
}
export const routes = [OpenAIResponses.route, OpenAIChat.route]
const chatRoute = OpenAIChat.route.with({ provider: id })
const responsesRoute = OpenAIResponses.route.with({ provider: id })
const defaults = (options: LanguageModelOptions) => {
const { apiKey: _, auth: _auth, baseURL: _baseURL, endpoint: _endpoint, ...rest } = options
return rest
}
const configuredResponsesRoute = (options: LanguageModelOptions) =>
responsesRoute.with({
endpoint: { baseURL: options.baseURL },
auth: AuthOptions.bearer(options, []),
})
const configuredChatRoute = (options: LanguageModelOptions) =>
chatRoute.with({
endpoint: { baseURL: options.baseURL },
auth: AuthOptions.bearer(options, []),
})
export const configure = (options: LanguageModelOptions) => {
const responsesRoute = configuredResponsesRoute(options)
const chatRoute = configuredChatRoute(options)
const responses = (modelID: string | ModelID) =>
responsesRoute
.with(withOpenAIOptions(modelID, defaults(options)))
.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) =>
chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model<OpenAIProviderOptionsInput>({ id: modelID })
return {
id,
model: (modelID: string | ModelID) =>
shouldUseResponsesApi(modelID, options.endpoint) ? responses(modelID) : chat(modelID),
responses,
chat,
configure,
}
}
export const provider = {
id,
configure,
}
-1
View File
@@ -5,7 +5,6 @@ export * as AmazonBedrockMantle from "./amazon-bedrock-mantle"
export * as Azure from "./azure" export * as Azure from "./azure"
export * as Cloudflare from "./cloudflare" export * as Cloudflare from "./cloudflare"
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare" export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
export * as GitHubCopilot from "./github-copilot"
export * as Google from "./google" export * as Google from "./google"
export * as GoogleVertex from "./google-vertex" export * as GoogleVertex from "./google-vertex"
export * as GoogleVertexChat from "./google-vertex-chat" export * as GoogleVertexChat from "./google-vertex-chat"
-5
View File
@@ -7,7 +7,6 @@ import * as Anthropic from "../src/providers/anthropic"
import * as AnthropicCompatible from "../src/providers/anthropic-compatible" import * as AnthropicCompatible from "../src/providers/anthropic-compatible"
import * as Azure from "../src/providers/azure" import * as Azure from "../src/providers/azure"
import * as Cloudflare from "../src/providers/cloudflare" import * as Cloudflare from "../src/providers/cloudflare"
import * as GitHubCopilot from "../src/providers/github-copilot"
import * as Google from "../src/providers/google" import * as Google from "../src/providers/google"
import * as GoogleVertex from "../src/providers/google-vertex" import * as GoogleVertex from "../src/providers/google-vertex"
import * as GoogleVertexChat from "../src/providers/google-vertex-chat" import * as GoogleVertexChat from "../src/providers/google-vertex-chat"
@@ -270,7 +269,3 @@ OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-
Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama") Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama")
// @ts-expect-error Cloudflare Workers AI model selectors only accept model ids. // @ts-expect-error Cloudflare Workers AI model selectors only accept model ids.
Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama", {}) Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama", {})
GitHubCopilot.configure({ baseURL: "https://copilot.test", apiKey: "copilot-key" }).model("gpt-4.1")
// @ts-expect-error GitHub Copilot model selectors only accept model ids.
GitHubCopilot.configure({ baseURL: "https://copilot.test", apiKey: "copilot-key" }).model("gpt-4.1", {})
-18
View File
@@ -10,7 +10,6 @@ import {
OpenRouter, OpenRouter,
XAI, XAI,
} from "@opencode-ai/ai/providers" } from "@opencode-ai/ai/providers"
import * as GitHubCopilot from "@opencode-ai/ai/providers/github-copilot"
import { import {
OpenAIChat, OpenAIChat,
OpenAICompatibleChat, OpenAICompatibleChat,
@@ -60,23 +59,6 @@ describe("public exports", () => {
expect(XAI.provider.chat).toBe(XAI.chat) expect(XAI.provider.chat).toBe(XAI.chat)
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses") expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("openai-compatible-chat") expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("openai-compatible-chat")
expect(
GitHubCopilot.configure({ baseURL: "https://api.githubcopilot.test", apiKey: "fixture" }).model,
).toBeFunction()
expect(
GitHubCopilot.configure({
baseURL: "https://api.githubcopilot.test",
apiKey: "fixture",
endpoint: "responses",
}).model("mai-code-1-flash-picker").route.id,
).toBe("openai-responses")
expect(
GitHubCopilot.configure({
baseURL: "https://api.githubcopilot.test",
apiKey: "fixture",
endpoint: "chat",
}).model("gpt-5").route.id,
).toBe("openai-chat")
}) })
test("protocol barrels expose supported low-level routes", () => { test("protocol barrels expose supported low-level routes", () => {
@@ -1,13 +0,0 @@
import { LLM } from "../../src"
import { GitHubCopilot } from "../../src/providers"
const model = GitHubCopilot.configure({ baseURL: "https://example.com" }).model("gpt-5")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningSummary: "auto" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Copilot reasoning summaries use the OpenAI union.
providerOptions: { openai: { reasoningSummary: "full" } },
})
+15
View File
@@ -19,6 +19,21 @@
- 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
@@ -0,0 +1,16 @@
## Required Reading
- Before writing, changing, or reviewing E2E tests, ALWAYS read and follow Playwright's official [Best Practices](https://playwright.dev/docs/best-practices), [Auto-waiting](https://playwright.dev/docs/actionability), and [Assertions](https://playwright.dev/docs/test-assertions) guides.
- Use the official [Locators](https://playwright.dev/docs/locators), [Network](https://playwright.dev/docs/network), and [Test Isolation](https://playwright.dev/docs/browser-contexts) guides when those concerns apply.
## Test Hygiene
- Test user-visible behavior with isolated, deterministic data and scoped, unique locators.
- Prefer role, label, text, and explicit test-contract locators. Do not use `.first()` or `.last()` merely to silence strictness errors.
- Use locator actions, Playwright auto-waiting, and web-first assertions for observable readiness and outcomes.
- NEVER use `waitForTimeout`, `setTimeout`, sleeps, animation-frame counts, or other wall-clock delays to synchronize a test. Wait for the specific UI state, request, response, event, or application outcome instead.
- Do not treat navigation, a network response, DOM attachment, or visibility alone as proof that asynchronously rendered UI is ready. Assert the state the next action actually requires.
- Register event and network waits before the action that triggers them.
- Do not retry state-changing actions. Retry idempotent readiness checks, then perform the action once and assert its outcome.
- Keep action and assertion timeouts adaptive. Do not use short timeouts as readiness probes or rely on retries to hide flakes.
- Assert exact outcomes and identities so stale state, duplicate rendering, and interactions with the wrong element cannot pass.
@@ -8,7 +8,7 @@ import { expect, type Page } from "@playwright/test"
import { Schema } from "effect" import { Schema } from "effect"
import { mockOpenCodeServer } from "../../utils/mock-server" import { mockOpenCodeServer } from "../../utils/mock-server"
import { installSseTransport } from "../../utils/sse-transport" import { installSseTransport } from "../../utils/sse-transport"
import { expectSessionTitle } from "../../utils/waits" import { expectSessionReady } from "../../utils/waits"
export const directory = "C:/OpenCode/TimelineStability" export const directory = "C:/OpenCode/TimelineStability"
export const projectID = "proj_timeline_stability" export const projectID = "proj_timeline_stability"
@@ -111,8 +111,9 @@ export async function setupTimeline(
active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" }, active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
decodeOptions, decodeOptions,
) )
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const transport = await installSseTransport<EventPayload>(page, { const transport = await installSseTransport<EventPayload>(page, {
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, server,
retry: input.eventRetry ?? 20, retry: input.eventRetry ?? 20,
}) })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
@@ -161,8 +162,8 @@ export async function setupTimeline(
}) })
} }
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionReady(page, { server, sessionID, title })
await transport.waitForConnection() await transport.waitForConnection()
await expectSessionTitle(page, title)
if (input.cpuRate && input.cpuRate > 1) { if (input.cpuRate && input.cpuRate > 1) {
const devtools = await page.context().newCDPSession(page) const devtools = await page.context().newCDPSession(page)
await devtools.send("Emulation.setCPUThrottlingRate", { rate: input.cpuRate }) await devtools.send("Emulation.setCPUThrottlingRate", { rate: input.cpuRate })
@@ -198,7 +199,9 @@ export async function setupTimeline(
) )
}, },
async waitForPart(partID: string) { async waitForPart(partID: string) {
await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible() const part = page.locator(`[data-timeline-part-id="${partID}"]`)
await expect(part).toHaveCount(1)
await expect(part).toBeVisible()
}, },
} }
} }
@@ -0,0 +1,60 @@
import { expect, test } from "@playwright/test"
import type { Page } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)
// The sixth project sits outside the five-item recent cap, so it is only reachable if the
// dialog hands every recent project to the list filter instead of a pre-truncated slice.
const OUTSIDE_CAP = "foxtrot-docs"
// Dialog rows carry data-directory-path; the sidebar project list does not, so this
// scopes assertions to the picker instead of matching the sidebar entry of the same name.
const rows = (page: Page) => page.locator("[data-directory-path]")
const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)
async function openProjectDialog(page: Page) {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
fileList: () => [],
findFiles: () => [],
})
await page.addInitScript((dirs) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
lastProject: {},
}),
)
}, worktrees)
await page.goto("/")
const add = page.getByRole("button", { name: "Add project" }).first()
await expectAppVisible(add)
await add.click()
await expect(rows(page)).toHaveCount(5)
return page.getByRole("textbox").last()
}
test("searches every recent project, not just the five most recent", async ({ page }) => {
const search = await openProjectDialog(page)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
await search.fill("foxtrot")
await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
})
test("still caps the idle recent list at five projects", async ({ page }) => {
await openProjectDialog(page)
await expect(row(page, NAMES[4])).toHaveCount(1)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
})
@@ -18,6 +18,7 @@ test("opens the comment editor when code is clicked", async ({ page }) => {
await line.click() await 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 }) => {
@@ -27,6 +28,7 @@ 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 }) => {
@@ -36,15 +38,10 @@ test("opens the comment editor for a line number range", async ({ page }) => {
await expectAppVisible(start) await expectAppVisible(start)
await expectAppVisible(end) await expectAppVisible(end)
const from = await start.boundingBox() await start.dragTo(end)
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 }) => {
@@ -54,31 +51,38 @@ test("shows a comment button when a line number is hovered", async ({ page }) =>
const comment = review.getByRole("button", { name: "Comment", exact: true }) 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(comment).toBeVisible({ timeout: 500 }) await expect(lineNumber).toHaveAttribute("data-hovered", "")
await comment.click({ timeout: 500 }) await expect(comment).toHaveCount(1)
}).toPass() await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.focus()
await expect(comment).toBeFocused()
}).toPass({ timeout: 10_000 })
await comment.press("Enter")
await expect(review.getByRole("textbox")).toBeVisible() await expect(review.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) => {
if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`) expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET")
}) })
const review = page.locator('[data-component="session-review"]') 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()
await review.getByRole("textbox").fill("Use the existing value instead") const textbox = review.getByRole("textbox")
await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click() await expect(textbox).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
await textbox.fill("Use the existing value instead")
const submit = review.locator('[data-slot="line-comment-action"][data-variant="primary"]')
await expect(submit).toBeEnabled()
await submit.click()
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible() await 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) {
@@ -144,15 +148,22 @@ 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 diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff") const changes = page.getByRole("tab", { name: "Changes" })
await page.getByRole("tab", { name: "Changes" }).click() const diffResponse = page.waitForResponse(
(response) =>
response.request().method() === "GET" && response.ok() && new URL(response.url()).pathname === "/api/vcs/diff",
)
await changes.click()
expect((await (await diffResponse).json()).data).toHaveLength(1) 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)
await review const file = review.locator('[data-file="src/review.ts"]')
.getByRole("heading", { name: /review\.ts/ }) await expectAppVisible(file)
.getByRole("button") const trigger = file.getByRole("button", { expanded: false })
.first() await expect(trigger).toHaveCount(1)
.click() await trigger.click()
await expect(file.getByRole("button", { expanded: true })).toBeVisible()
await expect(file.getByText("export const value = 'after'", { exact: true })).toBeVisible()
} }
@@ -1,6 +1,6 @@
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits" import { expectSessionReady } from "../utils/waits"
const directory = "C:/OpenCode/ReviewTerminalStacked" const directory = "C:/OpenCode/ReviewTerminalStacked"
const projectID = "proj_review_terminal_stacked" const projectID = "proj_review_terminal_stacked"
@@ -19,10 +19,6 @@ const branchDiffs = [
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => { test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } }
let detailVersion = 1
let detailFailures = 1
await page.setViewportSize({ width: 1400, height: 900 }) await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2", protocol: "v2",
@@ -57,10 +53,8 @@ test("keeps the review tree and terminal sized when both panels are open", async
time: { created: 1700000000000, updated: 1700000000000 }, time: { created: 1700000000000, updated: 1700000000000 },
}, },
], ],
sessionStatus: () => sessionStatus, sessionStatus: { [sessionID]: { type: "idle" } },
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
}) })
await page.route(/\/api\/vcs(?:\?.*)?$/, (route) => await page.route(/\/api\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({ route.fulfill({
@@ -73,20 +67,12 @@ test("keeps the review tree and terminal sized when both panels are open", async
}), }),
) )
await page.route("**/api/vcs/diff**", (route) => { await page.route("**/api/vcs/diff**", (route) => {
const url = new URL(route.request().url())
const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/")
const detail = scope?.endsWith("/src/branch/d00027")
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ body: JSON.stringify({
location: { directory, project: { id: projectID, directory, canonical: directory } }, location: { directory, project: { id: projectID, directory, canonical: directory } },
data: detail data: branchDiffs,
? branchDiffs
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
: branchDiffs,
}), }),
}) })
}) })
@@ -146,7 +132,8 @@ test("keeps the review tree and terminal sized when both panels are open", async
}) })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title) const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await expectSessionReady(page, { server, sessionID, title })
await expect(page.locator("#review-panel")).toBeVisible() await expect(page.locator("#review-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml") await expectTree(page, 2_773, "action.yml")
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740") await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
@@ -154,77 +141,6 @@ test("keeps the review tree and terminal sized when both panels are open", async
await expect(page.locator("#terminal-panel")).toBeVisible() await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml") await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page) await expectStackGeometry(page)
const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await treeViewport.hover()
await page.mouse.wheel(0, 100_000)
await expect
.poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
const lastFile = page.getByRole("button", { name: "generated-2738.ts" })
await expect(lastFile).toBeVisible()
const bottomGap = await lastFile.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
return viewport.bottom - element.getBoundingClientRect().bottom
})
expect(bottomGap).toBeGreaterThanOrEqual(0)
expect(bottomGap).toBeLessThanOrEqual(16)
const lazyDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/api/vcs/diff" &&
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
await lastFile.click()
await lazyDiff
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
await expect(preview).toContainText("after-1")
detailVersion = 2
sessionStatus[sessionID] = { type: "busy" }
events.push(statusEvent("busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
const refreshedDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/api/vcs/diff" &&
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
sessionStatus[sessionID] = { type: "idle" }
events.push(statusEvent("idle"))
await refreshedDiff
await expect(preview).toContainText("after-2")
const filter = page.getByRole("searchbox", { name: "Filter files" })
await filter.fill("generated-2738")
await expectTree(page, 1, "generated-2738.ts")
await filter.fill("")
await expectTree(page, 2_773, "generated-2738.ts")
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expectTree(page, 2_773, "generated-2738.ts")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toHaveCount(0)
await expectTree(page, 2_773, "generated-2738.ts")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "generated-2738.ts")
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.locator("#review-panel")).toHaveCount(0)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectTree(page, 2_773, "generated-2738.ts")
await page.setViewportSize({ width: 1_000, height: 700 })
await expectTree(page, 2_773, "generated-2738.ts")
await expectStackGeometry(page)
await page.setViewportSize({ width: 1_000, height: 120 })
await page.setViewportSize({ width: 1_400, height: 900 })
await expectTree(page, 2_773, "generated-2738.ts")
await expectStackGeometry(page)
}) })
async function expectTree(page: Page, total: number, file: string) { async function expectTree(page: Page, total: number, file: string) {
@@ -255,36 +171,32 @@ async function expectStackGeometry(page: Page) {
const terminal = document.querySelector<HTMLElement>("#terminal-panel")! const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
const reviewParent = review.parentElement!.getBoundingClientRect() const reviewParent = review.parentElement!.getBoundingClientRect()
const terminalParent = terminal.parentElement!.getBoundingClientRect() const terminalParent = terminal.parentElement!.getBoundingClientRect()
const sidebar = review.querySelector<HTMLElement>('[data-slot="session-review-v2-sidebar"]')!
return { return {
review: review.getBoundingClientRect().height, review: review.getBoundingClientRect().height,
reviewParent: reviewParent.height, reviewParent: reviewParent.height,
terminal: terminal.getBoundingClientRect().height, terminal: terminal.getBoundingClientRect().height,
terminalParent: terminalParent.height, terminalParent: terminalParent.height,
sidebar: sidebar.getBoundingClientRect().width,
} }
}) })
expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1) expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1)
expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1) expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1)
expect(geometry.sidebar).toBeGreaterThanOrEqual(240)
} }
function base64Encode(value: string) { function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
} }
function statusEvent(type: "busy" | "idle") { function fileDiff(file: string, additions: number, loaded = true) {
return {
directory,
payload: { type: "session.status", properties: { sessionID, status: { type } } },
}
}
function fileDiff(file: string, additions: number, loaded = true, version = 1) {
return { return {
file, file,
additions, additions,
deletions: 0, deletions: 0,
status: "modified", status: "modified",
patch: loaded patch: loaded
? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n` ? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`
: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`, : `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`,
} }
} }
@@ -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: "Erkundet" }, { locale: "de", label: "Erkundung abgeschlossen" },
{ 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,12 +1,5 @@
import { expect, test } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { import { partUpdated, setupTimeline, textPart } from "../performance/timeline-stability/fixture"
assistantMessage,
partUpdated,
setupTimeline,
status,
textPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("keeps one connection open while delivering multiple events", async ({ page }) => { test("keeps one connection open while delivering multiple events", async ({ page }) => {
const timeline = await setupTimeline(page) const timeline = await setupTimeline(page)
@@ -17,7 +10,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)
expect(await timeline.transport.connections()).toHaveLength(1) await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
expect(await timeline.transport.acknowledgements()).toHaveLength(2) expect(await timeline.transport.acknowledgements()).toHaveLength(2)
}) })
@@ -51,20 +44,24 @@ 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 timeline = await setupTimeline(page, { const timeline = await setupTimeline(page)
messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])], const partID = "prt_transport_heartbeat_sentinel"
}) const sentinel = await timeline.transport.send(partUpdated(textPart(partID, "heartbeat sentinel")))
const before = await page.locator("[data-timeline-row]").allTextContents() await timeline.waitForPart(partID)
await expect(page.locator(`[data-timeline-part-id="${partID}"] [data-component="markdown"]`)).toHaveAttribute(
"data-markdown-ready",
"",
)
const before = await timelineRows(page)
const heartbeat = await timeline.transport.heartbeat()
await timeline.transport.heartbeat() await expect.poll(() => timelineRows(page)).toEqual(before)
await timeline.settle() expect(heartbeat.connectionID).toBe(sentinel.connectionID)
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before)
expect(await timeline.transport.connections()).toHaveLength(1)
}) })
test("reconnects after a clean close", async ({ page }) => { test("reconnects after a clean close", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 }) const timeline = await setupTimeline(page)
const first = await timeline.transport.waitForConnection() const first = await timeline.transport.waitForConnection()
await timeline.transport.close() await timeline.transport.close()
@@ -77,13 +74,14 @@ 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, { eventRetry: 10 }) const timeline = await setupTimeline(page)
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(status("busy")) await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error")))
await timeline.waitForPart("prt_transport_error")
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2) 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")
@@ -112,5 +110,18 @@ test("passes through non-event fetches", async ({ page }) => {
}) })
expect(health).toEqual({ healthy: true, version: "2.0.0", pid: 1 }) expect(health).toEqual({ healthy: true, version: "2.0.0", pid: 1 })
expect(await timeline.transport.connections()).toHaveLength(1) await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
}) })
function timelineRows(page: Page) {
return page.locator("[data-timeline-row]").evaluateAll((rows) =>
rows.map((row) => ({
kind: row.getAttribute("data-timeline-row"),
message: row.getAttribute("data-message-id"),
parts: Array.from(row.querySelectorAll("[data-timeline-part-id]"), (part) =>
part.getAttribute("data-timeline-part-id"),
),
text: row.textContent,
})),
)
}
@@ -32,7 +32,7 @@ test("keeps the terminal session alive when switching session tabs in a workspac
const connection = new URL(connections[0]!) const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`) expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
expect(connection.searchParams.get("location[directory]")).toBe(directory) expect(connection.searchParams.get("location[directory]")).toBe(directory)
expect(connection.searchParams.get("ticket")).toBeNull() expect(connection.searchParams.get("ticket")).toBe("e2e-ticket")
await writeProbe(page) await writeProbe(page)
await switchTab(page, titleB) await switchTab(page, titleB)
+10 -5
View File
@@ -234,18 +234,23 @@ export async function installSseTransport<T>(
return { return {
server, server,
async waitForConnection(input = {}) { async waitForConnection(input = {}) {
await page.waitForFunction( const connection = 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?.some((connection) => connection.id > after) return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined)
}, },
input.after ?? 0, input.after ?? 0,
{ timeout: input.timeout }, { timeout: input.timeout },
) )
return (await command<SseConnectionRecord[]>({ type: "connections" })).findLast( let result: SseConnectionRecord | undefined
(connection) => connection.id > (input.after ?? 0), try {
)! result = await connection.jsonValue()
} finally {
await connection.dispose()
}
if (!result) throw new Error("SSE transport connection disappeared while waiting")
return result
}, },
send(payload, eventOptions) { send(payload, eventOptions) {
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false }) return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
+6
View File
@@ -1,4 +1,5 @@
import { expect, type Locator, type Page } from "@playwright/test" import { expect, type Locator, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
export const APP_READY_TIMEOUT = 30_000 export const APP_READY_TIMEOUT = 30_000
@@ -9,3 +10,8 @@ export async function expectAppVisible(locator: Locator) {
export async function expectSessionTitle(page: Page, title: string) { export async function expectSessionTitle(page: Page, title: string) {
await expectAppVisible(page.getByRole("heading", { name: title })) await expectAppVisible(page.getByRole("heading", { name: title }))
} }
export async function expectSessionReady(page: Page, input: { server: string; sessionID: string; title: string }) {
await expect(page).toHaveURL(`/server/${base64Encode(input.server)}/session/${input.sessionID}`)
await expectSessionTitle(page, input.title)
}
+4 -3
View File
@@ -1,11 +1,12 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.18.8", "version": "1.18.15",
"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",
@@ -19,9 +20,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 --only-failures --preload ./happydom.ts ./src", "test:unit": "bun test --conditions=solid --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 --watch --preload ./happydom.ts ./src", "test:unit:watch": "bun test --conditions=solid --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",
+16 -8
View File
@@ -3,7 +3,6 @@ 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"
@@ -155,7 +154,13 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
function UiI18nBridge(props: ParentProps) { function UiI18nBridge(props: ParentProps) {
const language = useLanguage() const language = useLanguage()
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider> return (
<I18nProvider
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
>
{props.children}
</I18nProvider>
)
} }
declare global { declare global {
@@ -218,7 +223,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: "Export logs", title: language.t("command.logs.export"),
category: language.t("command.category.settings"), category: language.t("command.category.settings"),
onSelect: () => { onSelect: () => {
void platform.exportDebugLogs?.() void platform.exportDebugLogs?.()
@@ -267,7 +272,12 @@ function DraftProviders(props: ParentProps) {
) )
} }
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { export function AppBaseProviders(
props: ParentProps<{
locale?: Locale
onNativeTranslations?: Parameters<typeof LanguageProvider>[0]["onNativeTranslations"]
}>,
) {
return ( return (
<MetaProvider> <MetaProvider>
<Font /> <Font />
@@ -276,7 +286,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
void window.api?.setTitlebar?.({ mode, scheme }) void window.api?.setTitlebar?.({ mode, scheme })
}} }}
> >
<LanguageProvider locale={props.locale}> <LanguageProvider locale={props.locale} onNativeTranslations={props.onNativeTranslations}>
<UiI18nBridge> <UiI18nBridge>
<ErrorBoundary <ErrorBoundary
fallback={(error) => { fallback={(error) => {
@@ -287,9 +297,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
<QueryProvider> <QueryProvider>
<WslServersProvider> <WslServersProvider>
<DialogProvider> <DialogProvider>
<MarkedProvider> <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider> </DialogProvider>
</WslServersProvider> </WslServersProvider>
</QueryProvider> </QueryProvider>
+64 -27
View File
@@ -60,35 +60,44 @@ function Cell(props: {
label: string label: string
tip: string tip: string
value: string value: string
wide?: boolean span?: 2 | 3
}) { }) {
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 flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline, "min-h-[20px] w-fit justify-start px-1.5 py-0.5 text-left": !!props.inline,
"justify-center text-center": !props.inline, "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.wide && !props.inline, "col-span-2": props.span === 2 && !props.inline,
"col-span-3": props.span === 3 && !props.inline,
}} }}
> >
<div <div
classList={{ classList={{
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true, "flex min-w-0": true,
"-translate-y-px items-baseline gap-1.5": !!props.inline,
"flex-col items-center": !props.inline,
}} }}
> >
{props.label} <div
</div> classList={{
<div "text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
classList={{ }}
"uppercase leading-none font-bold tabular-nums": true, >
"text-[11px]": !!props.inline, {props.label}
"text-[13px] sm:text-[14px]": !props.inline, </div>
"text-text-on-critical-base": !!props.bad, <div
"opacity-70": !!props.dim, classList={{
}} "uppercase leading-none font-bold tabular-nums": true,
> "text-[11px]": !!props.inline,
{props.value} "text-[13px] sm:text-[14px]": !props.inline,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div> </div>
</div> </div>
) )
@@ -108,37 +117,50 @@ function Cell(props: {
) )
} }
function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) { function ToggleCell(props: {
active: boolean
inline?: boolean
label: string
onClick: () => void
tip: string
value: string
}) {
const content = () => ( const content = () => (
<button <button
type="button" type="button"
aria-label="Force focus styles on all interactive elements" aria-label={`${props.label}: ${props.value}`}
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 flex-row justify-start gap-1.5 rounded px-1.5 py-0.5 text-left": !!props.inline, "min-h-[20px] w-fit justify-start rounded px-1.5 py-0.5 text-left": !!props.inline,
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline, "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 class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span> <span
<span classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}> classList={{
{props.active ? "ON" : "OFF"} flex: true,
"-translate-y-px items-baseline gap-1.5": !!props.inline,
"flex-col items-center": !props.inline,
}}
>
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">{props.label}</span>
<span class="text-[11px] leading-none font-bold">{props.value}</span>
</span> </span>
</button> </button>
) )
if (props.inline) { if (props.inline) {
return ( return (
<TooltipV2 value="Force focus styles on all interactive elements" placement="top"> <TooltipV2 value={props.tip} placement="top">
{content()} {content()}
</TooltipV2> </TooltipV2>
) )
} }
return ( return (
<Tooltip value="Force focus styles on all interactive elements" placement="top"> <Tooltip value={props.tip} placement="top">
{content()} {content()}
</Tooltip> </Tooltip>
) )
@@ -457,7 +479,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-5": !props.inline, "grid-cols-4": !props.inline,
grid: !props.inline, grid: !props.inline,
}} }}
> >
@@ -539,10 +561,25 @@ 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}
wide={!platform.setForceFocus} span={platform.setForceFocus ? 2 : 3}
/>
<ToggleCell
active={language.direction() === "rtl"}
inline={props.inline}
label={language.t("debugBar.direction.label")}
tip={language.t("debugBar.direction.tip")}
value={language.t(`debugBar.direction.${language.direction()}`)}
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
/> />
{platform.setForceFocus && ( {platform.setForceFocus && (
<FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} /> <ToggleCell
active={state.focus}
inline={props.inline}
label={language.t("debugBar.focus.label")}
tip={language.t("debugBar.focus.tip")}
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
onClick={() => void toggleFocus()}
/>
)} )}
</div> </div>
</aside> </aside>
@@ -1,4 +1,3 @@
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
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"
@@ -13,33 +12,21 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
type Accessor, import { createStore } from "solid-js/store"
type Component,
createEffect,
createMemo,
createResource,
createUniqueId,
For,
Match,
onCleanup,
onMount,
Show,
Switch,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { Link } from "@/components/link" import { ExternalLink } from "@/components/external-link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" 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 { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller"
const CUSTOM_ID = "_custom" const CUSTOM_ID = "_custom"
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }> type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]
type StringForm = Extract<IntegrationForm, { type: "string" }>
export function useProviderConnectController(options: { onBack?: () => void } = {}) { export function useProviderConnectController(options: { onBack?: () => void } = {}) {
const [store, setStore] = createStore({ selected: undefined as string | undefined }) const [store, setStore] = createStore({ selected: undefined as string | undefined })
@@ -383,120 +370,29 @@ function ProviderConnection(props: {
}) { }) {
const dialog = useDialog() const dialog = useDialog()
const serverSync = useServerSync() const serverSync = useServerSync()
const serverSDK = useServerSDK()
const params = useParams() const params = useParams()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(() => props.directory?.()) const providers = useProviders(() => props.directory?.())
const directory = () => props.directory?.() ?? decode64(params.dir) const directory = () => props.directory?.() ?? decode64(params.dir)
const location = () => {
const value = directory()
return value ? { directory: value } : undefined
}
const alive = { value: true }
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
onCleanup(() => {
alive.value = false
if (timer.current === undefined) return
clearTimeout(timer.current)
timer.current = undefined
})
const provider = createMemo( const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
) )
const fallback = createMemo<ConnectMethod[]>(() => [ const controller = createProviderConnectionController({
{ provider: () => props.provider,
type: "key" as const, directory,
label: language.t("provider.connect.method.apiKey"), onComplete: () => {
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
})
}, },
])
const [integration] = createResource(
() => ({ provider: props.provider, directory: directory() }),
(input) =>
serverSDK()
.api.integration.get({
integrationID: input.provider,
location: input.directory ? { directory: input.directory } : undefined,
})
.then((result) => result.data),
)
const loading = createMemo(() => integration.loading)
const methods = createMemo<ConnectMethod[]>(() => {
const values = integration.latest?.methods.filter(
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
)
return values?.length ? values : fallback()
}) })
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
promptInputs: undefined as undefined | Record<string, string>,
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined,
})
type Action =
| { type: "method.select"; index: number }
| { type: "method.reset" }
| { type: "auth.prompt" }
| { type: "auth.inputs"; inputs: Record<string, string> }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
| { type: "auth.error"; error: string }
function dispatch(action: Action) {
setStore(
produce((draft) => {
if (action.type === "method.select") {
draft.methodIndex = action.index
draft.authorization = undefined
draft.promptInputs = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "method.reset") {
draft.methodIndex = undefined
draft.authorization = undefined
draft.promptInputs = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.prompt") {
draft.state = "prompt"
draft.error = undefined
return
}
if (action.type === "auth.inputs") {
draft.promptInputs = action.inputs
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
return
}
if (action.type === "auth.complete") {
draft.state = "complete"
draft.authorization = action.authorization
draft.error = undefined
return
}
draft.state = "error"
draft.error = action.error
}),
)
}
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
const methodLabel = (value?: { type?: string; label?: string }) => { const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return "" if (!value) return ""
if (value.type === "key") return language.t("provider.connect.method.apiKey") if (value.type === "key") return language.t("provider.connect.method.apiKey")
@@ -509,160 +405,116 @@ 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[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined, hint:
hint?.toLowerCase() === "headless"
? language.t("provider.connect.method.headless")
: hint?.toLowerCase() === "browser" || (!hint && value?.type === "key")
? language.t("provider.connect.method.browser")
: undefined,
} }
} }
function formatError(value: unknown, fallback: string): string { function AuthFormView() {
if (value && typeof value === "object" && "data" in value) {
const data = (value as { data?: { message?: unknown } }).data
if (typeof data?.message === "string" && data.message) return data.message
}
if (value && typeof value === "object" && "error" in value) {
const nested = formatError((value as { error?: unknown }).error, "")
if (nested) return nested
}
if (value && typeof value === "object" && "message" in value) {
const message = (value as { message?: unknown }).message
if (typeof message === "string" && message) return message
}
if (value instanceof Error && value.message) return value.message
if (typeof value === "string" && value) return value
return fallback
}
async function selectMethod(index: number, inputs?: Record<string, string>) {
if (timer.current !== undefined) {
clearTimeout(timer.current)
timer.current = undefined
}
const method = methods()[index]
dispatch({ type: "method.select", index })
if (method.type === "oauth") {
if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.pending" })
await serverSDK()
.api.integration.oauth.connect({
integrationID: props.provider,
methodID: method.id,
inputs: inputs ?? {},
location: location(),
})
.then((x) => {
if (!alive.value) return
dispatch({ type: "auth.complete", authorization: x.data })
})
.catch((e) => {
if (!alive.value) return
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
})
}
}
function AuthPromptsView() {
const [formStore, setFormStore] = createStore({ const [formStore, setFormStore] = createStore({
value: {} as Record<string, string>, value: {} as Record<string, string>,
index: 0, index: 0,
}) })
const prompts = createMemo(() => { const fields = createMemo<StringForm[]>(() => {
const value = method() const value = controller.currentMethod()
return value?.type === "oauth" ? (value.prompts ?? []) : [] return (value?.form ?? []).flatMap((field) => (field.type === "string" ? [field] : []))
}) })
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => { const matches = (field: StringForm, value: Record<string, string>) => {
if (!prompt.when) return true return (field.when ?? []).every((condition) => {
const actual = value[prompt.when.key] const actual = value[condition.key]
if (actual === undefined) return false if (actual === undefined) return false
return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value return condition.op === "eq" ? actual === condition.value : actual !== condition.value
})
} }
const current = createMemo(() => { const current = createMemo(() => {
const all = prompts() const all = fields()
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value)) const index = all.findIndex((field, index) => index >= formStore.index && matches(field, formStore.value))
if (index === -1) return if (index === -1) return undefined
return { return {
index, index,
prompt: all[index], field: all[index],
} }
}) })
const valid = createMemo(() => { const valid = createMemo(() => {
const item = current() const item = current()
if (!item || item.prompt.type !== "text") return false if (!item || item.field.options) return false
const value = formStore.value[item.prompt.key] ?? "" if (!item.field.required) return true
return value.trim().length > 0 return (formStore.value[item.field.key] ?? "").trim().length > 0
}) })
async function next(index: number, value: Record<string, string>) { async function next(index: number, value: Record<string, string>) {
if (store.methodIndex === undefined) return const selected = controller.methodIndex()
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value)) if (selected === undefined) return
const next = fields().findIndex((field, i) => i > index && matches(field, value))
if (next !== -1) { if (next !== -1) {
setFormStore("index", next) setFormStore("index", next)
return return
} }
await selectMethod(store.methodIndex, value) await controller.auth.select(selected, value)
} }
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
const item = current() const item = current()
if (!item || item.prompt.type !== "text") return if (!item || item.field.options) return
if (!valid()) return if (!valid()) return
await next(item.index, formStore.value) await next(item.index, formStore.value)
} }
const item = () => current() const item = () => current()
const text = createMemo(() => { const text = createMemo(() => {
const prompt = item()?.prompt const field = item()?.field
if (!prompt || prompt.type !== "text") return if (!field || field.options) return undefined
return prompt return field
}) })
const select = createMemo(() => { const select = createMemo(() => {
const prompt = item()?.prompt const field = item()?.field
if (!prompt || prompt.type !== "select") return if (!field?.options) return undefined
return prompt return field
}) })
return ( return (
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<Switch> <Switch>
<Match when={item()?.prompt.type === "text"}> <Match when={item()?.field.options === undefined}>
<TextField <TextField
type="text" type="text"
label={text()?.message ?? ""} label={text()?.title ?? ""}
placeholder={text()?.placeholder} placeholder={text()?.placeholder}
value={text() ? (formStore.value[text()!.key] ?? "") : ""} value={text() ? (formStore.value[text()!.key] ?? "") : ""}
onChange={(value) => { onChange={(value) => {
const prompt = text() const field = text()
if (!prompt) return if (!field) return
setFormStore("value", prompt.key, value) setFormStore("value", field.key, value)
}} }}
/> />
<Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}> <Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
{language.t("common.continue")} {language.t("common.continue")}
</Button> </Button>
</Match> </Match>
<Match when={item()?.prompt.type === "select"}> <Match when={item()?.field.options !== undefined}>
<div class="w-full flex flex-col gap-1.5"> <div class="w-full flex flex-col gap-1.5">
<div class="text-14-regular text-text-base">{select()?.message}</div> <div class="text-14-regular text-text-base">{select()?.title}</div>
<div> <div>
<List <List
class="px-3" class="px-3"
items={select()?.options ?? []} items={select()?.options ?? []}
key={(x) => x.value} key={(x) => x.value}
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])} current={select()?.options?.find((x) => x.value === formStore.value[select()!.key])}
onSelect={(value) => { onSelect={(value) => {
if (!value) return if (!value) return
const prompt = select() const field = select()
if (!prompt) return if (!field) return
const nextValue = { const nextValue = {
...formStore.value, ...formStore.value,
[prompt.key]: value.value, [field.key]: value.value,
} }
setFormStore("value", prompt.key, value.value) setFormStore("value", field.key, value.value)
void next(item()!.index, nextValue) void next(item()!.index, nextValue)
}} }}
> >
@@ -672,7 +524,7 @@ function ProviderConnection(props: {
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" /> <div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div> </div>
<span>{option.label}</span> <span>{option.label}</span>
<span class="text-14-regular text-text-weak">{option.hint}</span> <span class="text-14-regular text-text-weak">{option.description}</span>
</div> </div>
)} )}
</List> </List>
@@ -693,32 +545,9 @@ function ProviderConnection(props: {
listRef?.onKeyDown(e) listRef?.onKeyDown(e)
} }
let auto = false
createEffect(() => {
if (auto) return
if (loading()) return
if (methods().length === 1) {
auto = true
void selectMethod(0)
}
})
async function complete() {
await serverSync()
.refreshProviders()
.catch(() => undefined)
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
})
}
function goBack() { function goBack() {
if (methods().length > 1 && store.methodIndex !== undefined) { if (controller.methods().length > 1 && controller.methodIndex() !== undefined) {
dispatch({ type: "method.reset" }) controller.auth.reset()
return return
} }
props.onBack() props.onBack()
@@ -734,14 +563,14 @@ function ProviderConnection(props: {
{language.t("provider.connect.selectMethod", { provider: provider().name })} {language.t("provider.connect.selectMethod", { provider: provider().name })}
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<For each={methods()}> <For each={controller.methods()}>
{(item, index) => { {(item, index) => {
const details = () => methodDetails(item) const details = () => methodDetails(item)
return ( return (
<button <button
type="button" type="button"
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none" class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => void selectMethod(index())} onClick={() => void controller.auth.select(index())}
> >
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]"> <span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" /> <span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
@@ -769,11 +598,11 @@ function ProviderConnection(props: {
ref={(ref) => { ref={(ref) => {
listRef = ref listRef = ref
}} }}
items={methods} items={controller.methods}
key={(m) => m?.label ?? m?.type} key={(m) => m?.label ?? m?.type}
onSelect={async (selected, index) => { onSelect={async (selected, index) => {
if (!selected) return if (!selected) return
void selectMethod(index) void controller.auth.select(index)
}} }}
> >
{(i) => ( {(i) => (
@@ -806,9 +635,9 @@ function ProviderConnection(props: {
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
const form = e.currentTarget as HTMLFormElement if (!(e.currentTarget instanceof HTMLFormElement)) return
const formData = new FormData(form) const value = new FormData(e.currentTarget).get("apiKey")
const apiKey = formData.get("apiKey") as string const apiKey = typeof value === "string" ? value : ""
if (!apiKey?.trim()) { if (!apiKey?.trim()) {
setFormStore("error", language.t("provider.connect.apiKey.required")) setFormStore("error", language.t("provider.connect.apiKey.required"))
@@ -816,12 +645,7 @@ function ProviderConnection(props: {
} }
setFormStore("error", undefined) setFormStore("error", undefined)
await serverSDK().api.integration.connect.key({ await controller.auth.connectKey(apiKey)
integrationID: props.provider,
location: location(),
key: apiKey,
})
await complete()
} }
if (newLayout()) if (newLayout())
@@ -836,12 +660,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")}
<Link <ExternalLink
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")}
</Link> </ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")} {language.t("provider.connect.opencodeZen.visit.suffix")}
</div> </div>
</div> </div>
@@ -886,9 +710,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")}
<Link href="https://opencode.ai/zen" tabIndex={-1}> <ExternalLink href="https://opencode.ai/zen" tabIndex={-1}>
{language.t("provider.connect.opencodeZen.visit.link")} {language.t("provider.connect.opencodeZen.visit.link")}
</Link> </ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")} {language.t("provider.connect.opencodeZen.visit.suffix")}
</div> </div>
</div> </div>
@@ -936,9 +760,9 @@ function ProviderConnection(props: {
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
const form = e.currentTarget as HTMLFormElement if (!(e.currentTarget instanceof HTMLFormElement)) return
const formData = new FormData(form) const value = new FormData(e.currentTarget).get("code")
const code = formData.get("code") as string const code = typeof value === "string" ? value : ""
if (!code?.trim()) { if (!code?.trim()) {
setFormStore("error", language.t("provider.connect.oauth.code.required")) setFormStore("error", language.t("provider.connect.oauth.code.required"))
@@ -946,20 +770,7 @@ function ProviderConnection(props: {
} }
setFormStore("error", undefined) setFormStore("error", undefined)
const result = await serverSDK() setFormStore("error", await controller.auth.completeCode(code))
.api.integration.oauth.complete({
integrationID: props.provider,
attemptID: store.authorization!.attemptID,
location: location(),
code,
})
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (result.ok) {
await complete()
return
}
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
} }
if (newLayout()) if (newLayout())
@@ -967,14 +778,14 @@ 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")}
<Link href={store.authorization!.url} class="text-v2-text-text-base"> <ExternalLink href={controller.authorization()!.url} class="text-v2-text-text-base">
{language.t("provider.connect.oauth.code.visit.link")} {language.t("provider.connect.oauth.code.visit.link")}
</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-5 self-stretch"> <form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base"> <label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })} {language.t("provider.connect.oauth.code.label", { method: controller.currentMethod()?.label ?? "" })}
<TextInputV2 <TextInputV2
ref={codeInput} ref={codeInput}
class="!w-full" class="!w-full"
@@ -1006,7 +817,9 @@ 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")}
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link> <ExternalLink href={controller.authorization()!.url}>
{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">
@@ -1014,7 +827,9 @@ function ProviderConnection(props: {
autofocus={!newLayout()} autofocus={!newLayout()}
ref={codeInput} ref={codeInput}
type="text" type="text"
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })} label={language.t("provider.connect.oauth.code.label", {
method: controller.currentMethod()?.label ?? "",
})}
placeholder={language.t("provider.connect.oauth.code.placeholder")} placeholder={language.t("provider.connect.oauth.code.placeholder")}
name="code" name="code"
value={formStore.value} value={formStore.value}
@@ -1032,52 +847,20 @@ function ProviderConnection(props: {
function OAuthAutoView() { function OAuthAutoView() {
const code = createMemo(() => { const code = createMemo(() => {
const instructions = store.authorization?.instructions const instructions = controller.authorization()?.instructions
if (instructions?.includes(":")) { if (instructions?.includes(":")) {
return instructions.split(":").pop()?.trim() return instructions.split(":").pop()?.trim()
} }
return instructions return instructions
}) })
onMount(() => {
const poll = async () => {
const authorization = store.authorization
if (!authorization || !alive.value) return
const result = await serverSDK()
.api.integration.oauth.status({
integrationID: props.provider,
attemptID: authorization.attemptID,
location: location(),
})
.then((value) => ({ ok: true as const, status: value.data }))
.catch((error) => ({ ok: false as const, error }))
if (!alive.value) return
if (!result.ok) {
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
return
}
if (result.status.status === "complete") {
await complete()
return
}
if (result.status.status === "failed") {
dispatch({ type: "auth.error", error: result.status.message })
return
}
if (result.status.status === "expired") {
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
return
}
timer.current = setTimeout(poll, 1_000)
}
void poll()
})
return ( return (
<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")}
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link> <ExternalLink href={controller.authorization()!.url}>
{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
@@ -1110,7 +893,9 @@ function ProviderConnection(props: {
} }
> >
<Switch> <Switch>
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}> <Match
when={props.provider === "anthropic" && controller.currentMethod()?.label?.toLowerCase().includes("max")}
>
{language.t("provider.connect.title.anthropicProMax")} {language.t("provider.connect.title.anthropicProMax")}
</Match> </Match>
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match> <Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
@@ -1121,10 +906,10 @@ function ProviderConnection(props: {
<div <div
onKeyDown={handleKey} onKeyDown={handleKey}
tabIndex={newLayout() ? undefined : 0} tabIndex={newLayout() ? undefined : 0}
autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined} autofocus={!newLayout() && controller.methodIndex() === undefined ? true : undefined}
> >
<Switch> <Switch>
<Match when={loading()}> <Match when={controller.loading()}>
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2"> <div class="flex items-center gap-x-2">
<Spinner /> <Spinner />
@@ -1132,10 +917,10 @@ function ProviderConnection(props: {
</div> </div>
</div> </div>
</Match> </Match>
<Match when={store.methodIndex === undefined}> <Match when={controller.methodIndex() === undefined}>
<MethodSelection /> <MethodSelection />
</Match> </Match>
<Match when={store.state === "pending"}> <Match when={controller.auth.state() === "pending"}>
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2"> <div class="flex items-center gap-x-2">
<Spinner /> <Spinner />
@@ -1143,26 +928,26 @@ function ProviderConnection(props: {
</div> </div>
</div> </div>
</Match> </Match>
<Match when={store.state === "prompt"}> <Match when={controller.auth.state() === "form"}>
<AuthPromptsView /> <AuthFormView />
</Match> </Match>
<Match when={store.state === "error"}> <Match when={controller.auth.state() === "error"}>
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2"> <div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" /> <Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span> <span>{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}</span>
</div> </div>
</div> </div>
</Match> </Match>
<Match when={method()?.type === "key"}> <Match when={controller.currentMethod()?.type === "key"}>
<ApiAuthView /> <ApiAuthView />
</Match> </Match>
<Match when={method()?.type === "oauth"}> <Match when={controller.currentMethod()?.type === "oauth"}>
<Switch> <Switch>
<Match when={store.authorization?.mode === "code"}> <Match when={controller.authorization()?.mode === "code"}>
<OAuthCodeView /> <OAuthCodeView />
</Match> </Match>
<Match when={store.authorization?.mode === "auto"}> <Match when={controller.authorization()?.mode === "auto"}>
<OAuthAutoView /> <OAuthAutoView />
</Match> </Match>
</Switch> </Switch>
@@ -8,8 +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 { Link } from "@/components/link" import { ExternalLink } from "@/components/external-link"
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"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form" import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
@@ -43,7 +42,6 @@ export function DialogCustomProvider(props: Props) {
export function CustomProviderForm(props: { autofocus?: boolean } = {}) { export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const dialog = useDialog() const dialog = useDialog()
const serverSync = useServerSync() const serverSync = useServerSync()
const serverSDK = useServerSDK()
const language = useLanguage() const language = useLanguage()
const [form, setForm] = createStore<FormState>({ const [form, setForm] = createStore<FormState>({
@@ -132,7 +130,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const saveMutation = useMutation(() => ({ const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>): Promise<typeof result> => { mutationFn: async (result: NonNullable<ReturnType<typeof validate>>): Promise<typeof result> => {
// TODO: Restore custom providers when V2 exposes config and arbitrary credential APIs. // TODO: Restore custom providers when V2 exposes config and arbitrary credential APIs.
throw new Error(`Custom provider ${result.providerID} is unavailable`) throw new Error(language.t("provider.custom.unavailable"))
}, },
onSuccess: (result) => { onSuccess: (result) => {
dialog.close() dialog.close()
@@ -168,9 +166,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")}
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}> <ExternalLink href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
{language.t("provider.custom.description.link")} {language.t("provider.custom.description.link")}
</Link> </ExternalLink>
{language.t("provider.custom.description.suffix")} {language.t("provider.custom.description.suffix")}
</p> </p>
@@ -18,6 +18,8 @@ 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
@@ -107,7 +109,6 @@ 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)
@@ -121,7 +122,10 @@ 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"))
return uniqueRows([...recentProjects(), ...directoryRows]) // Cap the idle list only. Once a query narrows the results, every project stays searchable.
const recent = recentProjects()
const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
return uniqueRows([...visible, ...directoryRows])
} }
function resolve(absolute: string) { function resolve(absolute: string) {
@@ -6,21 +6,33 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query" import { Show } from "solid-js"
import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { ServerConnection } from "@/context/server"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs" import { type ServerDomainController } from "@/components/server/server-management-controller"
const DEFAULT_USERNAME = "opencode" type ServerConnectionFormController = {
state: {
adding: () => boolean
busy: () => boolean
value: () => string
name: () => string
username: () => string
password: () => string
error: () => string
status: () => boolean | undefined
}
change: {
value: (value: string) => void
name: (value: string) => void
username: (value: string) => void
password: (value: string) => void
}
reset: () => void
submit: () => void
}
interface ServerFormProps { interface ServerFormProps {
value: string value: string
@@ -39,76 +51,6 @@ interface ServerFormProps {
onBack: () => void onBack: () => void
} }
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultUrlActions] = createResource(
async () => {
try {
const key = await platform.getDefaultServer?.()
if (!key) return null
return key
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
const setDefault = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultUrlActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
}
function useServerPreview() {
const checkServerHealth = useCheckServerHealth()
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return false
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
return host.includes(".") || host.includes(":")
}
const previewStatus = async (
value: string,
username: string,
password: string,
setStatus: (value: boolean | undefined) => void,
) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http)
setStatus(result.healthy)
}
return { previewStatus }
}
function ServerForm(props: ServerFormProps) { function ServerForm(props: ServerFormProps) {
const language = useLanguage() const language = useLanguage()
const keyDown = (event: KeyboardEvent) => { const keyDown = (event: KeyboardEvent) => {
@@ -174,387 +116,11 @@ function ServerForm(props: ServerFormProps) {
) )
} }
export function DialogSelectServer() { export function ServerConnectionList(props: {
const dialog = useDialog() domain: ServerDomainController
const controller = useServerManagementController({ onSelect: dialog.close }) onAdd: () => void
onEdit: (server: ServerConnection.Http) => void
return ( }) {
<Dialog title={controller.formTitle()}>
<div class="flex flex-1 min-h-0 flex-col px-5">
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}>
<ServerConnectionForm controller={controller} />
</Show>
</div>
</Dialog>
)
}
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
const navigate = useNavigate()
const server = useServer()
const tabs = useTabs()
const global = useGlobal()
const platform = usePlatform()
const language = useLanguage()
const { defaultKey, canDefault, setDefault } = useDefaultServer()
const { previewStatus } = useServerPreview()
const checkServerHealth = useCheckServerHealth()
const [store, setStore] = createStore({
addServer: {
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
showForm: false,
status: undefined as boolean | undefined,
},
editServer: {
id: undefined as string | undefined,
value: "",
name: "",
username: "",
password: "",
error: "",
status: undefined as boolean | undefined,
},
})
const resetAdd = () => {
setStore("addServer", {
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
showForm: false,
status: undefined,
})
}
const resetEdit = () => {
setStore("editServer", {
id: undefined,
value: "",
name: "",
username: "",
password: "",
error: "",
status: undefined,
})
}
const addMutation = useMutation(() => ({
mutationFn: async (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
if (options.navigateOnAdd === false) {
server.add(conn)
options.onSelect?.()
return
}
await select(conn, true)
},
}))
const editMutation = useMutation(() => ({
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
if (input.original.type !== "http") return
const normalized = normalizeServerUrl(input.value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = input.original.displayName
if (
normalized === input.original.http.url &&
name === existingName &&
username === input.original.http.username &&
password === input.original.http.password
) {
resetEdit()
return
}
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) {
server.add(conn)
} else {
replaceServer(input.original, conn)
}
resetEdit()
},
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const originalKey = ServerConnection.key(original)
const active = server.key
tabs.removeServer(originalKey)
const newConn = server.add(next)
if (!newConn) return
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive)
server.remove(originalKey)
}
const items = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)]
})
const settings = useSettings()
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
)
const sortedItems = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
const active = current()
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
options.onSelect?.()
if (persist && conn.type === "http") {
server.add(conn)
navigate("/")
return
}
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
}
const handleAddChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddNameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { name: value, error: "" })
}
const handleAddUsernameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddPasswordChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }),
)
}
const handleEditChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditNameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { name: value, error: "" })
}
const handleEditUsernameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditPasswordChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }),
)
}
const mode = createMemo<"list" | "add" | "edit">(() => {
if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add"
return "list"
})
const editing = createMemo(() => {
if (!store.editServer.id) return
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
})
const resetForm = () => {
resetAdd()
resetEdit()
}
const startAdd = () => {
resetEdit()
setStore("addServer", {
showForm: true,
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
status: undefined,
})
}
const startEdit = (conn: ServerConnection.Http) => {
resetAdd()
setStore("editServer", {
id: conn.http.url,
value: conn.http.url,
name: conn.displayName ?? "",
username: conn.http.username ?? "",
password: conn.http.password ?? "",
error: "",
status: global.servers.health[ServerConnection.key(conn)]?.healthy,
})
}
const submitForm = () => {
if (mode() === "add") {
if (addMutation.isPending) return
setStore("addServer", { error: "" })
addMutation.mutate(store.addServer.url)
return
}
const original = editing()
if (!original) return
if (editMutation.isPending) return
setStore("editServer", { error: "" })
editMutation.mutate({ original, value: store.editServer.value })
}
const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add")
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title")
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
</div>
)
})
createEffect(() => {
if (!store.editServer.id) return
if (editing()) return
resetEdit()
})
async function handleRemove(key: ServerConnection.Key) {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) {
await setDefault(null)
}
} catch (err) {
showRequestError(language, err)
}
}
return {
defaultKey,
canDefault,
current,
sortedItems,
status: () => global.servers.health,
isFormMode,
isAddMode,
formTitle,
formBusy,
formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
select,
setDefault,
startAdd,
startEdit,
resetForm,
submitForm,
canRemove: server.canRemove,
handleRemove,
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
}
}
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
@@ -568,10 +134,10 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
}} }}
noInitialSelection noInitialSelection
emptyMessage={language.t("dialog.server.empty")} emptyMessage={language.t("dialog.server.empty")}
items={props.controller.sortedItems} items={props.domain.collection.items}
key={(x) => x.http.url} key={(x) => x.http.url}
onSelect={(x) => { onSelect={(x) => {
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x) if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
}} }}
divider={true} divider={true}
> >
@@ -580,15 +146,15 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
return ( return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item"> <div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-center w-5"> <div class="flex flex-col h-full items-center w-5">
<ServerHealthIndicator health={props.controller.status()[key]} /> <ServerHealthIndicator health={props.domain.collection.health()[key]} />
</div> </div>
<ServerRow <ServerRow
conn={i} conn={i}
dimmed={props.controller.status()[key]?.healthy === false} dimmed={props.domain.collection.health()[key]?.healthy === false}
status={props.controller.status()[key]} status={props.domain.collection.health()[key]}
class="flex items-center gap-3 min-w-0 flex-1" class="flex items-center gap-3 min-w-0 flex-1"
badge={ badge={
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}> <Show when={props.domain.defaults.key() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs"> <span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")} {language.t("dialog.server.status.default")}
</span> </span>
@@ -597,7 +163,12 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
showCredentials showCredentials
/> />
<div class="flex items-center justify-center gap-4 pl-4"> <div class="flex items-center justify-center gap-4 pl-4">
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}> <Show
when={
props.domain.collection.current() &&
ServerConnection.key(props.domain.collection.current()!) === key
}
>
<Icon name="check" class="h-6" /> <Icon name="check" class="h-6" />
</Show> </Show>
@@ -616,27 +187,27 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
if (i.type !== "http") return if (i.type !== "http") return
props.controller.startEdit(i) props.onEdit(i)
}} }}
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}> <DropdownMenu.Item onSelect={() => props.domain.defaults.set(key)}>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}> <DropdownMenu.Item onSelect={() => props.domain.defaults.set(null)}>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")} {language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={props.controller.canRemove(key)}> <Show when={props.domain.connection.canRemove(key)}>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))} onSelect={() => props.domain.connection.remove(key)}
class="text-text-on-critical-base hover:bg-surface-critical-weak" class="text-text-on-critical-base hover:bg-surface-critical-weak"
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
@@ -657,7 +228,7 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
variant="secondary" variant="secondary"
icon="plus-small" icon="plus-small"
size="large" size="large"
onClick={props.controller.startAdd} onClick={props.onAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5" class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
> >
{language.t("dialog.server.add.button")} {language.t("dialog.server.add.button")}
@@ -667,38 +238,38 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
) )
} }
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) { export function ServerConnectionForm(props: { form: ServerConnectionFormController }) {
const language = useLanguage() const language = useLanguage()
return ( return (
<div class="flex flex-1 min-h-0 flex-col gap-4"> <div class="flex flex-1 min-h-0 flex-col gap-4">
<ServerForm <ServerForm
value={props.controller.formValue()} value={props.form.state.value()}
name={props.controller.formName()} name={props.form.state.name()}
username={props.controller.formUsername()} username={props.form.state.username()}
password={props.controller.formPassword()} password={props.form.state.password()}
placeholder={language.t("dialog.server.add.placeholder")} placeholder={language.t("dialog.server.add.placeholder")}
busy={props.controller.formBusy()} busy={props.form.state.busy()}
error={props.controller.formError()} error={props.form.state.error()}
status={props.controller.formStatus()} status={props.form.state.status()}
onChange={props.controller.handleFormChange()} onChange={props.form.change.value}
onNameChange={props.controller.handleFormNameChange()} onNameChange={props.form.change.name}
onUsernameChange={props.controller.handleFormUsernameChange()} onUsernameChange={props.form.change.username}
onPasswordChange={props.controller.handleFormPasswordChange()} onPasswordChange={props.form.change.password}
onSubmit={props.controller.submitForm} onSubmit={props.form.submit}
onBack={props.controller.resetForm} onBack={props.form.reset}
/> />
<div class="shrink-0 pb-5"> <div class="shrink-0 pb-5">
<Button <Button
variant="primary" variant="primary"
size="large" size="large"
onClick={props.controller.submitForm} onClick={props.form.submit}
disabled={props.controller.formBusy()} disabled={props.form.state.busy()}
class="px-3 py-1.5" class="px-3 py-1.5"
> >
{props.controller.formBusy() {props.form.state.busy()
? language.t("dialog.server.add.checking") ? language.t("dialog.server.add.checking")
: props.controller.isAddMode() : props.form.state.adding()
? language.t("dialog.server.add.button") ? language.t("dialog.server.add.button")
: language.t("common.save")} : language.t("common.save")}
</Button> </Button>
@@ -1,4 +1,5 @@
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"
@@ -14,10 +15,11 @@ 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.openLink(props.link) if (props.link) platform.openExternal(props.link)
props.onClose?.() props.onClose?.()
dialog.close() dialog.close()
} }
@@ -32,7 +34,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}>
Don't show again {language.t("dialog.usageExceeded.dontShowAgain")}
</Button> </Button>
<Button variant="primary" size="large" onClick={runAction}> <Button variant="primary" size="large" onClick={runAction}>
{props.actionLabel} {props.actionLabel}
@@ -139,6 +139,7 @@ test("resolves directory autocomplete from the current browser root", async () =
directories.push(input.location?.directory ?? "") directories.push(input.location?.directory ?? "")
return Promise.resolve({ data: [] }) return Promise.resolve({ data: [] })
}, },
list: () => Promise.resolve({ data: [] }),
}, },
}, },
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"] } as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
@@ -152,6 +153,67 @@ test("resolves directory autocomplete from the current browser root", async () =
expect(directories).toEqual(["/repo", "/repo/src"]) expect(directories).toEqual(["/repo", "/repo/src"])
}) })
test("keeps indexed directory results for servers that support empty search", async () => {
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
list: () => Promise.reject(new Error("listing should not run when search returns results")),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
expect(await search("")).toEqual(["/home/luke/projects"])
})
test("lists the default directory when empty search is unsupported", async () => {
const calls: string[] = []
const directories = Array.from({ length: 60 }, (_, index) => ({
path: `project-${index}/`,
type: "directory" as const,
}))
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [] }),
list: (input: { location?: { directory?: string } }) => {
calls.push(input.location?.directory ?? "")
return Promise.resolve({
data: [...directories, { path: "README.md", type: "file" }],
})
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
const results = await search("")
expect(results).toHaveLength(60)
expect(results.at(-1)).toBe("/home/luke/project-59")
expect(calls).toEqual(["/home/luke"])
})
test("matches the default directory listing when typed search is unsupported", async () => {
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [] }),
list: () =>
Promise.resolve({
data: [
{ path: "Documents/", type: "directory" },
{ path: "Downloads/", type: "directory" },
],
}),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
expect(await search("documents")).toEqual(["/home/luke/Documents"])
})
test("searches from an absolute root without a default base", async () => { test("searches from an absolute root without a default base", async () => {
const directories: string[] = [] const directories: string[] = []
const sdk = { const sdk = {
@@ -379,7 +379,14 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
.then((result) => result.data.map((entry) => entry.path)) .then((result) => result.data.map((entry) => entry.path))
.catch(() => []) .catch(() => [])
if (!active()) return [] if (!active()) return []
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) if (results.length) {
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
}
const fallback = query
? await match(input.directory, query, 50)
: (await directories(input.directory)).map((item) => item.absolute)
if (!active()) return []
return fallback
} }
const segments = query.replace(/^\/+/, "").split("/") const segments = query.replace(/^\/+/, "").split("/")
const head = segments.slice(0, -1).filter((part) => part && part !== ".") const head = segments.slice(0, -1).filter((part) => part && part !== ".")
@@ -0,0 +1,21 @@
import { ComponentProps, splitProps } from "solid-js"
export interface ExternalLinkProps extends Omit<ComponentProps<"a">, "href"> {
href: string
}
export function ExternalLink(props: ExternalLinkProps) {
const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"])
return (
<a
href={local.href}
class={`text-text-strong underline ${local.class ?? ""}`}
target={local.target ?? "_blank"}
rel={local.rel ?? "noopener noreferrer"}
{...rest}
>
{local.children}
</a>
)
}
+9 -7
View File
@@ -29,14 +29,14 @@ export type { Kind } from "@/components/file-tree"
const INDENT_STEP = 16 const INDENT_STEP = 16
function rowPaddingLeft(level: number, type: FileNode["type"]) { function rowPaddingStart(level: number, type: FileNode["type"]) {
if (type === "directory") return 8 + level * INDENT_STEP if (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 guideLineLeft(level: number) { function guideLineStart(level: number) {
return rowPaddingLeft(level, "directory") + 8 return rowPaddingStart(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-left: ${rowPaddingLeft(local.level, local.node.type)}px`} style={`padding-inline-start: ${rowPaddingStart(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,7 +99,9 @@ const FileTreeNodeV2 = (
{...rest} {...rest}
> >
{local.children} {local.children}
<span class="flex-1 min-w-0 text-12-medium whitespace-nowrap truncate">{local.node.name}</span> <span class="flex-1 min-w-0 text-start text-12-medium whitespace-nowrap truncate">
<bdi dir="auto">{local.node.name}</bdi>
</span>
{(() => { {(() => {
const value = kind() const value = kind()
if (!value || local.node.type !== "file") return null if (!value || local.node.type !== "file") return null
@@ -116,7 +118,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={`left: ${guideLineLeft(index())}px`} />} {(_, index) => <div data-slot="file-tree-v2-guide" style={`inset-inline-start: ${guideLineStart(index())}px`} />}
</For> </For>
) )
} }
@@ -227,7 +229,7 @@ export default function FileTreeV2(props: {
style={{ style={{
position: "absolute", position: "absolute",
top: "0", top: "0",
left: "0", "inset-inline-start": "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-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true, "w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-start hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
"bg-surface-base-active": local.node.path === local.active, "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-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`} style={`padding-inline-start: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
draggable={local.draggable} draggable={local.draggable}
onDragStart={(event: DragEvent) => { onDragStart={(event: DragEvent) => {
if (!local.draggable) return if (!local.draggable) return
-26
View File
@@ -1,26 +0,0 @@
import { ComponentProps, splitProps } from "solid-js"
import { usePlatform } from "@/context/platform"
export interface LinkProps extends Omit<ComponentProps<"a">, "href"> {
href: string
}
export function Link(props: LinkProps) {
const platform = usePlatform()
const [local, rest] = splitProps(props, ["href", "children", "class"])
return (
<a
href={local.href}
class={`text-text-strong underline ${local.class ?? ""}`}
onClick={(event) => {
if (!local.href) return
event.preventDefault()
platform.openLink(local.href)
}}
{...rest}
>
{local.children}
</a>
)
}
@@ -55,6 +55,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
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={
@@ -136,7 +137,10 @@ 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 = () => promptDesignPlaceholder(mode(), placeholder()) const designPlaceholder = () =>
promptDesignPlaceholder(mode(), placeholder(), (key, params) =>
language.t(key as Parameters<typeof language.t>[0], params as never),
)
const historyComments = () => { const 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))
@@ -343,7 +347,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.dataUrl} alt={attachment.filename} />), dialog.show(() => <ImagePreview src={attachment.blob.url} 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)
@@ -367,6 +371,7 @@ 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",
@@ -375,6 +380,7 @@ 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,
+3 -2
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 { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder" import { 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,10 +1489,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<PromptImageAttachments <PromptImageAttachments
attachments={imageAttachments()} attachments={imageAttachments()}
onOpen={(attachment) => onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />) dialog.show(() => <ImagePreview src={attachment.blob.url} 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
@@ -3,28 +3,13 @@ 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 }
@@ -36,6 +21,7 @@ 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 = {
@@ -65,16 +51,13 @@ 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,
dataUrl: url, blob: input.draftStore ? await input.draftStore.putBlob(file) : await createBlobReference(file),
} }
target.prompt.set([...target.prompt.current(), attachment], target.cursor) target.prompt.set([...target.prompt.current(), attachment], target.cursor)
return true return true
@@ -166,8 +149,10 @@ 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({
@@ -25,7 +25,7 @@ type ContextFile = {
type BuildRequestPartsInput = { type BuildRequestPartsInput = {
prompt: Prompt prompt: Prompt
context: ContextFile[] context: ContextFile[]
images: ImageAttachmentPart[] images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
text: string text: string
messageID: string messageID: string
sessionID: string sessionID: string
@@ -1,7 +1,13 @@
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 { prependHistoryEntry, type PromptHistoryComment, type PromptHistoryStoredEntry } from "./history" import {
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[]
@@ -35,13 +41,23 @@ export function createPromptInputHistory(): PromptInputHistory {
} }
export function createPersistedPromptInputHistory() { export function createPersistedPromptInputHistory() {
const [normal, setNormal] = persisted( const [normal, setNormal, normalInit] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]), Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])),
createStore<PromptHistoryState>({ entries: [] }), createStore<PromptHistoryState>({ entries: [] }),
) )
const [shell, setShell] = persisted( const [shell, setShell, shellInit] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])),
createStore<PromptHistoryState>({ entries: [] }), createStore<PromptHistoryState>({ entries: [] }),
) )
return createPromptInputHistoryStore(normal, setNormal, shell, setShell) const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell)
return {
...history,
add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
const ready = mode === "shell" ? shellInit : normalInit
if (!(ready instanceof Promise)) return history.add(prompt, mode, comments)
const saved = clonePromptParts(prompt)
const metadata = clonePromptHistoryComments(comments)
void ready.then(() => history.add(saved, mode, metadata))
},
}
} }
@@ -112,7 +112,7 @@ describe("prompt-input history", () => {
end: 12, 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", dataUrl: "data:image/png;base64,abc" }, { type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
] ]
const copy = clonePromptParts(original) const copy = clonePromptParts(original)
expect(copy).not.toBe(original) expect(copy).not.toBe(original)
@@ -16,6 +16,7 @@ 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
@@ -94,13 +95,13 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
} }
> >
<AttachmentCardV2 title={attachment.filename}> <AttachmentCardV2 title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime)} {typeLabel(attachment.filename, attachment.mime, props.fileLabel)}
</AttachmentCardV2> </AttachmentCardV2>
</Show> </Show>
} }
> >
<img <img
src={attachment.dataUrl} src={attachment.blob.url}
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 { promptPlaceholder } from "./placeholder" import { promptDesignPlaceholder, 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,3 +46,18 @@ 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,7 +14,11 @@ 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(mode: PromptPlaceholderInput["mode"], placeholder: string) { export function promptDesignPlaceholder(
mode: PromptPlaceholderInput["mode"],
placeholder: string,
t: PromptPlaceholderInput["t"],
) {
if (mode === "shell") return placeholder if (mode === "shell") return placeholder
return "Ask anything, / for commands, @ for context..." return t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
} }
@@ -23,6 +23,7 @@ import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope" import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state" import { createPromptSubmissionState } from "./submission-state"
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
@@ -97,10 +98,12 @@ 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: images.map((attachment) => ({ files: await Promise.all(
uri: attachment.dataUrl, images.map(async (attachment) => ({
name: attachment.filename, uri: await blobDataUrl(attachment.blob, attachment.mime),
})), name: attachment.filename,
})),
),
}) })
return true return true
} catch (err) { } catch (err) {
@@ -110,10 +113,16 @@ 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, images: encodedImages,
text, text,
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
messageID, messageID,
@@ -523,10 +532,12 @@ 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: images.map((attachment) => ({ files: await Promise.all(
uri: attachment.dataUrl, images.map(async (attachment) => ({
name: attachment.filename, uri: await blobDataUrl(attachment.blob, attachment.mime),
})), 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" })
@@ -0,0 +1,256 @@
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { useQueryClient } from "@tanstack/solid-query"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
type Authorization = IntegrationOauthConnectOutput["data"]
export function createProviderConnectionController(options: {
provider: () => string
directory: () => string | undefined
onComplete: () => void
pollInterval?: number
}) {
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const queryClient = useQueryClient()
const location = () => {
const directory = options.directory()
return directory ? { directory } : undefined
}
const [integration] = createResource(
() => ({ provider: options.provider(), directory: options.directory() }),
(input) =>
serverSDK()
.api.integration.get({ integrationID: input.provider, location: location() })
.then((result) => result.data),
)
const methods = createMemo<ProviderConnectMethod[]>(() => {
const values = integration.latest?.methods.filter(
(method): method is ProviderConnectMethod => method.type === "key" || method.type === "oauth",
)
if (values?.length) return [...values]
return [{ type: "key", label: language.t("provider.connect.method.apiKey") }]
})
const [store, setStore] = createStore({
methodIndex: undefined as number | undefined,
authorization: undefined as Authorization | undefined,
formAnswer: undefined as FormAnswer | undefined,
state: "pending" as "pending" | "complete" | "error" | "form" | undefined,
error: undefined as string | undefined,
})
const polling = {
generation: 0,
timer: undefined as ReturnType<typeof setTimeout> | undefined,
disposed: false,
}
const currentMethod = createMemo(() =>
store.methodIndex === undefined ? undefined : methods().at(store.methodIndex),
)
type Action =
| { type: "method.select"; index: number }
| { type: "method.reset" }
| { type: "auth.form" }
| { type: "auth.answer"; answer: FormAnswer | undefined }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: Authorization }
| { type: "auth.error"; error: string }
const dispatch = (action: Action) => {
setStore(
produce((draft) => {
if (action.type === "method.select") {
draft.methodIndex = action.index
draft.authorization = undefined
draft.formAnswer = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "method.reset") {
draft.methodIndex = undefined
draft.authorization = undefined
draft.formAnswer = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.form") {
draft.state = "form"
draft.error = undefined
return
}
if (action.type === "auth.answer") {
draft.formAnswer = action.answer
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
return
}
if (action.type === "auth.complete") {
draft.state = "complete"
draft.authorization = action.authorization
draft.error = undefined
return
}
draft.state = "error"
draft.error = action.error
}),
)
}
const cancelPolling = () => {
polling.generation++
if (polling.timer === undefined) return
clearTimeout(polling.timer)
polling.timer = undefined
}
const finish = async () => {
cancelPolling()
const directory = options.directory()
await queryClient
.refetchQueries(serverSync().queryOptions.providers(directory ? pathKey(directory) : null))
.catch(() => undefined)
if (polling.disposed) return
options.onComplete()
}
const poll = async (authorization: Authorization, generation: number) => {
const result = await serverSDK()
.api.integration.oauth.status({
integrationID: options.provider(),
attemptID: authorization.attemptID,
location: location(),
})
.then((response) => ({ ok: true as const, status: response.data }))
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
dispatch({
type: "auth.error",
error: result.error instanceof Error ? result.error.message : String(result.error),
})
return
}
if (result.status.status === "complete") {
await finish()
return
}
if (result.status.status === "failed") {
dispatch({ type: "auth.error", error: result.status.message })
return
}
if (result.status.status === "expired") {
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
return
}
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
}
const select = async (index: number, answer?: FormAnswer) => {
cancelPolling()
const generation = polling.generation
const selected = methods()[index]
dispatch({ type: "method.select", index })
if (selected.form?.length && !answer) {
dispatch({ type: "auth.form" })
return
}
if (selected.type === "key") {
dispatch({ type: "auth.answer", answer })
return
}
if (selected.type !== "oauth") return
if (selected.form?.some((field) => field.type !== "string")) {
dispatch({ type: "auth.error", error: "This authentication form contains unsupported fields" })
return
}
dispatch({ type: "auth.pending" })
const result = await serverSDK()
.api.integration.oauth.connect({
integrationID: options.provider(),
methodID: selected.id,
...(answer ? { answer } : {}),
location: location(),
})
.then((response) => ({ ok: true as const, authorization: response.data }))
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
dispatch({ type: "auth.error", error: String(result.error) })
return
}
dispatch({ type: "auth.complete", authorization: result.authorization })
if (result.authorization.mode === "auto") void poll(result.authorization, generation)
}
const reset = () => {
cancelPolling()
dispatch({ type: "method.reset" })
}
const connectKey = async (key: string) => {
await serverSDK().api.integration.connect.key({
integrationID: options.provider(),
location: location(),
key,
...(store.formAnswer ? { answer: store.formAnswer } : {}),
})
await finish()
}
const completeCode = async (code: string) => {
const authorization = store.authorization
if (!authorization) return language.t("provider.connect.oauth.code.invalid")
const result = await serverSDK()
.api.integration.oauth.complete({
integrationID: options.provider(),
attemptID: authorization.attemptID,
location: location(),
code,
})
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (!result.ok) {
const message = result.error instanceof Error ? result.error.message : String(result.error)
return message || language.t("provider.connect.oauth.code.invalid")
}
await finish()
return undefined
}
let auto = false
createEffect(() => {
if (auto || integration.loading || methods().length !== 1) return
auto = true
void select(0)
})
onCleanup(() => {
polling.disposed = true
cancelPolling()
})
return {
loading: () => integration.loading,
methods,
currentMethod,
methodIndex: () => store.methodIndex,
authorization: () => store.authorization,
auth: {
state: () => store.state,
error: () => store.error,
select,
reset,
connectKey,
completeCode,
},
}
}
export type ProviderConnectionController = ReturnType<typeof createProviderConnectionController>
@@ -0,0 +1,141 @@
import { useNavigate } from "@solidjs/router"
import { createMemo, createResource } from "solid-js"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { ServerConnection, useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { type ServerHealth } from "@/utils/server-health"
import { showToast } from "@/utils/toast"
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultKeyActions] = createResource(
async () => {
try {
return (await platform.getDefaultServer?.()) ?? null
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)
const set = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultKeyActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return {
key: () => defaultKey.latest,
available: createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer),
set,
}
}
export function useServerActionsController() {
const server = useServer()
const tabs = useTabs()
const platform = usePlatform()
const language = useLanguage()
const defaults = useDefaultServer()
const remove = async (key: ServerConnection.Key) => {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
} catch (err) {
showRequestError(language, err)
}
}
return { defaults, connection: { canRemove: server.canRemove, remove } }
}
export type ServerActionsController = ReturnType<typeof useServerActionsController>
export function useServerCollectionController() {
const server = useServer()
const global = useGlobal()
const settings = useSettings()
const actions = useServerActionsController()
const items = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((item) => item !== current)]
})
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
)
const sorted = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
const active = current()
const order = new Map(list.map((item, index) => [item, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
return {
collection: {
items: sorted,
current,
health: () => global.servers.health,
},
...actions,
}
}
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
const navigate = useNavigate()
const server = useServer()
const global = useGlobal()
const collection = useServerCollectionController()
const select = async (connection: ServerConnection.Any) => {
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
options.onSelect?.()
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
}
return { ...collection, selection: { select } }
}
export type ServerDomainController = ReturnType<typeof useServerDomainController>
@@ -0,0 +1,99 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
const values = (url: string): ServerFormValues => ({ url, name: "", username: "opencode", password: "" })
describe("createServerHealthPreview", () => {
test("ignores an older response that resolves after the latest response", async () => {
const first = deferred<{ healthy: boolean }>()
const second = deferred<{ healthy: boolean }>()
const requests = [first, second]
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => requests.shift()!.promise)
const older = preview.preview(values("old.example.com"), (value) => status.push(value))
const latest = preview.preview(values("new.example.com"), (value) => status.push(value))
second.resolve({ healthy: true })
await latest
first.resolve({ healthy: false })
await older
expect(status).toEqual([undefined, undefined, true])
})
test("an incomplete value invalidates an in-flight response", async () => {
const request = deferred<{ healthy: boolean }>()
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => request.promise)
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
await preview.preview(values("server"), (value) => status.push(value))
request.resolve({ healthy: true })
await pending
expect(status).toEqual([undefined, undefined])
})
test("cancellation prevents an in-flight response from updating status", async () => {
const request = deferred<{ healthy: boolean }>()
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => request.promise)
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
preview.cancel()
request.resolve({ healthy: true })
await pending
expect(status).toEqual([undefined])
})
})
describe("replaceServerConnection", () => {
const original: ServerConnection.Http = { type: "http", http: { url: "https://old.example.com" } }
const next: ServerConnection.Http = { type: "http", http: { url: "https://new.example.com" } }
test("moves active selection after adding the replacement and removes the original", () => {
const calls: string[] = []
replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: (key) => calls.push(`tabs:${key}`),
add: (server) => {
calls.push(`add:${ServerConnection.key(server)}`)
return server
},
setActive: (key) => calls.push(`active:${key}`),
remove: (key) => calls.push(`remove:${key}`),
})
expect(calls).toEqual([
"tabs:https://old.example.com",
"add:https://new.example.com",
"active:https://new.example.com",
"remove:https://old.example.com",
])
})
test("keeps the original when the replacement cannot be added", () => {
const removed: ServerConnection.Key[] = []
replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: () => {},
add: () => undefined,
setActive: () => {},
remove: (key) => removed.push(key),
})
expect(removed).toEqual([])
})
})
@@ -0,0 +1,59 @@
import { normalizeServerUrl, ServerConnection } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
export type ServerFormValues = {
url: string
name: string
username: string
password: string
}
export function createServerHealthPreview(
check: (server: ServerConnection.HttpBase) => Promise<Pick<ServerHealth, "healthy">>,
) {
let generation = 0
const cancel = () => {
generation += 1
}
const preview = async (values: ServerFormValues, setStatus: (value: boolean | undefined) => void) => {
const current = ++generation
setStatus(undefined)
const normalized = normalizeServerUrl(values.url)
if (!normalized) return
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return
if (!host.includes("localhost") && !host.startsWith("127.0.0.1") && !host.includes(".") && !host.includes(":"))
return
const http: ServerConnection.HttpBase = { url: normalized }
if (values.username) http.username = values.username
if (values.password) http.password = values.password
const result = await check(http)
if (current !== generation) return
setStatus(result.healthy)
}
return { cancel, preview }
}
export function replaceServerConnection(
originalKey: ServerConnection.Key,
next: ServerConnection.Http,
operations: {
active: () => ServerConnection.Key | undefined
removeTabs: (key: ServerConnection.Key) => void
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
setActive: (key: ServerConnection.Key) => void
remove: (key: ServerConnection.Key) => void
},
) {
const active = operations.active()
operations.removeTabs(originalKey)
const added = operations.add(next)
if (!added) return
const nextActive = active === originalKey ? ServerConnection.key(added) : active
if (nextActive) operations.setActive(nextActive)
operations.remove(originalKey)
}
@@ -2,13 +2,13 @@ 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 { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { type Component, Show } from "solid-js" import { type Component, Show } from "solid-js"
import { useServerManagementController } from "@/components/dialog-select-server" import type { ServerActionsController } from "@/components/server/server-management-controller"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
export const ServerRowMenu: Component<{ export const ServerRowMenu: Component<{
server: ServerConnection.Any server: ServerConnection.Any
controller: ReturnType<typeof useServerManagementController> domain: ServerActionsController
onEdit: (server: ServerConnection.Http) => void onEdit: (server: ServerConnection.Http) => void
open?: boolean open?: boolean
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void
@@ -19,13 +19,13 @@ export const ServerRowMenu: Component<{
<ServerRowMenuView <ServerRowMenuView
server={props.server} server={props.server}
labels={serverMenuLabels(language)} labels={serverMenuLabels(language)}
canDefault={props.controller.canDefault()} canDefault={props.domain.defaults.available()}
isDefault={props.controller.defaultKey() === key} isDefault={props.domain.defaults.key() === key}
canRemove={props.controller.canRemove(key)} canRemove={props.domain.connection.canRemove(key)}
onEdit={props.onEdit} onEdit={props.onEdit}
onSetDefault={() => props.controller.setDefault(key)} onSetDefault={() => props.domain.defaults.set(key)}
onRemoveDefault={() => props.controller.setDefault(null)} onRemoveDefault={() => props.domain.defaults.set(null)}
onRemove={() => props.controller.handleRemove(key)} onRemove={() => props.domain.connection.remove(key)}
open={props.open} open={props.open}
onOpenChange={props.onOpenChange} onOpenChange={props.onOpenChange}
/> />
@@ -5,12 +5,15 @@ 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 "@/types" import type { Message, Part, UserMessage } from "@/types"
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"
@@ -120,7 +123,8 @@ export function SessionContextTab() {
() => { () => {
const revert = info()?.revert?.messageID const revert = info()?.revert?.messageID
if (!revert) return userMessages() if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert) const boundary = userMessages().findIndex((message) => message.id === revert)
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
}, },
emptyUserMessages, emptyUserMessages,
{ equals: same }, { equals: same },
@@ -220,6 +224,31 @@ 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,
api: sdk().api,
})
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
@@ -328,7 +357,18 @@ export function SessionContextTab() {
</Show> </Show>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div> <div class="flex items-center justify-between">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Button
size="small"
variant="ghost"
class="gap-1.5 px-2 text-text-weak hover:text-text-base"
onClick={exportSession}
>
<Icon name="download" size="small" />
<span>{language.t("context.export.session")}</span>
</Button>
</div>
<Accordion multiple> <Accordion multiple>
<For each={messages()}> <For each={messages()}>
{(message) => ( {(message) => (
@@ -289,9 +289,9 @@ export function SessionHeader() {
return ( return (
<> <>
<Show when={search() && centerMount()}> <Show when={search() && centerMount()} keyed>
{(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()}> <Show when={hotkey()} keyed>
{(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()}> <Show when={rightMount()} keyed>
{(mount) => ( {(mount) => (
<Portal mount={mount()}> <Portal mount={mount}>
<Show <Show
when={isV2} when={isV2}
fallback={ fallback={
@@ -3,51 +3,63 @@ import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@op
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js" import { useMutation } from "@tanstack/solid-query"
import { type Component, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import {
createServerHealthPreview,
replaceServerConnection,
type ServerFormValues,
} from "@/components/server/server-management"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { type ServerConnection } from "@/context/server" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server" import { useTabs } from "@/context/tabs"
import { useCheckServerHealth } from "@/utils/server-health"
import "./settings-v2.css" import "./settings-v2.css"
const DEFAULT_USERNAME = "opencode"
type FormMode = "list" | "add" | "edit"
export const DialogServerV2: Component<{ export const DialogServerV2: Component<{
mode: "add" | "edit" mode: "add" | "edit"
server?: ServerConnection.Http server?: ServerConnection.Http
}> = (props) => { }> = (props) => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const controller = useServerManagementController({ const form = createFormController({
onSelect: () => dialog.close(), onSelect: () => dialog.close(),
navigateOnAdd: false,
}) })
const [opened, setOpened] = createSignal(false) const [opened, setOpened] = createSignal(false)
onMount(() => { onMount(() => {
if (props.mode === "add") controller.startAdd() if (props.mode === "add") form.start.add()
if (props.mode === "edit" && props.server) controller.startEdit(props.server) if (props.mode === "edit" && props.server) form.start.edit(props.server)
setOpened(true) setOpened(true)
}) })
onCleanup(() => { onCleanup(() => {
controller.resetForm() form.reset()
}) })
createEffect(() => { createEffect(() => {
if (!opened()) return if (!opened()) return
if (controller.isFormMode()) return if (form.state.open()) return
dialog.close() dialog.close()
}) })
const keyDown = (event: KeyboardEvent) => { const keyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.isComposing) return if (event.key !== "Enter" || event.isComposing) return
event.preventDefault() event.preventDefault()
controller.submitForm() form.submit()
} }
const title = () => const title = () =>
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title") props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
const submitLabel = () => { const submitLabel = () => {
if (controller.formBusy()) return language.t("dialog.server.add.checking") if (form.state.busy()) return language.t("dialog.server.add.checking")
if (props.mode === "add") return language.t("dialog.server.add.button") if (props.mode === "add") return language.t("dialog.server.add.button")
return language.t("common.save") return language.t("common.save")
} }
@@ -66,16 +78,16 @@ export const DialogServerV2: Component<{
type="text" type="text"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formValue()} value={form.state.value()}
placeholder={language.t("dialog.server.add.placeholder")} placeholder={language.t("dialog.server.add.placeholder")}
invalid={!!controller.formError()} invalid={!!form.state.error()}
disabled={controller.formBusy()} disabled={form.state.busy()}
autofocus autofocus
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)} onInput={(event) => form.change.value(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
<Show when={controller.formError()}> <Show when={form.state.error()}>
<span class="settings-v2-server-dialog-error">{controller.formError()}</span> <span class="settings-v2-server-dialog-error">{form.state.error()}</span>
</Show> </Show>
</div> </div>
<div class="flex w-full min-w-0 flex-col gap-2"> <div class="flex w-full min-w-0 flex-col gap-2">
@@ -84,10 +96,10 @@ export const DialogServerV2: Component<{
type="text" type="text"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formName()} value={form.state.name()}
placeholder={language.t("dialog.server.add.namePlaceholder")} placeholder={language.t("dialog.server.add.namePlaceholder")}
disabled={controller.formBusy()} disabled={form.state.busy()}
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)} onInput={(event) => form.change.name(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
</div> </div>
@@ -98,10 +110,10 @@ export const DialogServerV2: Component<{
type="text" type="text"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formUsername()} value={form.state.username()}
placeholder={language.t("dialog.server.add.usernamePlaceholder")} placeholder={language.t("dialog.server.add.usernamePlaceholder")}
disabled={controller.formBusy()} disabled={form.state.busy()}
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)} onInput={(event) => form.change.username(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
</div> </div>
@@ -111,10 +123,10 @@ export const DialogServerV2: Component<{
type="password" type="password"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formPassword()} value={form.state.password()}
placeholder={language.t("dialog.server.add.passwordPlaceholder")} placeholder={language.t("dialog.server.add.passwordPlaceholder")}
disabled={controller.formBusy()} disabled={form.state.busy()}
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)} onInput={(event) => form.change.password(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
</div> </div>
@@ -122,13 +134,171 @@ export const DialogServerV2: Component<{
</div> </div>
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}> <ButtonV2 variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
{language.t("common.cancel")} {language.t("common.cancel")}
</ButtonV2> </ButtonV2>
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}> <ButtonV2 variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
{submitLabel()} {submitLabel()}
</ButtonV2> </ButtonV2>
</DialogFooter> </DialogFooter>
</Dialog> </Dialog>
) )
} }
function createFormController(options: { onSelect?: () => void } = {}) {
const server = useServer()
const tabs = useTabs()
const global = useGlobal()
const language = useLanguage()
const checkServerHealth = useCheckServerHealth()
const healthPreview = createServerHealthPreview(checkServerHealth)
const [store, setStore] = createStore({
mode: "list" as FormMode,
originalUrl: undefined as string | undefined,
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
error: "",
status: undefined as boolean | undefined,
})
onCleanup(healthPreview.cancel)
const reset = () => {
healthPreview.cancel()
setStore({
mode: "list",
originalUrl: undefined,
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
error: "",
status: undefined,
})
}
const allServers = () => {
if (!server.current || server.list.includes(server.current)) return server.list
return [server.current, ...server.list]
}
const editing = createMemo(() =>
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
)
const add = (connection: ServerConnection.Http) => server.add(connection)
const replace = (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
replaceServerConnection(originalKey, next, {
active: () => server.key,
removeTabs: (key) => tabs.removeServer(key),
add,
setActive: (key) => server.setActive(key),
remove: (key) => server.remove(key),
})
const request = useMutation(() => ({
mutationFn: async () => {
const normalized = normalizeServerUrl(store.values.url)
if (!normalized) {
reset()
return
}
const original = store.mode === "edit" ? editing() : undefined
if (store.mode === "edit" && !original) return
const name = store.values.name.trim() || undefined
const username = store.values.username || undefined
const password = store.values.password || undefined
if (
original?.type === "http" &&
normalized === original.http.url &&
name === original.displayName &&
username === original.http.username &&
password === original.http.password
) {
reset()
return
}
const connection: ServerConnection.Http = {
type: "http",
displayName: name,
http: {
url: normalized,
username: store.mode === "add" && !password ? undefined : username,
password,
},
}
const result = await checkServerHealth(connection.http)
if (!result.healthy) {
setStore("error", language.t("dialog.server.add.error"))
return
}
if (original?.type === "http") {
if (normalized === original.http.url) add(connection)
if (normalized !== original.http.url) replace(ServerConnection.key(original), connection)
reset()
return
}
reset()
add(connection)
options.onSelect?.()
},
}))
const preview = () => void healthPreview.preview(store.values, (status) => setStore("status", status))
const change = (field: keyof ServerFormValues, value: string) => {
if (request.isPending) return
setStore("values", field, value)
setStore("error", "")
if (field !== "name") preview()
}
const startAdd = () => {
reset()
setStore("mode", "add")
}
const startEdit = (connection: ServerConnection.Http) => {
reset()
setStore({
mode: "edit",
originalUrl: connection.http.url,
values: {
url: connection.http.url,
name: connection.displayName ?? "",
username: connection.http.username ?? "",
password: connection.http.password ?? "",
},
error: "",
status: global.servers.health[ServerConnection.key(connection)]?.healthy,
})
}
const submit = () => {
if (store.mode === "list" || request.isPending) return
setStore("error", "")
request.mutate()
}
createEffect(() => {
if (store.mode !== "edit") return
if (editing()) return
reset()
})
return {
state: {
mode: () => store.mode,
open: () => store.mode !== "list",
adding: () => store.mode === "add",
busy: () => request.isPending,
value: () => store.values.url,
name: () => store.values.name,
username: () => store.values.username,
password: () => store.values.password,
error: () => store.error,
status: () => store.status,
},
change: {
value: (value: string) => change("url", value),
name: (value: string) => change("name", value),
username: (value: string) => change("username", value),
password: (value: string) => change("password", value),
},
start: { add: startAdd, edit: startEdit },
reset,
submit,
}
}
@@ -0,0 +1,70 @@
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 }
}
@@ -0,0 +1,53 @@
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()
}
})
})
@@ -0,0 +1,172 @@
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 { 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.location.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 serverSync = useServerSync()
const [shells] = createResource(
async () => {
// TODO: Dax is considering the V2 shell discovery and config update APIs.
// return (await sdk.api.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
// TODO: Dax is considering the V2 shell discovery and config update APIs.
// 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,174 +1,294 @@
import { Component, Show, createMemo, createResource, onMount } from "solid-js" import { Component, Show, createMemo, createResource } 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 { 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 { useUpdaterAction } from "../updater-action" import { useUpdaterAction } from "../updater-action"
import { import { useSettings } from "@/context/settings"
monoDefault, import { ExternalLink } from "../external-link"
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 {
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"
let demoSoundState = { const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
cleanup: undefined as (() => void) | undefined, const fontSettings = {
timeout: undefined as NodeJS.Timeout | undefined, ui: {
run: 0, action: "settings-ui-font",
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>
)
} }
type ThemeOption = { const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
id: string const language = useLanguage()
name: string const options = createMemo(() =>
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>
)
} }
type ShellOption = { const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
path: string const language = useLanguage()
name: string return (
acceptable: boolean <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={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>
)
} }
type ShellSelectOption = { const FontSetting: Component<{
id: string kind: "ui" | "code" | "terminal"
value: string fonts: AppearanceSettingsController["fonts"]
label: string }> = (props) => {
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>
)
} }
// To prevent audio from overlapping/playing very quickly when navigating the settings menus, const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => {
// delay the playback by 100ms during quick selection changes and pause existing sounds. const language = useLanguage()
const stopDemoSound = () => { return (
demoSoundState.run += 1 <div class="settings-v2-section">
if (demoSoundState.cleanup) { <h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
demoSoundState.cleanup() <SettingsListV2>
} <SoundSetting kind="agent" channel={props.controller.agent} />
clearTimeout(demoSoundState.timeout) <SoundSetting kind="permissions" channel={props.controller.permissions} />
demoSoundState.cleanup = undefined <SoundSetting kind="errors" channel={props.controller.errors} />
</SettingsListV2>
</div>
)
} }
const playDemoSound = (id: string | undefined) => { const SoundSetting: Component<{
stopDemoSound() kind: "agent" | "permissions" | "errors"
if (!id) return channel: SoundSettingsController["agent"]
}> = (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 run = ++demoSoundState.run const LanguageSetting = () => {
demoSoundState.timeout = setTimeout(() => { const language = useLanguage()
void playSoundById(id).then((cleanup) => { const options = createMemo(() =>
if (demoSoundState.run !== run) { language.locales.map((locale) => ({
cleanup?.() value: locale,
return label: language.label(locale),
} })),
demoSoundState.cleanup = cleanup )
}) return (
}, 100) <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={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 settings = useSettings() const settings = useSettings()
const serverSync = useServerSync()
const mobile = createMediaQuery("(max-width: 767px)") const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction() const updater = useUpdaterAction()
const permissionScope = createPermissionScopeController(() => props.sessionID)
const dir = createMemo(() => { const shell = createShellSettingsController()
if (!props.sessionID) return undefined const appearance = createAppearanceSettingsController()
return serverSync().session.lineage.peek(props.sessionID)?.session.location.directory const sounds = createSoundSettingsController()
})
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 () => {
// TODO: Restore executable shell discovery when the V2 client exposes it.
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] },
)
const [pinchZoom, { mutate: setPinchZoom }] = createResource( const [pinchZoom, { mutate: setPinchZoom }] = createResource(
() => (desktop() && platform.getPinchZoomEnabled ? true : false), () => desktop() && "getPinchZoomEnabled" in platform,
() => 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)
@@ -176,101 +296,14 @@ 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 GeneralSection = () => ( const GeneralSection = () => (
<div class="settings-v2-section"> <div class="settings-v2-section">
<SettingsListV2> <SettingsListV2>
<SettingsRowV2 <LanguageSetting />
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>
<SettingsRowV2 <PermissionScopeSetting controller={permissionScope} />
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>
<SettingsRowV2 <ShellSetting controller={shell} />
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")}
@@ -381,124 +414,6 @@ 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>
@@ -543,68 +458,6 @@ 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>
@@ -626,7 +479,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>
@@ -663,11 +516,11 @@ export const SettingsGeneralV2: Component<{
<div class="settings-v2-tab-body"> <div class="settings-v2-tab-body">
<GeneralSection /> <GeneralSection />
<AppearanceSection /> <AppearanceSection controller={appearance} />
<NotificationsSection /> <NotificationsSection />
<SoundsSection /> <SoundsSection controller={sounds} />
<Show when={desktop()}> <Show when={desktop()}>
<UpdatesSection /> <UpdatesSection />
@@ -10,7 +10,7 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row" import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server" import { ServerConnection, serverName } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server" import { useServerCollectionController } from "../server/server-management-controller"
import { DialogServerV2 } from "./dialog-server-v2" import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings" import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
@@ -19,16 +19,16 @@ import "./settings-v2.css"
export const SettingsServersV2: Component = () => { export const SettingsServersV2: Component = () => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const controller = useServerManagementController() const controller = useServerCollectionController()
const [store, setStore] = createStore({ filter: "" }) const [store, setStore] = createStore({ filter: "" })
const wslServers = useFilteredWslServers(() => store.filter) const wslServers = useFilteredWslServers(() => store.filter)
const showSearch = createMemo( const showSearch = createMemo(
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1, () => controller.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
) )
const filtered = createMemo(() => { const filtered = createMemo(() => {
const items = controller.sortedItems().filter((item) => !isWslServer(item)) const items = controller.collection.items().filter((item) => !isWslServer(item))
const query = store.filter.trim() const query = store.filter.trim()
if (!query) return items if (!query) return items
return fuzzysort return fuzzysort
@@ -39,11 +39,11 @@ export const SettingsServersV2: Component = () => {
}) })
const openAdd = () => { const openAdd = () => {
dialog.push(() => <DialogServerV2 mode="add" />) void dialog.push(() => <DialogServerV2 mode="add" />)
} }
const openEdit = (server: ServerConnection.Http) => { const openEdit = (server: ServerConnection.Http) => {
dialog.push(() => <DialogServerV2 mode="edit" server={server} />) void dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
} }
return ( return (
@@ -97,12 +97,12 @@ export const SettingsServersV2: Component = () => {
} }
> >
<SettingsListV2> <SettingsListV2>
<WslServerSettings controller={controller} servers={wslServers} /> <WslServerSettings domain={controller} servers={wslServers} />
<For each={filtered()}> <For each={filtered()}>
{(item) => { {(item) => {
const key = ServerConnection.key(item) const key = ServerConnection.key(item)
const health = () => controller.status()[key] const health = () => controller.collection.health()[key]
const isDefault = () => controller.defaultKey() === key const isDefault = () => controller.defaults.key() === key
return ( return (
<div class="settings-v2-servers-row"> <div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead"> <div class="settings-v2-servers-lead">
@@ -122,10 +122,10 @@ export const SettingsServersV2: Component = () => {
</div> </div>
</div> </div>
<div class="settings-v2-servers-actions"> <div class="settings-v2-servers-actions">
<Show when={controller.canDefault() && isDefault()}> <Show when={controller.defaults.available() && isDefault()}>
<Tag>{language.t("dialog.server.status.default")}</Tag> <Tag>{language.t("dialog.server.status.default")}</Tag>
</Show> </Show>
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} /> <ServerRowMenu server={item} domain={controller} onEdit={openEdit} />
</div> </div>
</div> </div>
) )
@@ -287,7 +287,7 @@
} }
.settings-v2-provider-env-hint { .settings-v2-provider-env-hint {
padding-right: 12px; padding-inline-end: 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: left; text-align: start;
} }
.settings-v2-providers-view-all:hover { .settings-v2-providers-view-all:hover {
@@ -361,13 +361,13 @@
} }
.settings-v2-tab-search [data-slot="text-input-v2-input"] { .settings-v2-tab-search [data-slot="text-input-v2-input"] {
padding-right: 28px; padding-inline-end: 28px;
} }
.settings-v2-tab-search-clear { .settings-v2-tab-search-clear {
position: absolute; position: absolute;
top: 50%; top: 50%;
right: 6px; inset-inline-end: 6px;
z-index: 1; z-index: 1;
transform: translateY(-50%); transform: translateY(-50%);
} }
@@ -1,10 +1,6 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router"
import { import {
type Accessor, type Accessor,
createEffect, createEffect,
@@ -16,14 +12,12 @@ import {
Show, Show,
} from "solid-js" } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { ServerConnection, useServer } from "@/context/server" import { ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { type ServerHealth } from "@/utils/server-health" import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings"
import { useMcpToggle } from "@/context/mcp" import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
@@ -106,16 +100,6 @@ const useDefaultServerKey = (
} }
} }
type ServerStatusState = {
servers: () => ServerStatusItem[]
defaultKey: () => ServerConnection.Key | undefined
ariaLabel: string
serversLabel: string
defaultLabel: string
manageLabel: string
onManage: () => void
}
type ServerStatusItem = { type ServerStatusItem = {
key: ServerConnection.Key key: ServerConnection.Key
conn: ServerConnection.Any conn: ServerConnection.Any
@@ -125,149 +109,13 @@ type ServerStatusItem = {
onSelect: () => void onSelect: () => void
} }
export function StatusPopoverServerBody() {
const global = useGlobal()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
dialogDead = true
dialogRun += 1
})
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const serverItems = createMemo(() =>
sortedServers().map((conn) => {
const key = ServerConnection.key(conn)
return {
key,
conn,
health: global.servers.health[key],
blocked: global.servers.health[key]?.healthy === false,
active: !!server.current && key === ServerConnection.key(server.current),
onSelect: () => {
navigate("/")
queueMicrotask(() => server.setActive(key))
},
}
}),
)
return (
<ServerStatusPopoverView
state={{
servers: serverItems,
defaultKey: defaultServer.key,
ariaLabel: language.t("status.popover.ariaLabel"),
serversLabel: language.t("status.popover.tab.servers"),
defaultLabel: language.t("common.default"),
manageLabel: language.t("status.popover.action.manageServers"),
onManage: () => {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
})
},
}}
/>
)
}
function ServerStatusPopoverView(props: { state: ServerStatusState }) {
return (
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
<Tabs
aria-label={props.state.ariaLabel}
class="tabs bg-background-strong rounded-xl overflow-hidden"
data-component="tabs"
data-active="servers"
defaultValue="servers"
variant="alt"
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{props.state.servers().length > 0 ? `${props.state.servers().length} ` : ""}
{props.state.serversLabel}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="servers">
<ServerStatusList state={props.state} />
</Tabs.Content>
</Tabs>
</div>
)
}
function ServerStatusList(props: { state: ServerStatusState }) {
return (
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={props.state.servers()}>
{(item) => {
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
classList={{
"hover:bg-surface-raised-base-hover": !item.blocked,
"cursor-not-allowed": item.blocked,
}}
aria-disabled={item.blocked}
onClick={() => {
if (item.blocked) return
item.onSelect()
}}
>
<ServerHealthIndicator health={item.health} />
<ServerRow
conn={item.conn}
dimmed={item.blocked}
status={item.health}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={item.key === props.state.defaultKey()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{props.state.defaultLabel}
</span>
</Show>
}
>
<div class="flex-1" />
<Show when={item.active}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</ServerRow>
</button>
)
}}
</For>
<Button variant="secondary" class="mt-3 self-start h-8 px-3 py-1.5" onClick={props.state.onManage}>
{props.state.manageLabel}
</Button>
</div>
</div>
)
}
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) { export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync() const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
const global = useGlobal() const global = useGlobal()
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const navigate = useNavigate()
const settings = useSettings()
const fail = (err: unknown) => { const fail = (err: unknown) => {
showToast({ showToast({
@@ -315,17 +163,11 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
aria-label={language.t("status.popover.ariaLabel")} aria-label={language.t("status.popover.ariaLabel")}
class="tabs bg-background-strong rounded-xl overflow-hidden" class="tabs bg-background-strong rounded-xl overflow-hidden"
data-component="tabs" data-component="tabs"
data-active={settings.general.newLayoutDesigns() ? "mcp" : "servers"} data-active="mcp"
defaultValue={settings.general.newLayoutDesigns() ? "mcp" : "servers"} defaultValue="mcp"
variant="alt" variant="alt"
> >
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10"> <Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
{!settings.general.newLayoutDesigns() && (
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
)}
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular"> <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""} {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
{language.t("status.popover.tab.mcp")} {language.t("status.popover.tab.mcp")}
@@ -342,73 +184,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</Show> </Show>
</Tabs.List> </Tabs.List>
{!settings.general.newLayoutDesigns() && (
<Tabs.Content value="servers">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}>
{(s) => {
const key = ServerConnection.key(s)
const blocked = () => global.servers.health[key]?.healthy === false
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
classList={{
"hover:bg-surface-raised-base-hover": !blocked(),
"cursor-not-allowed": blocked(),
}}
aria-disabled={blocked()}
onClick={() => {
if (blocked()) return
navigate("/")
queueMicrotask(() => server.setActive(key))
}}
>
<ServerHealthIndicator health={global.servers.health[key]} />
<ServerRow
conn={s}
dimmed={blocked()}
status={global.servers.health[key]}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={key === defaultServer.key()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")}
</span>
</Show>
}
>
<div class="flex-1" />
<Show when={server.current && key === ServerConnection.key(server.current)}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</ServerRow>
</button>
)
}}
</For>
<Button
variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5"
onClick={() => {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
})
}}
>
{language.t("status.popover.action.manageServers")}
</Button>
</div>
</div>
</Tabs.Content>
)}
<Tabs.Content value="mcp"> <Tabs.Content value="mcp">
<div class="flex flex-col px-2 pb-2"> <div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
+1 -27
View File
@@ -16,7 +16,6 @@ import {
} from "./status-popover-indicator" } 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 })))
export function StatusPopover() { export function StatusPopover() {
const language = useLanguage() const language = useLanguage()
@@ -82,8 +81,7 @@ export function StatusPopover() {
) )
} }
export function StatusPopoverV2(props: { scope?: "server" }) { export function StatusPopoverV2() {
if (props.scope === "server") return <ServerStatusPopover />
return <DirectoryStatusPopover /> return <DirectoryStatusPopover />
} }
@@ -124,30 +122,6 @@ function DirectoryStatusPopover() {
return <StatusPopoverView state={state()} /> return <StatusPopoverView state={state()} />
} }
function ServerStatusPopover() {
const language = useLanguage()
const server = useServer()
const global = useGlobal()
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: serverHealth() !== undefined,
serverHealth: serverHealth(),
attention: false,
issue: false,
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<ServerBody />
</StatusPopoverBody>
),
}))
return <StatusPopoverView state={state()} />
}
type StatusPopoverState = { type StatusPopoverState = {
shown: boolean shown: boolean
ready: boolean ready: boolean
+12 -3
View File
@@ -14,6 +14,7 @@ import { useSDK } from "@/context/sdk"
import { terminalFontFamily, useSettings } from "@/context/settings" import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal" import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalConnectToken } from "@/utils/terminal-connect-token"
import { terminalWriter } from "@/utils/terminal-writer" import { terminalWriter } from "@/utils/terminal-writer"
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url" import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
@@ -364,7 +365,11 @@ export const Terminal = (props: TerminalProps) => {
event.preventDefault() event.preventDefault()
event.stopImmediatePropagation() event.stopImmediatePropagation()
platform.openLink(text) if (URL.canParse(text) && new URL(text).protocol === "file:" && platform.openLocalFile) {
platform.openLocalFile(text)
return
}
platform.openExternal(text)
} }
onMount(() => { onMount(() => {
@@ -526,8 +531,11 @@ export const Terminal = (props: TerminalProps) => {
} }
const connectToken = async () => { const connectToken = async () => {
// TODO: Add PTY tickets when the V2 client exposes a connect-token API. const result = await terminalConnectToken({ url, id, directory })
return undefined if (result.ticket) return result.ticket
if (result.status === 404 || result.status === 405) return
if (result.status === 403) throw new Error(language.t("terminal.connectTicket.csrfError"))
throw new Error(language.t("terminal.connectTicket.statusError", { status: result.status }))
} }
const retry = (err: unknown) => { const retry = (err: unknown) => {
@@ -679,6 +687,7 @@ 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 }}
@@ -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;
right: 4px; inset-inline-end: 4px;
display: flex; display: flex;
height: 20px; height: 20px;
width: 20px; width: 20px;
@@ -9,58 +9,108 @@
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: 13.5px; gap: 6px;
} }
[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;
left: -6.75px; inset-inline-start: -3.75px;
width: 1.5px; width: 1.5px;
height: 12px; height: 12px;
border-radius: 9999px; border-radius: 9999px;
background: var(--v2-background-bg-layer-02); background: var(--tab-separator);
} }
[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] [data-slot="tab-close"]::before { [data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before,
content: ""; [data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + [data-titlebar-tab-slot]::before {
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-close"]::before { [data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
display: block; --tab-title-fade-offset: 4px;
right: 0; -webkit-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%
);
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"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before, [data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"]) -webkit-mask-image: linear-gradient(
[data-slot="tab-close"]::before { to left,
right: 100%; black 0,
black calc(100% - var(--tab-title-fade-offset) - 16px),
transparent calc(100% - var(--tab-title-fade-offset)),
transparent 100%
);
mask-image: linear-gradient(
to left,
black 0,
black calc(100% - var(--tab-title-fade-offset) - 16px),
transparent calc(100% - var(--tab-title-fade-offset)),
transparent 100%
);
}
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] {
--tab-title-fade-offset: 24px;
} }
[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-right: 0; padding-inline-end: 0;
} }
[data-titlebar-tab] [data-slot="tab-title"] { [data-titlebar-tab] [data-slot="tab-title"] {
@@ -5,6 +5,7 @@ import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { 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"
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"
@@ -184,11 +185,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] 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)]" 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]"
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-pressed={props.pressed} data-state={props.active || props.pressed ? "pressed" : undefined}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault() event.preventDefault()
@@ -228,6 +229,7 @@ export function TabNavItem(props: {
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center"> <span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
<Show <Show
when={props.session()} when={props.session()}
keyed
fallback={ fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" /> <span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
} }
@@ -235,8 +237,8 @@ export function TabNavItem(props: {
{(session) => ( {(session) => (
<SessionTabAvatar <SessionTabAvatar
project={project()} project={project()}
directory={session().location.directory} directory={session.location.directory}
sessionId={session().id} sessionId={session.id}
server={props.server} server={props.server}
/> />
)} )}
@@ -280,7 +282,7 @@ export function TabNavItem(props: {
/> />
</a> </a>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]"> <div data-slot="tab-close">
<IconButtonV2 <IconButtonV2
size="small" size="small"
variant="ghost-muted" variant="ghost-muted"
@@ -326,6 +328,7 @@ 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()
@@ -338,8 +341,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-pressed={props.pressed} data-state={props.active || props.pressed ? "pressed" : undefined}
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] 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)]" class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap"
classList={{ invisible: props.hidden }} classList={{ invisible: props.hidden }}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return if (event.button !== MIDDLE_MOUSE_BUTTON) return
@@ -385,7 +388,7 @@ export function DraftTabItem(props: {
{props.title} {props.title}
</span> </span>
</a> </a>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]"> <div data-slot="tab-close">
<IconButtonV2 <IconButtonV2
size="small" size="small"
variant="ghost-muted" variant="ghost-muted"
@@ -400,7 +403,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="Close tab" aria-label={language.t("common.closeTab")}
/> />
</div> </div>
</div> </div>
+22 -7
View File
@@ -1,4 +1,15 @@
import { createEffect, createMemo, createResource, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js" import {
createEffect,
createMemo,
createResource,
createSignal,
Match,
on,
onMount,
Show,
Switch,
untrack,
} from "solid-js"
import { createStore } from "solid-js/store" import { 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"
@@ -41,8 +52,11 @@ 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)
onMount(() => setMount(document.getElementById("opencode-titlebar-right"))) const sync = () => setMount(document.getElementById("opencode-titlebar-right"))
onMount(sync)
createEffect(on(language.direction, sync, { defer: true }))
return mount return mount
} }
@@ -112,9 +126,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
return { return {
visible: version !== undefined || installing, visible: version !== undefined || installing,
installing, installing,
label: "Update", label: language.t("titlebar.update"),
ariaLabel: language.t("toast.update.action.installRestart"), ariaLabel: language.t("toast.update.action.installRestart"),
title: version ? `Update ${version}` : undefined, title: version ? language.t("titlebar.updateVersion", { version }) : undefined,
onInstall: () => props.update?.install(), onInstall: () => props.update?.install(),
} }
}) })
@@ -168,7 +182,8 @@ 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,
"align-self": windows() ? "flex-start" : undefined, // Native Windows caption controls remain on the physical right in both writing directions.
"margin-right": windows() ? "auto" : undefined,
}} }}
data-tauri-drag-region data-tauri-drag-region
> >
@@ -510,7 +525,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={{
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir, "ltr:-translate-x-[36px] rtl:translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(), "duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(), "duration-180 ease-in": layout.sidebar.opened(),
}} }}
@@ -608,7 +623,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">
Update {props.state.label}
</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] right-[6px] left-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true, "group/drawer-content fixed inset-y-[6px] end-[6px] start-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true,
}} }}
{...rest} {...rest}
> >
@@ -8,6 +8,7 @@ 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>
@@ -15,6 +16,7 @@ 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
@@ -43,7 +45,7 @@ export function WindowsAppMenu(props: {
runAction(entry.action) runAction(entry.action)
return return
} }
if (entry.href) props.platform.openLink(entry.href) if (entry.href) props.platform.openExternal(entry.href)
} }
return ( return (
@@ -58,7 +60,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="OpenCode menu" aria-label={language.t("desktop.menu.ariaLabel")}
onPointerDown={rememberFocus} onPointerDown={rememberFocus}
onKeyDown={rememberFocus} onKeyDown={rememberFocus}
/> />
@@ -69,7 +71,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="OpenCode menu" aria-label={language.t("desktop.menu.ariaLabel")}
onPointerDown={rememberFocus} onPointerDown={rememberFocus}
onKeyDown={rememberFocus} onKeyDown={rememberFocus}
/> />
@@ -79,7 +81,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={menu.label}> <DesktopMenuSubmenu label={language.t(menu.labelKey)}>
{menu.items {menu.items
?.filter((entry) => desktopMenuVisible(entry, "windows")) ?.filter((entry) => desktopMenuVisible(entry, "windows"))
.map((entry) => .map((entry) =>
@@ -87,7 +89,7 @@ export function WindowsAppMenu(props: {
<DropdownMenu.Separator /> <DropdownMenu.Separator />
) : ( ) : (
<DesktopMenuItem <DesktopMenuItem
label={entry.label ?? ""} label={entry.labelKey ? language.t(entry.labelKey) : ""}
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(ext?: string[]) { export function filePickerFilters(name: string, ext?: string[]) {
if (!ext || ext.length === 0) return undefined if (!ext || ext.length === 0) return undefined
return [{ name: "Files", extensions: ext }] return [{ name, extensions: ext }]
} }
@@ -16,12 +16,12 @@ const rootSession = (input: { id: string; parentID?: string; archived?: number }
}, },
}) as SessionInfo }) as SessionInfo
const userMessage = (id: string, sessionID: string) => const userMessage = (id: string, sessionID: string, created = 1) =>
({ ({
id, id,
sessionID, sessionID,
role: "user", role: "user",
time: { created: 1 }, time: { created },
agent: "assistant", agent: "assistant",
model: { providerID: "openai", modelID: "gpt" }, model: { providerID: "openai", modelID: "gpt" },
}) as Message }) as Message
@@ -371,13 +371,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_1", sessionID), userMessage("msg_3", sessionID)] }, message: { [sessionID]: [userMessage("msg_z", sessionID, 1), userMessage("msg_b", sessionID, 3)] },
part: { msg_2: [textPart("prt_1", sessionID, "msg_2")] }, part: { msg_a: [textPart("prt_1", sessionID, "msg_a")] },
}), }),
) )
applyDirectoryEvent({ applyDirectoryEvent({
event: { type: "message.updated", properties: { info: userMessage("msg_2", sessionID) } }, event: { type: "message.updated", properties: { info: userMessage("msg_a", sessionID, 2) } },
store, store,
setStore, setStore,
push() {}, push() {},
@@ -385,14 +385,14 @@ describe("applyDirectoryEvent", () => {
loadLsp() {}, loadLsp() {},
}) })
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2", "msg_3"]) expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a", "msg_b"])
applyDirectoryEvent({ applyDirectoryEvent({
event: { event: {
type: "message.updated", type: "message.updated",
properties: { properties: {
info: { info: {
...userMessage("msg_2", sessionID), ...userMessage("msg_a", sessionID, 2),
role: "assistant", role: "assistant",
} as Message, } as Message,
}, },
@@ -404,10 +404,10 @@ describe("applyDirectoryEvent", () => {
loadLsp() {}, loadLsp() {},
}) })
expect(store.message[sessionID]?.find((x) => x.id === "msg_2")?.role).toBe("assistant") expect(store.message[sessionID]?.find((x) => x.id === "msg_a")?.role).toBe("assistant")
applyDirectoryEvent({ applyDirectoryEvent({
event: { type: "message.removed", properties: { sessionID, messageID: "msg_2" } }, event: { type: "message.removed", properties: { sessionID, messageID: "msg_a" } },
store, store,
setStore, setStore,
push() {}, push() {},
@@ -415,8 +415,8 @@ describe("applyDirectoryEvent", () => {
loadLsp() {}, loadLsp() {},
}) })
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_3"]) expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_b"])
expect(store.part.msg_2).toBeUndefined() expect(store.part.msg_a).toBeUndefined()
}) })
test("upserts and prunes message parts", () => { test("upserts and prunes message parts", () => {
@@ -12,6 +12,7 @@ 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([
@@ -271,7 +272,7 @@ export function applyDirectoryEvent(input: {
input.setStore("message", info.sessionID, [info]) input.setStore("message", info.sessionID, [info])
break break
} }
const result = Binary.search(messages, info.id, (m) => m.id) const result = Binary.search(messages, messageKey(info), messageKey)
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
@@ -291,8 +292,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 result = Binary.search(messages, props.messageID, (m) => m.id) const index = messages.findIndex((message) => message.id === props.messageID)
if (result.found) messages.splice(result.index, 1) if (index >= 0) messages.splice(index, 1)
} }
const parts = draft.part[props.messageID] const parts = draft.part[props.messageID]
if (parts) { if (parts) {
@@ -318,7 +319,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, (p) => p.id) const result = Binary.search(parts, part.id, (item) => item.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
@@ -341,13 +342,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, (p) => p.id) const result = Binary.search(parts, props.partID, (part) => part.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, (p) => p.id) const next = Binary.search(list, props.partID, (part) => part.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]
@@ -360,7 +361,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, (p) => p.id) const result = Binary.search(parts, props.partID, (part) => part.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]
+118 -119
View File
@@ -2,100 +2,46 @@ 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 = export type Locale = DesktopNativeLocale
| "en" export type Direction = "ltr" | "rtl"
| "zh"
| "zht" const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "ur", "pa", "fa", "dv"])
| "ko"
| "de" function localeDirection(locale: Locale): Direction {
| "es" return RTL_LOCALES.has(locale) ? "rtl" : "ltr"
| "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[] = [ const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES
"en",
"zh",
"zht",
"ko",
"de",
"es",
"fr",
"da",
"ja",
"pl",
"ru",
"uk",
"bs",
"ar",
"no",
"br",
"th",
"tr",
]
const INTL: Record<Locale, string> = { const INTL = DESKTOP_NATIVE_LOCALE_TAGS
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]])
@@ -121,6 +67,50 @@ 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) {
@@ -138,42 +128,9 @@ 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 {
@@ -194,12 +151,22 @@ function readStoredLocale() {
} }
const warm = readStoredLocale() ?? detectLocale() const warm = readStoredLocale() ?? detectLocale()
if (warm !== "en") void loadDict(warm) const initialLocale =
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 }) => { init: (props: { locale?: Locale; onNativeTranslations?: (bundle: DesktopNativeBundle) => void }) => {
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"]),
@@ -210,6 +177,13 @@ 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,
@@ -220,24 +194,49 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
params?: Record<string, string | number | boolean>, params?: Record<string, string | number | boolean>,
) => string ) => string
const label = (value: Locale) => t(LABEL_KEY[value]) const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => {
const category = pluralCategory(intl(), count)
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
const candidate = `${key}.${category}`
const fallback = `${key}.other`
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count })
}
const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value]
createEffect(() => { createEffect(() => {
if (typeof document !== "object") return if (typeof document !== "object") return
document.documentElement.lang = locale() const value = locale()
document.cookie = cookie(locale()) document.documentElement.lang = intl()
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)
},
} }
}, },
}) })
+8 -3
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 { useParams, useSearchParams } from "@solidjs/router" import { useNavigate, 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,6 +118,7 @@ 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()
@@ -153,6 +154,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
platform, platform,
settings, settings,
language, language,
navigate,
}), }),
}), }),
owner ?? undefined, owner ?? undefined,
@@ -217,6 +219,7 @@ 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
@@ -353,7 +356,9 @@ 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, href) void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
input.navigate(href),
)
} }
}) })
} }
@@ -386,7 +391,7 @@ function createServerNotificationState(input: {
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription")) (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, href) void platform.notify(language.t("notification.session.error.title"), description, () => input.navigate(href))
} }
}) })
} }
+11 -13
View File
@@ -5,6 +5,7 @@ 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 }
@@ -31,26 +32,23 @@ type PlatformBase = {
/** App version */ /** App version */
version?: string version?: string
/** Open a URL in the default browser */ /** Open a web or mail URL in the default system application */
openLink(url: string): void openExternal(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>
/** Navigate back in history */ /** Send a system notification */
back(): void notify(title: string, description?: string, onClick?: () => void): Promise<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?(
@@ -67,6 +65,9 @@ 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
@@ -91,9 +92,6 @@ 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>
+15 -8
View File
@@ -5,6 +5,8 @@ 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
@@ -37,7 +39,7 @@ export interface ImageAttachmentPart {
filename: string filename: string
sourcePath?: string sourcePath?: string
mime: string mime: string
dataUrl: string blob: BlobReference
} }
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
@@ -168,9 +170,9 @@ function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
} }
function promptTarget(serverScope: ServerScope, scope: PromptScope) { function promptTarget(serverScope: ServerScope, scope: PromptScope) {
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt") if ("draftID" in scope) return Persist.prompt(Persist.draft(scope.draftID, "prompt"))
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]) return Persist.prompt(Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]))
} }
function promptStore(initial?: InitialPrompt): PromptStore { function promptStore(initial?: InitialPrompt): PromptStore {
@@ -235,17 +237,22 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<P
return value return value
} }
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt) { function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt, platform?: Platform) {
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial))) const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)), platform)
return { ready, ...createPromptStateValue(store, setStore) } return { ready, ...createPromptStateValue(store, setStore) }
} }
export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) { export function createPromptSession(
return createPersistedPrompt(promptTarget(serverScope, scope), initial) serverScope: ServerScope,
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.draft(draftID, "prompt"), initial) return createPersistedPrompt(Persist.prompt(Persist.draft(draftID, "prompt")), initial)
} }
export type PromptSession = ReturnType<typeof createPromptSession> export type PromptSession = ReturnType<typeof createPromptSession>
@@ -392,6 +392,7 @@ 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 () => {
@@ -1573,7 +1574,7 @@ describe("server session", () => {
await store.sync("child", { force: true }) await store.sync("child", { force: true })
expect(store.data.message.child).toEqual([boundary, older]) expect(store.data.message.child).toEqual([older, boundary])
}) })
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 () => {
+26 -27
View File
@@ -6,7 +6,7 @@ import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus }
import { batch } from "solid-js" import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { rootSession } from "@/utils/session-route" import { rootSession } from "@/utils/session-route"
import { normalizeSessionMessages } from "@/utils/session-message" import { compareMessages, messageKey, 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"
@@ -14,7 +14,6 @@ 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
@@ -100,17 +99,16 @@ 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, item.message.id, (message) => message.id) const result = Binary.search(session, messageKey(item.message), messageKey)
if (!result.found) session.splice(result.index, 0, item.message) const found = result.found
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 = result.found const confirmed = found ? item.parts.filter((part) => current?.some((value) => value.id === part.id)) : []
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found) if (found) observed.push({ messageID: item.message.id, parts: confirmed })
: []
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
part.set( part.set(
item.message.id, item.message.id,
merge( merge(
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []), found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
item.parts.filter((part) => !confirmed.includes(part)), item.parts.filter((part) => !confirmed.includes(part)),
), ),
) )
@@ -147,6 +145,7 @@ 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]))
@@ -169,7 +168,8 @@ 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)
return [...result.values()].sort((a, b) => cmp(a.id, b.id)) const items = [...result.values()]
return options.compare ? items.sort(options.compare) : items
} }
type ServerSessionOptions = { retry?: typeof retry } type ServerSessionOptions = { retry?: typeof retry }
@@ -395,8 +395,7 @@ 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 && Binary.search(messages, messageID, (message) => message.id).found) if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
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)
@@ -419,16 +418,14 @@ 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 && Binary.search(messages, messageID, (message) => message.id).found) if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
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 && Binary.search(messages, messageID, (message) => message.id).found) if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
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)
@@ -536,7 +533,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((a, b) => cmp(a.id, b.id)), session: normalized.messages.sort(compareMessages),
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)),
@@ -656,7 +653,7 @@ export function createServerSession(
const normalized = normalizeSessionMessages(sessionID, source) const normalized = normalizeSessionMessages(sessionID, source)
return { return {
...page, ...page,
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)), session: normalized.messages.sort(compareMessages),
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)),
@@ -673,6 +670,7 @@ 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))
@@ -714,7 +712,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 || cmpMessage(message, oldest) < 0 ? message : oldest), (oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest),
undefined, undefined,
) )
if (generations.get(sessionID) !== active) return if (generations.get(sessionID) !== active) return
@@ -764,14 +762,15 @@ 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" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0))) mode === "prepend" ||
(!result.complete && (!first || ((message: Message) => compareMessages(message, first) < 0)))
applyMessagePage( applyMessagePage(
sessionID, sessionID,
result, result,
@@ -888,7 +887,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((a, b) => cmp(a.id, b.id)) const messages = [...current.filter((item) => item.id !== message.id), message].sort(compareMessages)
projectV2({ sessionID, messages, touched: [message.id] }) projectV2({ sessionID, messages, touched: [message.id] })
}) })
.catch(() => {}) .catch(() => {})
@@ -1011,7 +1010,7 @@ export function createServerSession(
setData("message", info.sessionID, [info]) setData("message", info.sessionID, [info])
return return
} }
const result = Binary.search(messages, info.id, (message) => message.id) const result = Binary.search(messages, messageKey(info), messageKey)
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 = []) => {
@@ -1044,8 +1043,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 result = Binary.search(messages, props.messageID, (message) => message.id) const index = messages.findIndex((message) => message.id === props.messageID)
if (result.found) messages.splice(result.index, 1) if (index >= 0) messages.splice(index, 1)
} }
deleteMessageParts(draft, props.messageID) deleteMessageParts(draft, props.messageID)
}), }),
@@ -1057,7 +1056,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 || !Binary.search(messages, part.messageID, (message) => message.id).found const missing = !messages?.some((message) => message.id === part.messageID)
// 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 &&
@@ -1301,7 +1300,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])) setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
setData( setData(
"part_text_accum_delta", "part_text_accum_delta",
produce((draft) => { produce((draft) => {
+2 -3
View File
@@ -662,16 +662,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
integrationID: server.integrationID, integrationID: server.integrationID,
location: { directory: key }, location: { directory: key },
}) })
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.prompts?.length) const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.form?.length)
if (!method || method.type !== "oauth") if (!method || method.type !== "oauth")
throw new Error(`MCP server ${name} requires an interactive authentication form`) throw new Error(`MCP server ${name} requires an interactive authentication form`)
const attempt = await serverSDK.api.integration.oauth.connect({ const attempt = await serverSDK.api.integration.oauth.connect({
integrationID: server.integrationID, integrationID: server.integrationID,
methodID: method.id, methodID: method.id,
inputs: {},
location: { directory: key }, location: { directory: key },
}) })
platform.openLink(attempt.data.url) platform.openExternal(attempt.data.url)
}, },
refresh: async () => { refresh: async () => {
await queryClient.refetchQueries(queryOptionsApi.mcp(key)) await queryClient.refetchQueries(queryOptionsApi.mcp(key))
@@ -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): Message => ({ const userMessage = (id: string, sessionID: string, created = 1): Message => ({
id, id,
sessionID, sessionID,
role: "user", role: "user",
time: { created: 1 }, time: { created },
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 message in sorted order and stores parts", () => { test("applyOptimisticAdd inserts by creation time", () => {
const sessionID = "ses_1" const sessionID = "ses_1"
const draft = { const draft = {
message: { [sessionID]: [userMessage("msg_2", sessionID)] }, message: { [sessionID]: [userMessage("msg_z", sessionID, 1)] },
part: {} as Record<string, Part[] | undefined>, part: {} as Record<string, Part[] | undefined>,
} }
applyOptimisticAdd(draft, { applyOptimisticAdd(draft, {
sessionID, sessionID,
message: userMessage("msg_1", sessionID), message: userMessage("msg_a", sessionID, 2),
parts: [textPart("prt_2", sessionID, "msg_1"), textPart("prt_1", sessionID, "msg_1")], parts: [textPart("prt_2", sessionID, "msg_a"), textPart("prt_1", sessionID, "msg_a")],
}) })
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2"]) expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
expect(draft.part.msg_1?.map((x) => x.id)).toEqual(["prt_1", "prt_2"]) expect(draft.part.msg_a?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
}) })
test("applyOptimisticRemove removes message and part entries", () => { test("applyOptimisticRemove removes message and part entries", () => {
@@ -60,19 +60,33 @@ describe("sync optimistic reducers", () => {
const sessionID = "ses_1" const sessionID = "ses_1"
const page = mergeOptimisticPage( const page = mergeOptimisticPage(
{ {
session: [userMessage("msg_1", sessionID)], session: [userMessage("msg_z", sessionID, 1)],
part: [{ id: "msg_1", part: [textPart("prt_1", sessionID, "msg_1")] }], part: [{ id: "msg_z", part: [textPart("prt_1", sessionID, "msg_z")] }],
complete: true, complete: true,
}, },
[{ message: userMessage("msg_2", sessionID), parts: [textPart("prt_2", sessionID, "msg_2")] }], [{ message: userMessage("msg_a", sessionID, 2), parts: [textPart("prt_2", sessionID, "msg_a")] }],
) )
expect(page.session.map((x) => x.id)).toEqual(["msg_1", "msg_2"]) expect(page.session.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_2"]) expect(page.part.find((x) => x.id === "msg_a")?.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(
+5 -4
View File
@@ -3,6 +3,7 @@ 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 "@/types" import type { Message, Part } from "@/types"
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"])
@@ -67,7 +68,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, item.message.id, (message) => message.id) const result = Binary.search(session, messageKey(item.message), messageKey)
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)
@@ -92,7 +93,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, input.message.id, (m) => m.id) const result = Binary.search(messages, messageKey(input.message), messageKey)
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]
@@ -103,8 +104,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 result = Binary.search(messages, input.messageID, (m) => m.id) const index = messages.findIndex((message) => message.id === input.messageID)
if (result.found) messages.splice(result.index, 1) if (index >= 0) messages.splice(index, 1)
} }
delete draft.part[input.messageID] delete draft.part[input.messageID]
} }
+23
View File
@@ -0,0 +1,23 @@
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,6 +3,7 @@ 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
@@ -11,6 +12,23 @@ 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) => {
+6 -8
View File
@@ -12,6 +12,7 @@ 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"
@@ -59,13 +60,7 @@ 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) => { migrate: (value: unknown) => migrateTabs(value, fallback),
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[]>([]),
) )
@@ -106,7 +101,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
} }
const removeDraftPersisted = (draftID: string) => { const removeDraftPersisted = (draftID: string) => {
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform) for (const key of draftPersistedKeys()) {
const target = Persist.draft(draftID, key)
removePersisted(key === "prompt" ? Persist.prompt(target) : target, platform)
}
} }
const removeInfo = (key: string) => { const removeInfo = (key: string) => {
+11 -1
View File
@@ -4,10 +4,20 @@ 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.label === "Export Logs...", (item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs",
) )
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)
})
}) })
+130 -51
View File
@@ -1,3 +1,5 @@
import type { DesktopNativeKey } from "./i18n/desktop-native"
export type DesktopMenuPlatform = "macos" | "windows" export type DesktopMenuPlatform = "macos" | "windows"
export type DesktopMenuAction = export type DesktopMenuAction =
@@ -44,7 +46,7 @@ export type DesktopMenuRole =
export type DesktopMenuItem = { export type DesktopMenuItem = {
type: "item" type: "item"
label?: string labelKey?: DesktopNativeKey
command?: string command?: string
action?: DesktopMenuAction action?: DesktopMenuAction
role?: DesktopMenuRole role?: DesktopMenuRole
@@ -63,7 +65,7 @@ export type DesktopMenuEntry = DesktopMenuItem | DesktopMenuSeparator
export type DesktopMenu = { export type DesktopMenu = {
id: string id: string
label: string labelKey: DesktopNativeKey
role?: DesktopMenuRole role?: DesktopMenuRole
items?: DesktopMenuEntry[] items?: DesktopMenuEntry[]
platforms?: DesktopMenuPlatform[] platforms?: DesktopMenuPlatform[]
@@ -72,15 +74,20 @@ export type DesktopMenu = {
export const DESKTOP_MENU: DesktopMenu[] = [ export const DESKTOP_MENU: DesktopMenu[] = [
{ {
id: "app", id: "app",
label: "OpenCode", labelKey: "desktop.menu.app",
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", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } }, type: "item",
{ type: "item", label: "Reload Webview", action: "view.reload" }, labelKey: "desktop.menu.checkForUpdates",
{ type: "item", label: "Restart", action: "app.relaunch" }, action: "app.checkForUpdates",
{ type: "item", label: "Export Logs...", command: "logs.export" }, enabled: "updater",
},
{ type: "item", labelKey: "desktop.menu.settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
{ type: "item", labelKey: "desktop.menu.reloadWebview", action: "view.reload" },
{ type: "item", labelKey: "desktop.menu.restart", action: "app.relaunch" },
{ type: "item", labelKey: "desktop.menu.exportLogs", command: "logs.export" },
{ type: "separator" }, { type: "separator" },
{ type: "item", role: "hide" }, { type: "item", role: "hide" },
{ type: "item", role: "hideOthers" }, { type: "item", role: "hideOthers" },
@@ -91,46 +98,81 @@ export const DESKTOP_MENU: DesktopMenu[] = [
}, },
{ {
id: "file", id: "file",
label: "File", labelKey: "desktop.menu.file",
items: [ items: [
{ {
type: "item", type: "item",
label: "New Session", labelKey: "desktop.menu.newSession",
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",
label: "Settings", labelKey: "desktop.menu.openProject",
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",
label: "New Window", labelKey: "desktop.menu.newWindow",
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", label: "Close Window", action: "window.close", role: "close" }, { type: "item", labelKey: "desktop.menu.closeWindow", action: "window.close", role: "close" },
], ],
}, },
{ {
id: "edit", id: "edit",
label: "Edit", labelKey: "desktop.menu.edit",
items: [ items: [
{ type: "item", label: "Undo", action: "edit.undo", role: "undo", accelerator: { windows: "Ctrl+Z" } },
{ type: "item", label: "Redo", action: "edit.redo", role: "redo", accelerator: { windows: "Ctrl+Y" } },
{ type: "separator" },
{ type: "item", label: "Cut", action: "edit.cut", role: "cut", accelerator: { windows: "Ctrl+X" } },
{ type: "item", label: "Copy", action: "edit.copy", role: "copy", accelerator: { windows: "Ctrl+C" } },
{ type: "item", label: "Paste", action: "edit.paste", role: "paste", accelerator: { windows: "Ctrl+V" } },
{ type: "item", label: "Delete", action: "edit.delete" },
{ {
type: "item", type: "item",
label: "Select All", labelKey: "desktop.menu.undo",
action: "edit.undo",
role: "undo",
accelerator: { windows: "Ctrl+Z" },
},
{
type: "item",
labelKey: "desktop.menu.redo",
action: "edit.redo",
role: "redo",
accelerator: { windows: "Ctrl+Y" },
},
{ type: "separator" },
{
type: "item",
labelKey: "desktop.menu.cut",
action: "edit.cut",
role: "cut",
accelerator: { windows: "Ctrl+X" },
},
{
type: "item",
labelKey: "desktop.menu.copy",
action: "edit.copy",
role: "copy",
accelerator: { windows: "Ctrl+C" },
},
{
type: "item",
labelKey: "desktop.menu.paste",
action: "edit.paste",
role: "paste",
accelerator: { windows: "Ctrl+V" },
},
{ type: "item", labelKey: "desktop.menu.delete", action: "edit.delete" },
{
type: "item",
labelKey: "desktop.menu.selectAll",
action: "edit.selectAll", action: "edit.selectAll",
role: "selectAll", role: "selectAll",
accelerator: { windows: "Ctrl+A" }, accelerator: { windows: "Ctrl+A" },
@@ -139,47 +181,84 @@ export const DESKTOP_MENU: DesktopMenu[] = [
}, },
{ {
id: "view", id: "view",
label: "View", labelKey: "desktop.menu.view",
items: [ items: [
{ type: "item", label: "Toggle Sidebar", command: "sidebar.toggle" }, { type: "item", labelKey: "desktop.menu.toggleSidebar", command: "sidebar.toggle" },
{ type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } }, {
{ type: "item", label: "Toggle File Tree", command: "fileTree.toggle" }, type: "item",
labelKey: "desktop.menu.toggleTerminal",
command: "terminal.toggle",
accelerator: { macos: "Ctrl+`" },
},
{ type: "item", labelKey: "desktop.menu.toggleFileTree", command: "fileTree.toggle" },
{ type: "separator" }, { type: "separator" },
{ type: "item", label: "Reload", action: "view.reload", role: "reload" }, { type: "item", labelKey: "desktop.menu.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",
label: "Actual Size", labelKey: "desktop.menu.actualSize",
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", label: "Zoom Out", action: "view.zoomOut", role: "zoomOut", accelerator: { windows: "Ctrl+-" } }, type: "item",
labelKey: "desktop.menu.zoomIn",
action: "view.zoomIn",
role: "zoomIn",
accelerator: { windows: "Ctrl++" },
},
{
type: "item",
labelKey: "desktop.menu.zoomOut",
action: "view.zoomOut",
role: "zoomOut",
accelerator: { windows: "Ctrl+-" },
},
{ type: "separator" }, { type: "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",
label: "Go", labelKey: "desktop.menu.go",
items: [ items: [
{ type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } }, { type: "item", labelKey: "desktop.menu.back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
{ type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } }, { type: "item", labelKey: "desktop.menu.forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
{ type: "separator" },
{ type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } },
{ type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } },
{ type: "separator" }, { type: "separator" },
{ {
type: "item", type: "item",
label: "Previous Project", labelKey: "desktop.menu.previousSession",
command: "session.previous",
accelerator: { macos: "Option+Up" },
},
{
type: "item",
labelKey: "desktop.menu.nextSession",
command: "session.next",
accelerator: { macos: "Option+Down" },
},
{ type: "separator" },
{
type: "item",
labelKey: "desktop.menu.previousProject",
command: "project.previous", command: "project.previous",
accelerator: { macos: "Cmd+Option+Up" }, accelerator: { macos: "Cmd+Option+Up" },
}, },
{ {
type: "item", type: "item",
label: "Next Project", labelKey: "desktop.menu.nextProject",
command: "project.next", command: "project.next",
accelerator: { macos: "Cmd+Option+Down" }, accelerator: { macos: "Cmd+Option+Down" },
}, },
@@ -187,31 +266,31 @@ export const DESKTOP_MENU: DesktopMenu[] = [
}, },
{ {
id: "window", id: "window",
label: "Window", labelKey: "desktop.menu.window",
role: "windowMenu", role: "windowMenu",
items: [ items: [
{ type: "item", label: "Minimize", action: "window.minimize" }, { type: "item", labelKey: "desktop.menu.minimize", action: "window.minimize" },
{ type: "item", label: "Maximize", action: "window.toggleMaximize" }, { type: "item", labelKey: "desktop.menu.maximize", action: "window.toggleMaximize" },
{ type: "separator" }, { type: "separator" },
{ type: "item", label: "Close Window", action: "window.close" }, { type: "item", labelKey: "desktop.menu.closeWindow", action: "window.close" },
], ],
}, },
{ {
id: "help", id: "help",
label: "Help", labelKey: "desktop.menu.help",
items: [ items: [
{ type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" }, { type: "item", labelKey: "desktop.menu.documentation", href: "https://opencode.ai/docs" },
{ type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" }, { type: "item", labelKey: "desktop.menu.supportForum", href: "https://discord.com/invite/opencode" },
{ type: "item", label: "Export Logs...", command: "logs.export" }, { type: "item", labelKey: "desktop.menu.exportLogs", command: "logs.export" },
{ type: "separator" }, { type: "separator" },
{ {
type: "item", type: "item",
label: "Share Feedback", labelKey: "desktop.menu.shareFeedback",
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",
label: "Report a Bug", labelKey: "desktop.menu.reportBug",
href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml", href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml",
}, },
], ],
+39 -41
View File
@@ -3,10 +3,11 @@
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"
@@ -54,7 +55,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, href) => { const notify: Platform["notify"] = async (title, description, onClick) => {
if (!("Notification" in window)) return if (!("Notification" in window)) return
const permission = const permission =
@@ -73,21 +74,17 @@ const notify: Platform["notify"] = async (title, description, href) => {
}) })
notification.onclick = () => { notification.onclick = () => {
handleNotificationClick(href) window.focus()
onClick?.()
notification.close() notification.close()
} }
} }
const openLink: Platform["openLink"] = (url) => { const openExternal: Platform["openExternal"] = (value) => {
window.open(url, "_blank") if (!URL.canParse(value)) return
} const url = new URL(value)
if (url.protocol !== "http:" && url.protocol !== "https:" && url.protocol !== "mailto:") return
const back: Platform["back"] = () => { window.open(url.href, "_blank", "noopener,noreferrer")
window.history.back()
}
const forward: Platform["forward"] = () => {
window.history.forward()
} }
const restart: Platform["restart"] = async () => { const restart: Platform["restart"] = async () => {
@@ -121,10 +118,9 @@ const clearAuthToken = () => {
const platform: Platform = { const platform: Platform = {
platform: "web", platform: "web",
draftStore: createBrowserDraftStore(),
version: pkg.version, version: pkg.version,
openLink, openExternal,
back,
forward,
restart, restart,
notify, notify,
getDefaultServer: async () => { getDefaultServer: async () => {
@@ -154,29 +150,31 @@ if (import.meta.env.VITE_SENTRY_DSN) {
} }
if (root instanceof HTMLElement) { if (root instanceof HTMLElement) {
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token")) void loadInitialLocale().then((locale) => {
clearAuthToken() const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
const server: ServerConnection.Http = { clearAuthToken()
type: "http", const server: ServerConnection.Http = {
authToken: !!auth, type: "http",
http: { authToken: !!auth,
url: getCurrentUrl(), http: {
...auth, url: getCurrentUrl(),
}, ...auth,
} },
render( }
() => ( render(
<PlatformProvider value={platform}> () => (
<AppBaseProviders> <PlatformProvider value={platform}>
<AppInterface <AppBaseProviders locale={locale}>
defaultServer={ServerConnection.Key.make(getDefaultUrl())} <AppInterface
canonicalLocalServer={ServerConnection.key(server)} defaultServer={ServerConnection.Key.make(getDefaultUrl())}
servers={[server]} canonicalLocalServer={ServerConnection.key(server)}
disableHealthCheck servers={[server]}
/> disableHealthCheck
</AppBaseProviders> />
</PlatformProvider> </AppBaseProviders>
), </PlatformProvider>
root, ),
) root,
)
})
} }
File diff suppressed because it is too large Load Diff
+245 -82
View File
@@ -1,4 +1,102 @@
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": "مشروع",
@@ -16,6 +114,7 @@ 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": "داكن",
@@ -59,16 +158,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": "تبديل MCPs", "command.mcp.toggle": "تبديل خوادم MCP",
"command.mcp.toggle.description": "تبديل MCPs", "command.mcp.toggle.description": "تبديل خوادم MCP",
"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": "Prompt", "command.prompt.mode.normal": "موجّه",
"command.permissions.autoaccept.enable": "قبول الأذونات تلقائيًا", "command.permissions.autoaccept.enable": "قبول الأذونات تلقائيًا",
"command.permissions.autoaccept.disable": "إيقاف قبول الأذونات تلقائيًا", "command.permissions.autoaccept.disable": "إيقاف قبول الأذونات تلقائيًا",
"command.workspace.toggle": "تبديل مساحات العمل", "command.workspace.toggle": "تبديل مساحات العمل",
@@ -85,6 +184,9 @@ 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": "لا توجد نتائج",
@@ -94,13 +196,14 @@ 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 أو مفتاح API", "dialog.provider.anthropic.note": "وصول مباشر إلى نماذج Claude، بما فيها Pro و Max",
"dialog.provider.copilot.note": "اتصل باستخدام Copilot أو مفتاح API", "dialog.provider.copilot.note": "نماذج ذكاء اصطناعي للمساعدة في البرمجة عبر GitHub Copilot",
"dialog.provider.openai.note": "اتصل باستخدام ChatGPT Pro/Plus أو مفتاح API", "dialog.provider.openai.note": "نماذج GPT لمهام الذكاء الاصطناعي العامة السريعة والمتقدمة",
"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": "وصول موحد إلى نماذج الذكاء الاصطناعي مع توجيه ذكي",
@@ -112,17 +215,20 @@ 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":
"أدخل مفتاح واجهة برمجة تطبيقات {{provider}} الخاص بك لتوصيل حسابك واستخدام نماذج {{provider}} في OpenCode.", "أدخل مفتاح API الخاص بـ {{provider}} لتوصيل حسابك واستخدام نماذج {{provider}} في OpenCode.",
"provider.connect.apiKey.label": "مفتاح واجهة برمجة تطبيقات {{provider}}", "provider.connect.apiKey.label": "مفتاح API لـ {{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":
@@ -148,6 +254,7 @@ 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": ".",
@@ -196,26 +303,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": "جارٍ الحفظ...",
@@ -227,7 +334,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", "prompt.mode.normal": "موجّه",
"prompt.mode.shell.exit": "esc للخروج", "prompt.mode.shell.exit": "esc للخروج",
"session.child.promptDisabled": "لا يمكن إرسال موجّهات إلى جلسات الوكلاء الفرعيين.", "session.child.promptDisabled": "لا يمكن إرسال موجّهات إلى جلسات الوكلاء الفرعيين.",
"session.child.backToParent": "العودة إلى الجلسة الرئيسية.", "session.child.backToParent": "العودة إلى الجلسة الرئيسية.",
@@ -249,17 +356,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": "إضافة تسجيل الدخول (logging) في جميع أنحاء قاعدة التعليمات البرمجية", "prompt.example.19": "إضافة التسجيل في جميع أنحاء قاعدة التعليمات البرمجية",
"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",
@@ -267,7 +374,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": "الأوامر",
@@ -275,8 +382,9 @@ 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": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.",
@@ -286,10 +394,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": "MCPs", "dialog.mcp.title": "خوادم MCP",
"dialog.mcp.description": "{{enabled}} من {{total}} مفعل", "dialog.mcp.description": "تم تفعيل {{enabled}} من أصل {{total}}",
"dialog.mcp.empty": "لم يتم تكوين MCPs", "dialog.mcp.empty": "لم يتم تكوين أي خوادم MCP",
"dialog.lsp.empty": م الكشف تلقائيًا عن LSPs من أنواع الملفات", "dialog.lsp.empty": ُكتشف خوادم LSP تلقائيًا من أنواع الملفات",
"dialog.plugins.empty": "الإضافات المكونة في opencode.json", "dialog.plugins.empty": "الإضافات المكونة في opencode.json",
"mcp.status.connected": "متصل", "mcp.status.connected": "متصل",
"mcp.status.failed": "فشل", "mcp.status.failed": "فشل",
@@ -400,6 +508,21 @@ 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": "أيقونة",
@@ -408,35 +531,39 @@ 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": "انقر لعرض السياق",
@@ -464,9 +591,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": "الآن يتم عرض عدة worktrees في الشريط الجانبي", "toast.workspace.enabled.description": "تُعرض الآن عدة أشجار عمل في الشريط الجانبي",
"toast.workspace.disabled.title": "تم تعطيل مساحات العمل", "toast.workspace.disabled.title": "تم تعطيل مساحات العمل",
"toast.workspace.disabled.description": "يتم عرض worktree الرئيسي فقط في الشريط الجانبي", "toast.workspace.disabled.description": "تُعرض شجرة العمل الرئيسية فقط في الشريط الجانبي",
"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": "تم إيقاف قبول الأذونات تلقائيًا",
@@ -486,9 +613,14 @@ 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": "حدث خطأ ما",
@@ -508,7 +640,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",
@@ -536,7 +668,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": "الرئيسية",
@@ -554,7 +686,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": "سياق",
@@ -564,7 +696,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": "جارٍ تحميل التغييرات...",
@@ -572,7 +704,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": "كل الملفات",
@@ -590,14 +722,26 @@ 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.other": "{{count}} رسائل في الانتظار", "session.followupDock.summary.zero": "عدد الرسائل في قائمة الانتظار: {{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": "توسيع الرسائل التي تم التراجع عنها",
@@ -649,12 +793,19 @@ 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": "حذف",
@@ -671,7 +822,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": "لا توجد مشاريع مفتوحة",
@@ -690,7 +841,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 المحطة الطرفية",
@@ -727,10 +878,11 @@ 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":
"settings.general.row.reasoningSummaries.title": "إظهار ملخصات الاستنتاج", "التبديل بين الوكلاء في محرر الرسائل. عند إخفائه، يُستخدم وكيل Build افتراضيًا.",
"settings.general.row.reasoningSummaries.description": "عرض ملخصات استنتاج النموذج في الشريط الزمني", "settings.general.row.reasoningSummaries.title": "إظهار ملخصات الاستدلال",
"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 موسعة بشكل افتراضي في الشريط الزمني",
@@ -743,11 +895,12 @@ 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": "السماح بإيماءة القرص على لوحة اللمس وإيماءة Ctrl-scroll للتكبير", "settings.general.row.pinchZoom.description":
"السماح بإيماءات مقاربة الأصابع ومباعدتها على لوحة التعقب والتمرير مع الضغط على 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": "ملاحظات الإصدار",
@@ -760,7 +913,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",
@@ -822,17 +975,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": "موجه",
@@ -857,19 +1010,18 @@ 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",
@@ -887,7 +1039,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": "حذف الجلسة",
@@ -895,7 +1047,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": "إعادة تعيين مساحة العمل",
@@ -905,7 +1057,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": "حذف مساحة العمل",
@@ -921,7 +1073,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": "تكوين غير صالح",
@@ -931,22 +1083,25 @@ 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",
@@ -964,9 +1119,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",
@@ -978,10 +1133,18 @@ 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",
@@ -997,7 +1160,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
File diff suppressed because it is too large Load Diff
+201 -44
View File
@@ -1,4 +1,104 @@
export const dict = { export const dict = {
"desktop.menu.app": "OpenCode",
"desktop.menu.file": "Arquivo",
"desktop.menu.edit": "Editar",
"desktop.menu.view": "Visualizar",
"desktop.menu.go": "Ir",
"desktop.menu.window": "Janela",
"desktop.menu.help": "Ajuda",
"desktop.menu.checkForUpdates": "Verificar atualizações...",
"desktop.menu.settings": "Configurações",
"desktop.menu.reloadWebview": "Recarregar WebView",
"desktop.menu.restart": "Reiniciar",
"desktop.menu.exportLogs": "Exportar logs...",
"desktop.menu.newSession": "Nova sessão",
"desktop.menu.openProject": "Abrir projeto...",
"desktop.menu.newWindow": "Nova janela",
"desktop.menu.closeWindow": "Fechar janela",
"desktop.menu.undo": "Desfazer",
"desktop.menu.redo": "Refazer",
"desktop.menu.cut": "Recortar",
"desktop.menu.copy": "Copiar",
"desktop.menu.paste": "Colar",
"desktop.menu.delete": "Excluir",
"desktop.menu.selectAll": "Selecionar tudo",
"desktop.menu.toggleSidebar": "Alternar barra lateral",
"desktop.menu.toggleTerminal": "Alternar terminal",
"desktop.menu.toggleFileTree": "Alternar árvore de arquivos",
"desktop.menu.reload": "Recarregar",
"desktop.menu.toggleDeveloperTools": "Alternar ferramentas de desenvolvedor",
"desktop.menu.actualSize": "Tamanho real",
"desktop.menu.zoomIn": "Ampliar",
"desktop.menu.zoomOut": "Reduzir",
"desktop.menu.toggleFullScreen": "Alternar tela inteira",
"desktop.menu.back": "Voltar",
"desktop.menu.forward": "Avançar",
"desktop.menu.previousSession": "Sessão anterior",
"desktop.menu.nextSession": "Próxima sessão",
"desktop.menu.previousProject": "Projeto anterior",
"desktop.menu.nextProject": "Próximo projeto",
"desktop.menu.minimize": "Minimizar",
"desktop.menu.maximize": "Maximizar",
"desktop.menu.documentation": "Documentação do OpenCode",
"desktop.menu.supportForum": "Fórum de suporte",
"desktop.menu.shareFeedback": "Enviar comentários",
"desktop.menu.reportBug": "Relatar um bug",
"desktop.menu.ariaLabel": "Menu do OpenCode",
"desktop.updater.dialog.checkFailed.message": "Falha ao verificar atualizações.",
"desktop.updater.dialog.checkFailed.title": "Erro de atualização",
"desktop.updater.dialog.upToDate.message": "Você está em dia.",
"desktop.updater.dialog.upToDate.title": "Nenhuma atualização",
"desktop.updater.dialog.ready.message": "A atualização {{version}} foi baixada. Deseja reiniciar agora?",
"desktop.updater.dialog.ready.title": "Atualização pronta",
"desktop.updater.dialog.restart": "Reiniciar",
"desktop.updater.dialog.later": "Mais tarde",
"desktop.recovery.action.relaunch": "Reabrir",
"desktop.recovery.action.exportLogs": "Exportar logs",
"desktop.recovery.action.keepWaiting": "Continuar aguardando",
"desktop.recovery.action.quit": "Encerrar",
"desktop.recovery.loadFailed": "Falha ao carregar o OpenCode",
"desktop.recovery.terminated": "A janela do OpenCode foi encerrada inesperadamente",
"desktop.recovery.unresponsive": "O OpenCode não está respondendo",
"desktop.recovery.unresponsive.detail": "Você pode reabrir o aplicativo, abrir os logs ou continuar aguardando.",
"desktop.recovery.loadFailed.detail": "Janela: {{window}}\nURL: {{url}}\nErro: {{code}} {{description}}",
"desktop.recovery.terminated.detail": "Janela: {{window}}\nMotivo: {{reason}}\nCódigo: {{code}}",
"desktop.recovery.unknown": "<desconhecido>",
"desktop.dialog.chooseFolder": "Escolher uma pasta",
"desktop.dialog.chooseFile": "Escolher um arquivo",
"desktop.dialog.saveFile": "Salvar arquivo",
"desktop.dialog.files": "Arquivos",
"desktop.server.local": "Servidor local",
"desktop.wsl.error.windowsOnly": "O WSL está disponível somente no Windows",
"desktop.wsl.error.unavailable": "O WSL está indisponível",
"desktop.wsl.error.listInstalled": "Falha ao listar as distribuições WSL instaladas",
"desktop.wsl.error.listOnline": "Falha ao listar as distribuições WSL disponíveis online",
"desktop.wsl.error.executeDistro": "Não é possível executar comandos na distribuição",
"desktop.wsl.error.installWsl": "Falha na instalação do WSL",
"desktop.wsl.error.installDistro": "Falha ao instalar a distribuição: {{distro}}",
"desktop.wsl.error.installOpencode": "Falha na instalação do OpenCode",
"desktop.wsl.error.alreadyAdded": "{{distro}} já foi adicionada",
"desktop.wsl.error.opencodeMissing": "O opencode não está instalado nesta distribuição",
"desktop.wsl.error.opencodeCannotRun": "O opencode está instalado, mas não pôde ser executado",
"desktop.wsl.error.opencodeNotInstalled": "O OpenCode não está instalado em {{distro}}",
"desktop.wsl.error.updateVersion":
"A atualização do OpenCode foi concluída, mas {{distro}} ainda informa a versão {{installed}}; a versão esperada é {{expected}}",
"desktop.wsl.error.noVersion": "sem versão",
"desktop.wsl.error.serverExited":
"O servidor WSL foi encerrado após a inicialização (código={{code}} sinal={{signal}})",
"desktop.wsl.error.serverExitedBeforeHealthy":
"O servidor WSL foi encerrado antes de ficar íntegro (código={{code}} sinal={{signal}}){{output}}",
"desktop.wsl.error.healthTimeout":
"O tempo limite da verificação de integridade do sidecar de {{distro}} foi excedido após {{timeout}}ms",
"desktop.wsl.error.commandTimeout": "O tempo limite de {{command}} {{args}} foi excedido após {{timeout}}ms",
"desktop.wsl.error.failedPort": "Falha ao obter a porta",
"desktop.picker.error.notSelected": "O arquivo não foi selecionado pelo seletor",
"desktop.picker.error.sizeLimit": "Os anexos selecionados excedem o limite de {{limit}} MB",
"command.category.suggested": "Sugerido", "command.category.suggested": "Sugerido",
"command.category.view": "Visualizar", "command.category.view": "Visualizar",
"command.category.project": "Projeto", "command.category.project": "Projeto",
@@ -16,6 +116,7 @@ export const dict = {
"command.category.permissions": "Permissões", "command.category.permissions": "Permissões",
"command.category.workspace": "Espaço de trabalho", "command.category.workspace": "Espaço de trabalho",
"command.category.settings": "Configurações", "command.category.settings": "Configurações",
"command.logs.export": "Exportar logs",
"theme.scheme.system": "Sistema", "theme.scheme.system": "Sistema",
"theme.scheme.light": "Claro", "theme.scheme.light": "Claro",
"theme.scheme.dark": "Escuro", "theme.scheme.dark": "Escuro",
@@ -85,6 +186,9 @@ export const dict = {
"command.session.share.description": "Compartilhar esta sessão e copiar a URL para a área de transferência", "command.session.share.description": "Compartilhar esta sessão e copiar a URL para a área de transferência",
"command.session.unshare": "Parar de compartilhar sessão", "command.session.unshare": "Parar de compartilhar sessão",
"command.session.unshare.description": "Parar de compartilhar esta sessão", "command.session.unshare.description": "Parar de compartilhar esta sessão",
"command.session.export": "Exportar sessão",
"command.session.export.description": "Exportar a transcrição completa da sessão como JSON",
"palette.search.placeholder": "Buscar arquivos, comandos e sessões", "palette.search.placeholder": "Buscar arquivos, comandos e sessões",
"palette.search.placeholder.home": "Buscar comandos e sessões", "palette.search.placeholder.home": "Buscar comandos e sessões",
"palette.empty": "Nenhum resultado encontrado", "palette.empty": "Nenhum resultado encontrado",
@@ -94,13 +198,14 @@ export const dict = {
"dialog.provider.empty": "Nenhum provedor encontrado", "dialog.provider.empty": "Nenhum provedor encontrado",
"dialog.provider.group.popular": "Popular", "dialog.provider.group.popular": "Popular",
"dialog.provider.group.other": "Outro", "dialog.provider.group.other": "Outro",
"dialog.provider.custom.label": "Provedor personalizado compatível com OpenAI",
"dialog.provider.tag.recommended": "Recomendado", "dialog.provider.tag.recommended": "Recomendado",
"dialog.provider.opencode.note": "Modelos selecionados incluindo Claude, GPT, Gemini e mais", "dialog.provider.opencode.note": "Modelos selecionados incluindo Claude, GPT, Gemini e mais",
"dialog.provider.opencode.tagline": "Modelos otimizados e confiáveis", "dialog.provider.opencode.tagline": "Modelos otimizados e confiáveis",
"dialog.provider.opencodeGo.tagline": "Assinatura de baixo custo para todos", "dialog.provider.opencodeGo.tagline": "Assinatura de baixo custo para todos",
"dialog.provider.anthropic.note": "Conectar com Claude Pro/Max ou chave de API", "dialog.provider.anthropic.note": "Acesso direto aos modelos Claude, incluindo Pro e Max",
"dialog.provider.copilot.note": "Conectar com Copilot ou chave de API", "dialog.provider.copilot.note": "Modelos de IA para auxílio à programação pelo GitHub Copilot",
"dialog.provider.openai.note": "Conectar com ChatGPT Pro/Plus ou chave de API", "dialog.provider.openai.note": "Modelos GPT rápidos e avançados para tarefas gerais de IA",
"dialog.provider.google.note": "Modelos Gemini para respostas rápidas e estruturadas", "dialog.provider.google.note": "Modelos Gemini para respostas rápidas e estruturadas",
"dialog.provider.openrouter.note": "Acesse todos os modelos suportados de um único provedor", "dialog.provider.openrouter.note": "Acesse todos os modelos suportados de um único provedor",
"dialog.provider.vercel.note": "Acesso unificado a modelos de IA com roteamento inteligente", "dialog.provider.vercel.note": "Acesso unificado a modelos de IA com roteamento inteligente",
@@ -112,11 +217,14 @@ export const dict = {
"dialog.model.manage.provider.toggle": "Alternar todos os modelos {{provider}}", "dialog.model.manage.provider.toggle": "Alternar todos os modelos {{provider}}",
"dialog.model.unpaid.freeModels.title": "Modelos gratuitos fornecidos pelo OpenCode", "dialog.model.unpaid.freeModels.title": "Modelos gratuitos fornecidos pelo OpenCode",
"dialog.model.unpaid.addMore.title": "Adicionar mais modelos de provedores populares", "dialog.model.unpaid.addMore.title": "Adicionar mais modelos de provedores populares",
"dialog.model.unpaid.viewMoreProviders": "Ver mais de 70 provedores",
"dialog.provider.viewAll": "Ver mais provedores", "dialog.provider.viewAll": "Ver mais provedores",
"provider.connect.title": "Conectar {{provider}}", "provider.connect.title": "Conectar {{provider}}",
"provider.connect.title.anthropicProMax": "Entrar com Claude Pro/Max", "provider.connect.title.anthropicProMax": "Entrar com Claude Pro/Max",
"provider.connect.selectMethod": "Selecionar método de login para {{provider}}.", "provider.connect.selectMethod": "Selecionar método de login para {{provider}}.",
"provider.connect.method.apiKey": "Chave de API", "provider.connect.method.apiKey": "Chave de API",
"provider.connect.method.browser": "Navegador",
"provider.connect.method.headless": "Sem interface gráfica",
"provider.connect.status.inProgress": "Autorização em andamento...", "provider.connect.status.inProgress": "Autorização em andamento...",
"provider.connect.status.waiting": "Aguardando autorização...", "provider.connect.status.waiting": "Aguardando autorização...",
"provider.connect.status.failed": "Autorização falhou: {{error}}", "provider.connect.status.failed": "Autorização falhou: {{error}}",
@@ -148,15 +256,16 @@ export const dict = {
"provider.connect.toast.connected.title": "{{provider}} conectado", "provider.connect.toast.connected.title": "{{provider}} conectado",
"provider.connect.toast.connected.description": "Modelos do {{provider}} agora estão disponíveis para uso.", "provider.connect.toast.connected.description": "Modelos do {{provider}} agora estão disponíveis para uso.",
"provider.custom.title": "Provedor personalizado", "provider.custom.title": "Provedor personalizado",
"provider.custom.unavailable": "Provedores personalizados não estão disponíveis neste servidor",
"provider.custom.description.prefix": "Configure um provedor compatível com OpenAI. Veja a ", "provider.custom.description.prefix": "Configure um provedor compatível com OpenAI. Veja a ",
"provider.custom.description.link": "documentação de configuração do provedor", "provider.custom.description.link": "documentação de configuração do provedor",
"provider.custom.description.suffix": ".", "provider.custom.description.suffix": ".",
"provider.custom.field.providerID.label": "ID do Provedor", "provider.custom.field.providerID.label": "ID do provedor",
"provider.custom.field.providerID.placeholder": "meuprovedor", "provider.custom.field.providerID.placeholder": "meuprovedor",
"provider.custom.field.providerID.description": "Letras minúsculas, números, hifens ou sublinhados", "provider.custom.field.providerID.description": "Letras minúsculas, números, hifens ou sublinhados",
"provider.custom.field.name.label": "Nome de exibição", "provider.custom.field.name.label": "Nome de exibição",
"provider.custom.field.name.placeholder": "Meu Provedor de IA", "provider.custom.field.name.placeholder": "Meu provedor de IA",
"provider.custom.field.baseURL.label": "URL Base", "provider.custom.field.baseURL.label": "URL base",
"provider.custom.field.baseURL.placeholder": "https://api.meuprovedor.com/v1", "provider.custom.field.baseURL.placeholder": "https://api.meuprovedor.com/v1",
"provider.custom.field.apiKey.label": "Chave de API", "provider.custom.field.apiKey.label": "Chave de API",
"provider.custom.field.apiKey.placeholder": "Chave de API", "provider.custom.field.apiKey.placeholder": "Chave de API",
@@ -165,7 +274,7 @@ export const dict = {
"provider.custom.models.id.label": "ID", "provider.custom.models.id.label": "ID",
"provider.custom.models.id.placeholder": "id-do-modelo", "provider.custom.models.id.placeholder": "id-do-modelo",
"provider.custom.models.name.label": "Nome", "provider.custom.models.name.label": "Nome",
"provider.custom.models.name.placeholder": "Nome de Exibição", "provider.custom.models.name.placeholder": "Nome de exibição",
"provider.custom.models.remove": "Remover modelo", "provider.custom.models.remove": "Remover modelo",
"provider.custom.models.add": "Adicionar modelo", "provider.custom.models.add": "Adicionar modelo",
"provider.custom.headers.label": "Cabeçalhos (opcional)", "provider.custom.headers.label": "Cabeçalhos (opcional)",
@@ -175,11 +284,11 @@ export const dict = {
"provider.custom.headers.value.placeholder": "valor", "provider.custom.headers.value.placeholder": "valor",
"provider.custom.headers.remove": "Remover cabeçalho", "provider.custom.headers.remove": "Remover cabeçalho",
"provider.custom.headers.add": "Adicionar cabeçalho", "provider.custom.headers.add": "Adicionar cabeçalho",
"provider.custom.error.providerID.required": "ID do Provedor é obrigatório", "provider.custom.error.providerID.required": "O ID do provedor é obrigatório",
"provider.custom.error.providerID.format": "Use letras minúsculas, números, hifens ou sublinhados", "provider.custom.error.providerID.format": "Use letras minúsculas, números, hifens ou sublinhados",
"provider.custom.error.providerID.exists": "Esse ID de provedor já existe", "provider.custom.error.providerID.exists": "Esse ID de provedor já existe",
"provider.custom.error.name.required": "Nome de exibição é obrigatório", "provider.custom.error.name.required": "O nome de exibição é obrigatório",
"provider.custom.error.baseURL.required": "URL Base é obrigatória", "provider.custom.error.baseURL.required": "A URL base é obrigatória",
"provider.custom.error.baseURL.format": "Deve começar com http:// ou https://", "provider.custom.error.baseURL.format": "Deve começar com http:// ou https://",
"provider.custom.error.required": "Obrigatório", "provider.custom.error.required": "Obrigatório",
"provider.custom.error.duplicate": "Duplicado", "provider.custom.error.duplicate": "Duplicado",
@@ -215,7 +324,7 @@ export const dict = {
"common.clear": "Limpar", "common.clear": "Limpar",
"common.connect": "Conectar", "common.connect": "Conectar",
"common.disconnect": "Desconectar", "common.disconnect": "Desconectar",
"common.continue": "Enviar", "common.continue": "Continuar",
"common.submit": "Enviar", "common.submit": "Enviar",
"common.save": "Salvar", "common.save": "Salvar",
"common.saving": "Salvando...", "common.saving": "Salvando...",
@@ -267,7 +376,7 @@ export const dict = {
"prompt.context.includeActiveFile": "Incluir arquivo ativo", "prompt.context.includeActiveFile": "Incluir arquivo ativo",
"prompt.context.removeActiveFile": "Remover arquivo ativo do contexto", "prompt.context.removeActiveFile": "Remover arquivo ativo do contexto",
"prompt.context.removeFile": "Remover arquivo do contexto", "prompt.context.removeFile": "Remover arquivo do contexto",
"prompt.action.attachFile": "Anexar arquivo", "prompt.action.attachFile": "Adicionar arquivos",
"prompt.menu.addImagesAndFiles": "Adicionar arquivos e mais", "prompt.menu.addImagesAndFiles": "Adicionar arquivos e mais",
"prompt.menu.imagesAndFiles": "Imagens e arquivos", "prompt.menu.imagesAndFiles": "Imagens e arquivos",
"prompt.menu.commands": "Comandos", "prompt.menu.commands": "Comandos",
@@ -277,6 +386,7 @@ export const dict = {
"prompt.action.send": "Enviar", "prompt.action.send": "Enviar",
"prompt.action.stop": "Parar", "prompt.action.stop": "Parar",
"prompt.toast.pasteUnsupported.title": "Anexo não suportado", "prompt.toast.pasteUnsupported.title": "Anexo não suportado",
"prompt.toast.attachmentDuplicate.title": "Este arquivo já foi enviado",
"prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.", "prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.",
"prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo", "prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo",
"prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.", "prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.",
@@ -287,7 +397,7 @@ export const dict = {
"prompt.toast.promptSendFailed.title": "Falha ao enviar prompt", "prompt.toast.promptSendFailed.title": "Falha ao enviar prompt",
"prompt.toast.promptSendFailed.description": "Não foi possível recuperar a sessão", "prompt.toast.promptSendFailed.description": "Não foi possível recuperar a sessão",
"dialog.mcp.title": "MCPs", "dialog.mcp.title": "MCPs",
"dialog.mcp.description": "{{enabled}} of {{total}} habilitados", "dialog.mcp.description": "{{enabled}} de {{total}} ativados",
"dialog.mcp.empty": "Nenhum MCP configurado", "dialog.mcp.empty": "Nenhum MCP configurado",
"dialog.lsp.empty": "LSPs detectados automaticamente pelos tipos de arquivo", "dialog.lsp.empty": "LSPs detectados automaticamente pelos tipos de arquivo",
"dialog.plugins.empty": "Plugins configurados em opencode.json", "dialog.plugins.empty": "Plugins configurados em opencode.json",
@@ -309,11 +419,11 @@ export const dict = {
"dialog.server.search.placeholder": "Buscar servidores", "dialog.server.search.placeholder": "Buscar servidores",
"dialog.server.empty": "Nenhum servidor ainda", "dialog.server.empty": "Nenhum servidor ainda",
"dialog.server.add.title": "Adicionar um servidor", "dialog.server.add.title": "Adicionar um servidor",
"dialog.server.add.url": "URL do servidor", "dialog.server.add.url": "Endereço do servidor",
"dialog.server.add.placeholder": "http://localhost:4096", "dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Não foi possível conectar ao servidor", "dialog.server.add.error": "Não foi possível conectar ao servidor",
"dialog.server.add.checking": "Verificando...", "dialog.server.add.checking": "Verificando...",
"dialog.server.add.button": "Adicionar", "dialog.server.add.button": "Adicionar servidor",
"dialog.server.add.name": "Nome do servidor (opcional)", "dialog.server.add.name": "Nome do servidor (opcional)",
"dialog.server.add.namePlaceholder": "Localhost", "dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Nome de usuário (opcional)", "dialog.server.add.username": "Nome de usuário (opcional)",
@@ -321,7 +431,7 @@ export const dict = {
"dialog.server.edit.title": "Editar servidor", "dialog.server.edit.title": "Editar servidor",
"dialog.server.default.title": "Servidor padrão", "dialog.server.default.title": "Servidor padrão",
"dialog.server.default.description": "dialog.server.default.description":
"Conectar a este servidor na inicialização do aplicativo ao invés de iniciar um servidor local. Requer reinicialização.", "Conectar a este servidor na inicialização do aplicativo em vez de iniciar um servidor local. Requer reinicialização.",
"dialog.server.default.none": "Nenhum servidor selecionado", "dialog.server.default.none": "Nenhum servidor selecionado",
"dialog.server.default.set": "Definir servidor atual como padrão", "dialog.server.default.set": "Definir servidor atual como padrão",
"dialog.server.default.clear": "Limpar", "dialog.server.default.clear": "Limpar",
@@ -401,6 +511,21 @@ export const dict = {
"wsl.onboarding.desktopVersion": "desktop {{version}}", "wsl.onboarding.desktopVersion": "desktop {{version}}",
"wsl.onboarding.versionMismatch": "A versão instalada não corresponde à versão do aplicativo para desktop.", "wsl.onboarding.versionMismatch": "A versão instalada não corresponde à versão do aplicativo para desktop.",
"wsl.onboarding.adding": "Adicionando...", "wsl.onboarding.adding": "Adicionando...",
"help.tabs.toast.ariaLabel": "Apresentamos as abas. Organize seu trabalho e suas sessões ativas com abas",
"help.tabs.toast.dismiss": "Dispensar informações sobre as abas",
"help.tabs.title": "Apresentamos as abas",
"help.tabs.description": "Organize seu trabalho e suas sessões ativas com abas",
"help.tabs.date": "14 de julho",
"help.tabs.introduction": "Agora, o OpenCode Desktop é organizado em abas.",
"help.tabs.sessions":
"Inicie uma nova sessão em uma aba ou abra uma sessão existente de qualquer um dos seus projetos. Abra uma nova aba quando começar algo novo e feche-a quando terminar.",
"help.tabs.organize":
"Manter algumas abas abertas facilita a organização das sessões ativas. Renomeie as abas com nomes fáceis de lembrar se pretende mantê-las abertas.",
"help.tabs.home":
"Você encontrará todas as suas sessões e projetos na nova tela Início. Ao selecionar uma sessão, ela será aberta em uma aba.",
"help.tabs.persistence": "Quando você reabrir o aplicativo, suas abas ainda estarão abertas.",
"help.tabs.worktrees":
"O novo design ainda não oferece suporte a Git Worktrees, mas esse recurso estará disponível em breve. Se preferir continuar usando o layout anterior, você poderá alternar entre os layouts nas Configurações. Lembre-se apenas de que o novo layout se tornará permanente em algumas semanas.",
"dialog.project.edit.title": "Editar projeto", "dialog.project.edit.title": "Editar projeto",
"dialog.project.edit.name": "Nome", "dialog.project.edit.name": "Nome",
"dialog.project.edit.icon": "Ícone", "dialog.project.edit.icon": "Ícone",
@@ -411,33 +536,37 @@ export const dict = {
"dialog.project.edit.color.select": "Selecionar cor {{color}}", "dialog.project.edit.color.select": "Selecionar cor {{color}}",
"dialog.project.edit.worktree.startup": "Script de inicialização do espaço de trabalho", "dialog.project.edit.worktree.startup": "Script de inicialização do espaço de trabalho",
"dialog.project.edit.worktree.startup.description": "Executa após criar um novo espaço de trabalho (worktree).", "dialog.project.edit.worktree.startup.description": "Executa após criar um novo espaço de trabalho (worktree).",
"dialog.project.edit.worktree.startup.placeholder": "ex: bun install", "dialog.project.edit.worktree.startup.placeholder": "Ex.: bun install",
"context.breakdown.title": "Detalhamento do Contexto", "dialog.usageExceeded.dontShowAgain": "Não mostrar novamente",
"context.breakdown.title": "Detalhamento do contexto",
"context.breakdown.note": "context.breakdown.note":
'Detalhamento aproximado dos tokens de entrada. "Outros" inclui definições de ferramentas e overhead.', 'Detalhamento aproximado dos tokens de entrada. "Outros" inclui definições de ferramentas e sobrecarga.',
"context.breakdown.system": "Sistema", "context.breakdown.system": "Sistema",
"context.breakdown.user": "Usuário", "context.breakdown.user": "Usuário",
"context.breakdown.assistant": "Assistente", "context.breakdown.assistant": "Assistente",
"context.breakdown.tool": "Chamadas de Ferramentas", "context.breakdown.tool": "Chamadas de ferramentas",
"context.breakdown.other": "Outros", "context.breakdown.other": "Outros",
"context.systemPrompt.title": "Prompt do Sistema", "context.systemPrompt.title": "Prompt do sistema",
"context.rawMessages.title": "Mensagens brutas", "context.rawMessages.title": "Mensagens brutas",
"context.export.session": "Exportar sessão",
"context.stats.session": "Sessão", "context.stats.session": "Sessão",
"context.stats.messages": "Mensagens", "context.stats.messages": "Mensagens",
"context.stats.provider": "Provedor", "context.stats.provider": "Provedor",
"context.stats.model": "Modelo", "context.stats.model": "Modelo",
"context.stats.limit": "Limite de Contexto", "context.stats.limit": "Limite de contexto",
"context.stats.totalTokens": "Total de Tokens", "context.stats.totalTokens": "Total de tokens",
"context.stats.usage": "Uso", "context.stats.usage": "Uso",
"context.stats.inputTokens": "Tokens de Entrada", "context.stats.inputTokens": "Tokens de entrada",
"context.stats.outputTokens": "Tokens de Saída", "context.stats.outputTokens": "Tokens de saída",
"context.stats.reasoningTokens": "Tokens de Raciocínio", "context.stats.reasoningTokens": "Tokens de raciocínio",
"context.stats.cacheTokens": "Tokens de Cache (leitura/escrita)", "context.stats.cacheTokens": "Tokens de cache (leitura/escrita)",
"context.stats.userMessages": "Mensagens de Usuário", "context.stats.userMessages": "Mensagens do usuário",
"context.stats.assistantMessages": "Mensagens do Assistente", "context.stats.assistantMessages": "Mensagens do assistente",
"context.stats.totalCost": "Custo Total", "context.stats.totalCost": "Custo total",
"context.stats.sessionCreated": "Sessão Criada", "context.stats.sessionCreated": "Sessão criada",
"context.stats.lastActivity": "Última Atividade", "context.stats.lastActivity": "Última atividade",
"context.usage.tokens": "Tokens", "context.usage.tokens": "Tokens",
"context.usage.usage": "Uso", "context.usage.usage": "Uso",
"context.usage.cost": "Custo", "context.usage.cost": "Custo",
@@ -488,6 +617,11 @@ export const dict = {
"toast.session.unshare.success.description": "Sessão deixou de ser compartilhada com sucesso!", "toast.session.unshare.success.description": "Sessão deixou de ser compartilhada com sucesso!",
"toast.session.unshare.failed.title": "Falha ao parar de compartilhar sessão", "toast.session.unshare.failed.title": "Falha ao parar de compartilhar sessão",
"toast.session.unshare.failed.description": "Ocorreu um erro ao parar de compartilhar a sessão", "toast.session.unshare.failed.description": "Ocorreu um erro ao parar de compartilhar a sessão",
"toast.session.export.success.title": "Sessão exportada",
"toast.session.export.success.description": "Sessão salva em {{filename}}",
"toast.session.export.failed.title": "Falha ao exportar sessão",
"toast.session.export.failed.description": "Ocorreu um erro ao exportar a sessão",
"toast.session.listFailed.title": "Falha ao carregar sessões para {{project}}", "toast.session.listFailed.title": "Falha ao carregar sessões para {{project}}",
"toast.update.title": "Atualização disponível", "toast.update.title": "Atualização disponível",
"toast.update.description": "Uma nova versão do OpenCode ({{version}}) está disponível para instalação.", "toast.update.description": "Uma nova versão do OpenCode ({{version}}) está disponível para instalação.",
@@ -496,7 +630,7 @@ export const dict = {
"error.page.title": "Algo deu errado", "error.page.title": "Algo deu errado",
"error.page.description": "Ocorreu um erro ao carregar a aplicação.", "error.page.description": "Ocorreu um erro ao carregar a aplicação.",
"error.page.description.localServerStartup": "Ocorreu um erro ao iniciar o servidor local.", "error.page.description.localServerStartup": "Ocorreu um erro ao iniciar o servidor local.",
"error.page.details.label": "Detalhes do Erro", "error.page.details.label": "Detalhes do erro",
"error.page.action.restart": "Reiniciar", "error.page.action.restart": "Reiniciar",
"error.page.action.report": "Reportar erro", "error.page.action.report": "Reportar erro",
"error.page.action.reported": "Erro reportado", "error.page.action.reported": "Erro reportado",
@@ -569,7 +703,7 @@ export const dict = {
"session.error.notFound.description": "Esta aba aponta para uma sessão que não existe mais neste servidor.", "session.error.notFound.description": "Esta aba aponta para uma sessão que não existe mais neste servidor.",
"session.error.notFound.closeTab": "Fechar aba", "session.error.notFound.closeTab": "Fechar aba",
"session.error.serverConnection": "Não é possível conectar a este servidor", "session.error.serverConnection": "Não é possível conectar a este servidor",
"session.review.filesChanged": "{{count}} Arquivos Alterados", "session.review.filesChanged": "{{count}} arquivos alterados",
"session.review.change.one": "Alteração", "session.review.change.one": "Alteração",
"session.review.change.other": "Alterações", "session.review.change.other": "Alterações",
"session.review.loadingChanges": "Carregando alterações...", "session.review.loadingChanges": "Carregando alterações...",
@@ -596,14 +730,17 @@ export const dict = {
"session.question.minimize": "Minimizar pergunta", "session.question.minimize": "Minimizar pergunta",
"session.question.restore": "Restaurar pergunta", "session.question.restore": "Restaurar pergunta",
"session.question.pending.one": "{{count}} pergunta pendente", "session.question.pending.one": "{{count}} pergunta pendente",
"session.question.pending.many": "{{count}} de perguntas pendentes",
"session.question.pending.other": "{{count}} perguntas pendentes", "session.question.pending.other": "{{count}} perguntas pendentes",
"session.followupDock.summary.one": "{{count}} mensagem na fila", "session.followupDock.summary.one": "{{count}} mensagem na fila",
"session.followupDock.summary.many": "{{count}} de mensagens na fila",
"session.followupDock.summary.other": "{{count}} mensagens na fila", "session.followupDock.summary.other": "{{count}} mensagens na fila",
"session.followupDock.sendNow": "Enviar agora", "session.followupDock.sendNow": "Enviar agora",
"session.followupDock.edit": "Editar", "session.followupDock.edit": "Editar",
"session.followupDock.collapse": "Recolher mensagens na fila", "session.followupDock.collapse": "Recolher mensagens na fila",
"session.followupDock.expand": "Expandir mensagens na fila", "session.followupDock.expand": "Expandir mensagens na fila",
"session.revertDock.summary.one": "{{count}} mensagem revertida", "session.revertDock.summary.one": "{{count}} mensagem revertida",
"session.revertDock.summary.many": "{{count}} de mensagens revertidas",
"session.revertDock.summary.other": "{{count}} mensagens revertidas", "session.revertDock.summary.other": "{{count}} mensagens revertidas",
"session.revertDock.collapse": "Recolher mensagens revertidas", "session.revertDock.collapse": "Recolher mensagens revertidas",
"session.revertDock.expand": "Expandir mensagens revertidas", "session.revertDock.expand": "Expandir mensagens revertidas",
@@ -655,15 +792,23 @@ export const dict = {
"terminal.title": "Terminal", "terminal.title": "Terminal",
"terminal.title.numbered": "Terminal {{number}}", "terminal.title.numbered": "Terminal {{number}}",
"terminal.close": "Fechar terminal", "terminal.close": "Fechar terminal",
"terminal.connectionLost.title": "Conexão Perdida", "terminal.connectionLost.title": "Conexão perdida",
"terminal.connectionLost.description": "terminal.connectionLost.description":
"A conexão do terminal foi interrompida. Isso pode acontecer quando o servidor reinicia.", "A conexão do terminal foi interrompida. Isso pode acontecer quando o servidor reinicia.",
"terminal.connectTicket.csrfError":
"O tíquete de conexão PTY foi rejeitado pelas verificações de origem ou CSRF. Verifique a configuração de CORS do servidor.",
"terminal.connectTicket.statusError": "O tíquete de conexão PTY falhou com o status {{status}}",
"titlebar.update": "Atualizar",
"titlebar.updateVersion": "Atualizar {{version}}",
"common.closeTab": "Fechar aba", "common.closeTab": "Fechar aba",
"common.dismiss": "Descartar", "common.dismiss": "Descartar",
"common.requestFailed": "Requisição falhou", "common.requestFailed": "Requisição falhou",
"common.moreOptions": "Mais opções", "common.moreOptions": "Mais opções",
"common.learnMore": "Saiba mais", "common.learnMore": "Saiba mais",
"common.rename": "Renomear", "common.rename": "Renomear",
"common.export": "Exportar",
"common.reset": "Redefinir", "common.reset": "Redefinir",
"common.archive": "Arquivar", "common.archive": "Arquivar",
"common.delete": "Excluir", "common.delete": "Excluir",
@@ -705,7 +850,7 @@ export const dict = {
"settings.general.row.shell.title": "Shell do terminal", "settings.general.row.shell.title": "Shell do terminal",
"settings.general.row.shell.description": "settings.general.row.shell.description":
"Escolha o shell usado no terminal. Os shells compatíveis também são usados nas chamadas de ferramentas do agente.", "Escolha o shell usado no terminal. Os shells compatíveis também são usados nas chamadas de ferramentas do agente.",
"settings.general.row.shell.autoDefault": "Automático (Padrão)", "settings.general.row.shell.autoDefault": "Automático (padrão)",
"settings.general.row.shell.terminalOnly": "somente no terminal", "settings.general.row.shell.terminalOnly": "somente no terminal",
"settings.general.row.appearance.title": "Aparência", "settings.general.row.appearance.title": "Aparência",
"settings.general.row.appearance.description": "Personalize como o OpenCode aparece no seu dispositivo", "settings.general.row.appearance.description": "Personalize como o OpenCode aparece no seu dispositivo",
@@ -738,8 +883,9 @@ export const dict = {
"settings.general.row.mobileTitlebarBottom.title": "Navegação inferior", "settings.general.row.mobileTitlebarBottom.title": "Navegação inferior",
"settings.general.row.mobileTitlebarBottom.description": "settings.general.row.mobileTitlebarBottom.description":
"Posicionar a barra de título e as abas da sessão na parte inferior da tela em dispositivos móveis", "Posicionar a barra de título e as abas da sessão na parte inferior da tela em dispositivos móveis",
"settings.general.row.showCustomAgents.title": "Agentes personalizados", "settings.general.row.showCustomAgents.title": "Mostrar agente",
"settings.general.row.showCustomAgents.description": "Mostrar o seletor de agentes na área de composição", "settings.general.row.showCustomAgents.description":
"Alternar entre agentes na área de composição. Quando oculto, usa o agente Build como padrão.",
"settings.general.row.reasoningSummaries.title": "Mostrar resumos de raciocínio", "settings.general.row.reasoningSummaries.title": "Mostrar resumos de raciocínio",
"settings.general.row.reasoningSummaries.description": "Exibir resumos de raciocínio do modelo na linha do tempo", "settings.general.row.reasoningSummaries.description": "Exibir resumos de raciocínio do modelo na linha do tempo",
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell", "settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell",
@@ -822,14 +968,15 @@ export const dict = {
"sound.option.yup06": "Sim 06", "sound.option.yup06": "Sim 06",
"settings.general.notifications.agent.title": "Agente", "settings.general.notifications.agent.title": "Agente",
"settings.general.notifications.agent.description": "settings.general.notifications.agent.description":
"Mostrar notificação do sistema quando o agente estiver completo ou precisar de atenção", "Mostrar notificação do sistema quando o agente concluir o trabalho ou precisar de atenção",
"settings.general.notifications.permissions.title": "Permissões", "settings.general.notifications.permissions.title": "Permissões",
"settings.general.notifications.permissions.description": "settings.general.notifications.permissions.description":
"Mostrar notificação do sistema quando uma permissão for necessária", "Mostrar notificação do sistema quando uma permissão for necessária",
"settings.general.notifications.errors.title": "Erros", "settings.general.notifications.errors.title": "Erros",
"settings.general.notifications.errors.description": "Mostrar notificação do sistema quando ocorrer um erro", "settings.general.notifications.errors.description": "Mostrar notificação do sistema quando ocorrer um erro",
"settings.general.sounds.agent.title": "Agente", "settings.general.sounds.agent.title": "Agente",
"settings.general.sounds.agent.description": "Reproduzir som quando o agente estiver completo ou precisar de atenção", "settings.general.sounds.agent.description":
"Reproduzir som quando o agente concluir o trabalho ou precisar de atenção",
"settings.general.sounds.permissions.title": "Permissões", "settings.general.sounds.permissions.title": "Permissões",
"settings.general.sounds.permissions.description": "Reproduzir som quando uma permissão for necessária", "settings.general.sounds.permissions.description": "Reproduzir som quando uma permissão for necessária",
"settings.general.sounds.errors.title": "Erros", "settings.general.sounds.errors.title": "Erros",
@@ -877,8 +1024,7 @@ export const dict = {
"settings.permissions.tool.read.title": "Ler", "settings.permissions.tool.read.title": "Ler",
"settings.permissions.tool.read.description": "Ler um arquivo (corresponde ao caminho do arquivo)", "settings.permissions.tool.read.description": "Ler um arquivo (corresponde ao caminho do arquivo)",
"settings.permissions.tool.edit.title": "Editar", "settings.permissions.tool.edit.title": "Editar",
"settings.permissions.tool.edit.description": "settings.permissions.tool.edit.description": "Modificar arquivos, incluindo edições, escritas e patches",
"Modificar arquivos, incluindo edições, escritas, patches e multi-edições",
"settings.permissions.tool.glob.title": "Glob", "settings.permissions.tool.glob.title": "Glob",
"settings.permissions.tool.glob.description": "Corresponder arquivos usando padrões glob", "settings.permissions.tool.glob.description": "Corresponder arquivos usando padrões glob",
"settings.permissions.tool.grep.title": "Grep", "settings.permissions.tool.grep.title": "Grep",
@@ -888,7 +1034,7 @@ export const dict = {
"settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.title": "Bash",
"settings.permissions.tool.bash.description": "Executar comandos shell", "settings.permissions.tool.bash.description": "Executar comandos shell",
"settings.permissions.tool.task.title": "Tarefa", "settings.permissions.tool.task.title": "Tarefa",
"settings.permissions.tool.task.description": "Lançar sub-agentes", "settings.permissions.tool.task.description": "Iniciar subagentes",
"settings.permissions.tool.skill.title": "Habilidade", "settings.permissions.tool.skill.title": "Habilidade",
"settings.permissions.tool.skill.description": "Carregar uma habilidade por nome", "settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
@@ -945,7 +1091,7 @@ export const dict = {
"common.time.hoursAgo.short": "{{count}}h atrás", "common.time.hoursAgo.short": "{{count}}h atrás",
"common.time.daysAgo.short": "{{count}}d atrás", "common.time.daysAgo.short": "{{count}}d atrás",
"settings.providers.connected.environmentDescription": "Conectado a partir de suas variáveis de ambiente", "settings.providers.connected.environmentDescription": "Conectado a partir de suas variáveis de ambiente",
"settings.providers.custom.description": "Adicionar um provedor compatível com a OpenAI através do URL base.", "settings.providers.custom.description": "Adicionar um provedor compatível com OpenAI por meio da URL base.",
"app.server.unreachable": "Não foi possível conectar a {{server}}", "app.server.unreachable": "Não foi possível conectar a {{server}}",
"app.server.retrying": "Tentando novamente automaticamente...", "app.server.retrying": "Tentando novamente automaticamente...",
@@ -961,6 +1107,9 @@ export const dict = {
"session.question.progress": "{{current}} de {{total}} perguntas", "session.question.progress": "{{current}} de {{total}} perguntas",
"session.header.open.finder": "Finder", "session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Explorador de Arquivos", "session.header.open.fileExplorer": "Explorador de Arquivos",
"session.header.reveal.finder": "Revelar no Finder",
"session.header.reveal.fileExplorer": "Revelar no Explorador de Arquivos",
"session.header.reveal.containingFolder": "Abrir pasta que contém este item",
"session.header.open.fileManager": "Gerenciador de Arquivos", "session.header.open.fileManager": "Gerenciador de Arquivos",
"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",
@@ -998,6 +1147,14 @@ export const dict = {
"debugBar.mem.label": "MEM", "debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Heap JS usado vs limite de heap. Apenas Chromium.", "debugBar.mem.tipUnavailable": "Heap JS usado vs limite de heap. Apenas Chromium.",
"debugBar.mem.tip": "Heap JS usado vs limite de heap. {{used}} de {{limit}}.", "debugBar.mem.tip": "Heap JS usado vs limite de heap. {{used}} de {{limit}}.",
"debugBar.focus.label": "FOCUS",
"debugBar.focus.tip": "Forçar estilos de foco em todos os elementos interativos",
"debugBar.focus.on": "LIGADO",
"debugBar.focus.off": "DESLIGADO",
"debugBar.direction.label": "DIR",
"debugBar.direction.tip": "Forçar a direção do layout de todo o aplicativo sem alterar o idioma selecionado",
"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",
+199 -42
View File
@@ -1,4 +1,104 @@
export const dict = { export const dict = {
"desktop.menu.app": "OpenCode",
"desktop.menu.file": "Datoteka",
"desktop.menu.edit": "Uredi",
"desktop.menu.view": "Prikaz",
"desktop.menu.go": "Idi",
"desktop.menu.window": "Prozor",
"desktop.menu.help": "Pomoć",
"desktop.menu.checkForUpdates": "Provjeri ima li ažuriranja...",
"desktop.menu.settings": "Postavke",
"desktop.menu.reloadWebview": "Ponovo učitaj Webview",
"desktop.menu.restart": "Ponovo pokreni",
"desktop.menu.exportLogs": "Izvezi zapisnike...",
"desktop.menu.newSession": "Nova sesija",
"desktop.menu.openProject": "Otvori projekat...",
"desktop.menu.newWindow": "Novi prozor",
"desktop.menu.closeWindow": "Zatvori prozor",
"desktop.menu.undo": "Poništi",
"desktop.menu.redo": "Vrati",
"desktop.menu.cut": "Izreži",
"desktop.menu.copy": "Kopiraj",
"desktop.menu.paste": "Zalijepi",
"desktop.menu.delete": "Izbriši",
"desktop.menu.selectAll": "Odaberi sve",
"desktop.menu.toggleSidebar": "Prikaži ili sakrij bočnu traku",
"desktop.menu.toggleTerminal": "Prikaži ili sakrij terminal",
"desktop.menu.toggleFileTree": "Prikaži ili sakrij stablo datoteka",
"desktop.menu.reload": "Ponovo učitaj",
"desktop.menu.toggleDeveloperTools": "Prikaži ili sakrij alate za programere",
"desktop.menu.actualSize": "Stvarna veličina",
"desktop.menu.zoomIn": "Uvećaj",
"desktop.menu.zoomOut": "Umanji",
"desktop.menu.toggleFullScreen": "Prikaži ili sakrij prikaz preko cijelog ekrana",
"desktop.menu.back": "Nazad",
"desktop.menu.forward": "Naprijed",
"desktop.menu.previousSession": "Prethodna sesija",
"desktop.menu.nextSession": "Sljedeća sesija",
"desktop.menu.previousProject": "Prethodni projekat",
"desktop.menu.nextProject": "Sljedeći projekat",
"desktop.menu.minimize": "Minimiziraj",
"desktop.menu.maximize": "Maksimiziraj",
"desktop.menu.documentation": "Dokumentacija za OpenCode",
"desktop.menu.supportForum": "Forum za podršku",
"desktop.menu.shareFeedback": "Pošalji povratne informacije",
"desktop.menu.reportBug": "Prijavi grešku",
"desktop.menu.ariaLabel": "OpenCode meni",
"desktop.updater.dialog.checkFailed.message": "Provjera ažuriranja nije uspjela.",
"desktop.updater.dialog.checkFailed.title": "Greška pri ažuriranju",
"desktop.updater.dialog.upToDate.message": "Sve je ažurno.",
"desktop.updater.dialog.upToDate.title": "Nema ažuriranja",
"desktop.updater.dialog.ready.message":
"Ažuriranje {{version}} je preuzeto. Želiš li sada ponovo pokrenuti aplikaciju?",
"desktop.updater.dialog.ready.title": "Ažuriranje je spremno",
"desktop.updater.dialog.restart": "Ponovo pokreni",
"desktop.updater.dialog.later": "Kasnije",
"desktop.recovery.action.relaunch": "Ponovo pokreni",
"desktop.recovery.action.exportLogs": "Izvezi zapisnike",
"desktop.recovery.action.keepWaiting": "Nastavi čekati",
"desktop.recovery.action.quit": "Izađi",
"desktop.recovery.loadFailed": "OpenCode se nije uspio učitati",
"desktop.recovery.terminated": "Prozor OpenCode-a neočekivano je zatvoren",
"desktop.recovery.unresponsive": "OpenCode ne reaguje",
"desktop.recovery.unresponsive.detail": "Možeš ponovo pokrenuti aplikaciju, otvoriti zapisnike ili nastaviti čekati.",
"desktop.recovery.loadFailed.detail": "Prozor: {{window}}\nURL: {{url}}\nGreška: {{code}} {{description}}",
"desktop.recovery.terminated.detail": "Prozor: {{window}}\nRazlog: {{reason}}\nKod: {{code}}",
"desktop.recovery.unknown": "<nepoznato>",
"desktop.dialog.chooseFolder": "Odaberi fasciklu",
"desktop.dialog.chooseFile": "Odaberi datoteku",
"desktop.dialog.saveFile": "Sačuvaj datoteku",
"desktop.dialog.files": "Datoteke",
"desktop.server.local": "Lokalni server",
"desktop.wsl.error.windowsOnly": "WSL je dostupan samo u Windowsu",
"desktop.wsl.error.unavailable": "WSL nije dostupan",
"desktop.wsl.error.listInstalled": "Nije uspjelo prikazivanje instaliranih WSL distribucija",
"desktop.wsl.error.listOnline": "Nije uspjelo prikazivanje dostupnih WSL distribucija",
"desktop.wsl.error.executeDistro": "Nije moguće izvršiti komande u distribuciji",
"desktop.wsl.error.installWsl": "Instalacija WSL-a nije uspjela",
"desktop.wsl.error.installDistro": "Instalacija distribucije nije uspjela: {{distro}}",
"desktop.wsl.error.installOpencode": "Instalacija OpenCode-a nije uspjela",
"desktop.wsl.error.alreadyAdded": "Distribucija {{distro}} je već dodana",
"desktop.wsl.error.opencodeMissing": "opencode nije instaliran u ovoj distribuciji",
"desktop.wsl.error.opencodeCannotRun": "opencode je instaliran, ali se ne može pokrenuti",
"desktop.wsl.error.opencodeNotInstalled": "OpenCode nije instaliran u distribuciji {{distro}}",
"desktop.wsl.error.updateVersion":
"Ažuriranje OpenCode-a je završeno, ali {{distro}} i dalje prijavljuje {{installed}}; očekivano je {{expected}}",
"desktop.wsl.error.noVersion": "nema verzije",
"desktop.wsl.error.serverExited": "WSL server se zatvorio nakon pokretanja (code={{code}} signal={{signal}})",
"desktop.wsl.error.serverExitedBeforeHealthy":
"WSL server se zatvorio prije nego što je postao dostupan (code={{code}} signal={{signal}}){{output}}",
"desktop.wsl.error.healthTimeout":
"Isteklo je vrijeme provjere dostupnosti komponente Sidecar za {{distro}} nakon {{timeout}} ms",
"desktop.wsl.error.commandTimeout": "Isteklo je vrijeme za {{command}} {{args}} nakon {{timeout}} ms",
"desktop.wsl.error.failedPort": "Nije uspjelo dohvaćanje porta",
"desktop.picker.error.notSelected": "Datoteka nije odabrana u izborniku",
"desktop.picker.error.sizeLimit": "Odabrani prilozi premašuju ograničenje od {{limit}} MB",
"command.category.suggested": "Predloženo", "command.category.suggested": "Predloženo",
"command.category.view": "Prikaz", "command.category.view": "Prikaz",
"command.category.project": "Projekat", "command.category.project": "Projekat",
@@ -16,6 +116,7 @@ export const dict = {
"command.category.permissions": "Dozvole", "command.category.permissions": "Dozvole",
"command.category.workspace": "Radni prostor", "command.category.workspace": "Radni prostor",
"command.category.settings": "Postavke", "command.category.settings": "Postavke",
"command.logs.export": "Izvezi zapisnike",
"theme.scheme.system": "Sistem", "theme.scheme.system": "Sistem",
"theme.scheme.light": "Svijetlo", "theme.scheme.light": "Svijetlo",
@@ -92,6 +193,9 @@ export const dict = {
"command.session.unshare": "Ukini dijeljenje sesije", "command.session.unshare": "Ukini dijeljenje sesije",
"command.session.unshare.description": "Zaustavi dijeljenje ove sesije", "command.session.unshare.description": "Zaustavi dijeljenje ove sesije",
"command.session.export": "Izvezi sesiju",
"command.session.export.description": "Izvezi cijeli zapis sesije u JSON formatu",
"palette.search.placeholder": "Pretraži datoteke, komande i sesije", "palette.search.placeholder": "Pretraži datoteke, komande i sesije",
"palette.search.placeholder.home": "Pretraži komande i sesije", "palette.search.placeholder.home": "Pretraži komande i sesije",
"palette.empty": "Nema rezultata", "palette.empty": "Nema rezultata",
@@ -102,6 +206,7 @@ export const dict = {
"dialog.provider.empty": "Nema pronađenih provajdera", "dialog.provider.empty": "Nema pronađenih provajdera",
"dialog.provider.group.popular": "Popularno", "dialog.provider.group.popular": "Popularno",
"dialog.provider.group.other": "Ostalo", "dialog.provider.group.other": "Ostalo",
"dialog.provider.custom.label": "Prilagođeni provajder kompatibilan s OpenAI-jem",
"dialog.provider.tag.recommended": "Preporučeno", "dialog.provider.tag.recommended": "Preporučeno",
"dialog.provider.opencode.note": "Kurirani modeli uključujući Claude, GPT, Gemini i druge", "dialog.provider.opencode.note": "Kurirani modeli uključujući Claude, GPT, Gemini i druge",
"dialog.provider.opencode.tagline": "Pouzdani optimizovani modeli", "dialog.provider.opencode.tagline": "Pouzdani optimizovani modeli",
@@ -122,6 +227,7 @@ export const dict = {
"dialog.model.unpaid.freeModels.title": "Besplatni modeli koje obezbjeđuje OpenCode", "dialog.model.unpaid.freeModels.title": "Besplatni modeli koje obezbjeđuje OpenCode",
"dialog.model.unpaid.addMore.title": "Dodaj još modela od popularnih provajdera", "dialog.model.unpaid.addMore.title": "Dodaj još modela od popularnih provajdera",
"dialog.model.unpaid.viewMoreProviders": "Pogledaj još više od 70 provajdera",
"dialog.provider.viewAll": "Prikaži više provajdera", "dialog.provider.viewAll": "Prikaži više provajdera",
@@ -129,6 +235,8 @@ export const dict = {
"provider.connect.title.anthropicProMax": "Prijavi se putem Claude Pro/Max", "provider.connect.title.anthropicProMax": "Prijavi se putem Claude Pro/Max",
"provider.connect.selectMethod": "Odaberi način prijave za {{provider}}.", "provider.connect.selectMethod": "Odaberi način prijave za {{provider}}.",
"provider.connect.method.apiKey": "API ključ", "provider.connect.method.apiKey": "API ključ",
"provider.connect.method.browser": "Preglednik",
"provider.connect.method.headless": "Bez grafičkog interfejsa",
"provider.connect.status.inProgress": "Autorizacija je u toku...", "provider.connect.status.inProgress": "Autorizacija je u toku...",
"provider.connect.status.waiting": "Čekanje na autorizaciju...", "provider.connect.status.waiting": "Čekanje na autorizaciju...",
"provider.connect.status.failed": "Autorizacija nije uspjela: {{error}}", "provider.connect.status.failed": "Autorizacija nije uspjela: {{error}}",
@@ -161,6 +269,7 @@ export const dict = {
"provider.connect.toast.connected.description": "{{provider}} modeli su sada dostupni za korištenje.", "provider.connect.toast.connected.description": "{{provider}} modeli su sada dostupni za korištenje.",
"provider.custom.title": "Prilagođeni provajder", "provider.custom.title": "Prilagođeni provajder",
"provider.custom.unavailable": "Prilagođeni provajderi nisu dostupni na ovom serveru",
"provider.custom.description.prefix": "Konfiguriši OpenAI-kompatibilnog provajdera. Pogledaj ", "provider.custom.description.prefix": "Konfiguriši OpenAI-kompatibilnog provajdera. Pogledaj ",
"provider.custom.description.link": "dokumentaciju za konfiguraciju provajdera", "provider.custom.description.link": "dokumentaciju za konfiguraciju provajdera",
"provider.custom.description.suffix": ".", "provider.custom.description.suffix": ".",
@@ -232,7 +341,7 @@ export const dict = {
"common.clear": "Očisti", "common.clear": "Očisti",
"common.connect": "Poveži", "common.connect": "Poveži",
"common.disconnect": "Prekini vezu", "common.disconnect": "Prekini vezu",
"common.continue": "Pošalji", "common.continue": "Nastavi",
"common.submit": "Pošalji", "common.submit": "Pošalji",
"common.save": "Sačuvaj", "common.save": "Sačuvaj",
"common.saving": "Čuvanje...", "common.saving": "Čuvanje...",
@@ -263,7 +372,7 @@ export const dict = {
"prompt.example.11": "Optimizuj upite prema bazi podataka", "prompt.example.11": "Optimizuj upite prema bazi podataka",
"prompt.example.12": "Dodaj validaciju ulaza", "prompt.example.12": "Dodaj validaciju ulaza",
"prompt.example.13": "Napravi novu komponentu za...", "prompt.example.13": "Napravi novu komponentu za...",
"prompt.example.14": "Kako da deployam ovaj projekat?", "prompt.example.14": "Kako da postavim ovaj projekat?",
"prompt.example.15": "Pregledaj moj kod prema najboljim praksama", "prompt.example.15": "Pregledaj moj kod prema najboljim praksama",
"prompt.example.16": "Dodaj obradu grešaka u ovu funkciju", "prompt.example.16": "Dodaj obradu grešaka u ovu funkciju",
"prompt.example.17": "Objasni ovaj regex obrazac", "prompt.example.17": "Objasni ovaj regex obrazac",
@@ -271,14 +380,14 @@ export const dict = {
"prompt.example.19": "Dodaj logovanje kroz cijelu bazu koda", "prompt.example.19": "Dodaj logovanje kroz cijelu bazu koda",
"prompt.example.20": "Koje su zavisnosti zastarjele?", "prompt.example.20": "Koje su zavisnosti zastarjele?",
"prompt.example.21": "Pomozi mi da napišem migracijsku skriptu", "prompt.example.21": "Pomozi mi da napišem migracijsku skriptu",
"prompt.example.22": "Implementiraj keširanje za ovaj endpoint", "prompt.example.22": "Implementiraj keširanje za ovu krajnju tačku",
"prompt.example.23": "Dodaj paginaciju u ovu listu", "prompt.example.23": "Dodaj paginaciju u ovu listu",
"prompt.example.24": "Napravi CLI komandu za...", "prompt.example.24": "Napravi CLI komandu za...",
"prompt.example.25": "Kako ovdje rade varijable okruženja?", "prompt.example.25": "Kako ovdje rade varijable okruženja?",
"prompt.popover.emptyResults": "Nema rezultata", "prompt.popover.emptyResults": "Nema rezultata",
"prompt.popover.emptyCommands": "Nema komandi", "prompt.popover.emptyCommands": "Nema komandi",
"prompt.dropzone.label": "Ovdje prevucite slike, PDF-ove ili tekstualne datoteke", "prompt.dropzone.label": "Ovdje prevuci slike, PDF-ove ili tekstualne datoteke",
"prompt.dropzone.file.label": "Spusti za @spominjanje datoteke", "prompt.dropzone.file.label": "Spusti za @spominjanje datoteke",
"prompt.slash.badge.custom": "prilagođeno", "prompt.slash.badge.custom": "prilagođeno",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
@@ -287,7 +396,7 @@ export const dict = {
"prompt.context.includeActiveFile": "Uključi aktivnu datoteku", "prompt.context.includeActiveFile": "Uključi aktivnu datoteku",
"prompt.context.removeActiveFile": "Ukloni aktivnu datoteku iz konteksta", "prompt.context.removeActiveFile": "Ukloni aktivnu datoteku iz konteksta",
"prompt.context.removeFile": "Ukloni datoteku iz konteksta", "prompt.context.removeFile": "Ukloni datoteku iz konteksta",
"prompt.action.attachFile": "Priloži datoteku", "prompt.action.attachFile": "Dodaj datoteke",
"prompt.menu.addImagesAndFiles": "Dodaj datoteke i više", "prompt.menu.addImagesAndFiles": "Dodaj datoteke i više",
"prompt.menu.imagesAndFiles": "Slike i datoteke", "prompt.menu.imagesAndFiles": "Slike i datoteke",
"prompt.menu.commands": "Komande", "prompt.menu.commands": "Komande",
@@ -298,6 +407,7 @@ export const dict = {
"prompt.action.stop": "Zaustavi", "prompt.action.stop": "Zaustavi",
"prompt.toast.pasteUnsupported.title": "Nepodržan prilog", "prompt.toast.pasteUnsupported.title": "Nepodržan prilog",
"prompt.toast.attachmentDuplicate.title": "Ova datoteka je već učitana",
"prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.", "prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.",
"prompt.toast.modelAgentRequired.title": "Odaberi agenta i model", "prompt.toast.modelAgentRequired.title": "Odaberi agenta i model",
"prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.", "prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.",
@@ -310,7 +420,7 @@ export const dict = {
"dialog.mcp.title": "MCP-ovi", "dialog.mcp.title": "MCP-ovi",
"dialog.mcp.description": "{{enabled}} od {{total}} omogućeno", "dialog.mcp.description": "{{enabled}} od {{total}} omogućeno",
"dialog.mcp.empty": "Nema konfigurisnih MCP-ova", "dialog.mcp.empty": "Nema konfigurisanih MCP-ova",
"dialog.lsp.empty": "LSP-ovi se automatski otkrivaju prema tipu datoteke", "dialog.lsp.empty": "LSP-ovi se automatski otkrivaju prema tipu datoteke",
"dialog.plugins.empty": "Plugini su konfigurisani u opencode.json", "dialog.plugins.empty": "Plugini su konfigurisani u opencode.json",
@@ -318,18 +428,18 @@ export const dict = {
"mcp.status.connected": "povezano", "mcp.status.connected": "povezano",
"mcp.status.failed": "neuspjelo", "mcp.status.failed": "neuspjelo",
"mcp.status.needs_auth": "potrebna autentifikacija", "mcp.status.needs_auth": "potrebna autentifikacija",
"mcp.auth.clickToAuthenticate": "Kliknite za autentifikaciju", "mcp.auth.clickToAuthenticate": "Klikni za autentifikaciju",
"mcp.status.disabled": "onemogućeno", "mcp.status.disabled": "onemogućeno",
"dialog.fork.empty": "Nema poruka za fork", "dialog.fork.empty": "Nema poruka za fork",
"dialog.directory.search.placeholder": "Pretraži foldere", "dialog.directory.search.placeholder": "Pretraži fascikle",
"dialog.directory.empty": "Nema pronađenih foldera", "dialog.directory.empty": "Nema pronađenih fascikli",
"dialog.directory.action.selectFile": "Odaberi datoteku", "dialog.directory.action.selectFile": "Odaberi datoteku",
"dialog.directory.action.selectFolder": "Odaberi folder", "dialog.directory.action.selectFolder": "Odaberi fasciklu",
"dialog.directory.root": "Korijen", "dialog.directory.root": "Korijen",
"dialog.directory.parent": "Nadređeni folder", "dialog.directory.parent": "Nadređena fascikla",
"dialog.directory.readError": "Nije moguće pročitati ovaj folder", "dialog.directory.readError": "Nije moguće pročitati ovu fasciklu",
"dialog.server.title": "Serveri", "dialog.server.title": "Serveri",
"dialog.server.description": "Promijeni na koji se OpenCode server ova aplikacija povezuje.", "dialog.server.description": "Promijeni na koji se OpenCode server ova aplikacija povezuje.",
@@ -348,7 +458,7 @@ export const dict = {
"dialog.server.edit.title": "Uredi server", "dialog.server.edit.title": "Uredi server",
"dialog.server.default.title": "Podrazumijevani server", "dialog.server.default.title": "Podrazumijevani server",
"dialog.server.default.description": "dialog.server.default.description":
"Poveži se na ovaj server pri pokretanju aplikacije umjesto pokretanja lokalnog servera. Potreban je restart.", "Poveži se na ovaj server pri pokretanju aplikacije umjesto pokretanja lokalnog servera. Potrebno je ponovo pokrenuti aplikaciju.",
"dialog.server.default.none": "Nije odabran server", "dialog.server.default.none": "Nije odabran server",
"dialog.server.default.set": "Postavi trenutni server kao podrazumijevani", "dialog.server.default.set": "Postavi trenutni server kao podrazumijevani",
"dialog.server.default.clear": "Očisti", "dialog.server.default.clear": "Očisti",
@@ -430,6 +540,22 @@ export const dict = {
"wsl.onboarding.versionMismatch": "Instalirana verzija ne odgovara verziji desktop aplikacije.", "wsl.onboarding.versionMismatch": "Instalirana verzija ne odgovara verziji desktop aplikacije.",
"wsl.onboarding.adding": "Dodavanje...", "wsl.onboarding.adding": "Dodavanje...",
"help.tabs.toast.ariaLabel": "Predstavljamo kartice. Organizuj svoj rad i aktivne sesije pomoću kartica",
"help.tabs.toast.dismiss": "Odbaci informacije o karticama",
"help.tabs.title": "Predstavljamo kartice",
"help.tabs.description": "Organizuj svoj rad i aktivne sesije pomoću kartica",
"help.tabs.date": "14. juli",
"help.tabs.introduction": "OpenCode Desktop sada je organizovan oko kartica.",
"help.tabs.sessions":
"Započni novu sesiju u kartici ili otvori postojeću sesiju iz bilo kojeg projekta. Otvori novu karticu kada započinješ nešto novo, a zatvori je kada završiš.",
"help.tabs.organize":
"Nekoliko otvorenih kartica olakšava organizovanje aktivnih sesija. Preimenuj kartice tako da ih možeš lako prepoznati ako ih namjeravaš zadržati.",
"help.tabs.home":
"Sve svoje sesije i projekte pronaći ćeš na novom početnom ekranu. Odabirom sesije ona se otvara u kartici.",
"help.tabs.persistence": "Kada ponovo otvoriš aplikaciju, tvoje kartice ostaju otvorene.",
"help.tabs.worktrees":
"Novi dizajn još ne podržava Git Worktrees, ali podrška stiže uskoro. Ako želiš nastaviti koristiti prethodni raspored, možeš mijenjati rasporede u Postavkama. Imaj na umu da će novi raspored za nekoliko sedmica postati trajan.",
"dialog.project.edit.title": "Uredi projekat", "dialog.project.edit.title": "Uredi projekat",
"dialog.project.edit.name": "Naziv", "dialog.project.edit.name": "Naziv",
"dialog.project.edit.icon": "Ikonica", "dialog.project.edit.icon": "Ikonica",
@@ -442,9 +568,11 @@ export const dict = {
"dialog.project.edit.worktree.startup.description": "Pokreće se nakon kreiranja novog radnog prostora (worktree).", "dialog.project.edit.worktree.startup.description": "Pokreće se nakon kreiranja novog radnog prostora (worktree).",
"dialog.project.edit.worktree.startup.placeholder": "npr. bun install", "dialog.project.edit.worktree.startup.placeholder": "npr. bun install",
"dialog.usageExceeded.dontShowAgain": "Nemoj više prikazivati",
"context.breakdown.title": "Razlaganje konteksta", "context.breakdown.title": "Razlaganje konteksta",
"context.breakdown.note": "context.breakdown.note":
'Približna raspodjela ulaznih tokena. "Ostalo" uključuje definicije alata i dodatni overhead.', 'Približna raspodjela ulaznih tokena. "Ostalo" uključuje definicije alata i dodatno opterećenje.',
"context.breakdown.system": "Sistem", "context.breakdown.system": "Sistem",
"context.breakdown.user": "Korisnik", "context.breakdown.user": "Korisnik",
"context.breakdown.assistant": "Asistent", "context.breakdown.assistant": "Asistent",
@@ -454,6 +582,8 @@ export const dict = {
"context.systemPrompt.title": "Sistemski prompt", "context.systemPrompt.title": "Sistemski prompt",
"context.rawMessages.title": "Sirove poruke", "context.rawMessages.title": "Sirove poruke",
"context.export.session": "Izvezi sesiju",
"context.stats.session": "Sesija", "context.stats.session": "Sesija",
"context.stats.messages": "Poruke", "context.stats.messages": "Poruke",
"context.stats.provider": "Provajder", "context.stats.provider": "Provajder",
@@ -464,7 +594,7 @@ export const dict = {
"context.stats.inputTokens": "Ulazni tokeni", "context.stats.inputTokens": "Ulazni tokeni",
"context.stats.outputTokens": "Izlazni tokeni", "context.stats.outputTokens": "Izlazni tokeni",
"context.stats.reasoningTokens": "Tokeni za rasuđivanje", "context.stats.reasoningTokens": "Tokeni za rasuđivanje",
"context.stats.cacheTokens": "Cache tokeni (čitanje/pisanje)", "context.stats.cacheTokens": "Keširani tokeni (čitanje/pisanje)",
"context.stats.userMessages": "Korisničke poruke", "context.stats.userMessages": "Korisničke poruke",
"context.stats.assistantMessages": "Poruke asistenta", "context.stats.assistantMessages": "Poruke asistenta",
"context.stats.totalCost": "Ukupni trošak", "context.stats.totalCost": "Ukupni trošak",
@@ -532,18 +662,23 @@ export const dict = {
"toast.session.unshare.failed.title": "Neuspjelo ukidanje dijeljenja", "toast.session.unshare.failed.title": "Neuspjelo ukidanje dijeljenja",
"toast.session.unshare.failed.description": "Došlo je do greške prilikom ukidanja dijeljenja", "toast.session.unshare.failed.description": "Došlo je do greške prilikom ukidanja dijeljenja",
"toast.session.export.success.title": "Sesija izvezena",
"toast.session.export.success.description": "Sesija je sačuvana kao {{filename}}",
"toast.session.export.failed.title": "Izvoz sesije nije uspio",
"toast.session.export.failed.description": "Došlo je do greške prilikom izvoza sesije",
"toast.session.listFailed.title": "Neuspjelo učitavanje sesija za {{project}}", "toast.session.listFailed.title": "Neuspjelo učitavanje sesija za {{project}}",
"toast.update.title": "Dostupno ažuriranje", "toast.update.title": "Dostupno ažuriranje",
"toast.update.description": "Nova verzija OpenCode-a ({{version}}) je dostupna za instalaciju.", "toast.update.description": "Nova verzija OpenCode-a ({{version}}) je dostupna za instalaciju.",
"toast.update.action.installRestart": "Instaliraj i restartuj", "toast.update.action.installRestart": "Instaliraj i ponovo pokreni",
"toast.update.action.notYet": "Ne još", "toast.update.action.notYet": "Ne još",
"error.page.title": "Nešto je pošlo po zlu", "error.page.title": "Nešto je pošlo po zlu",
"error.page.description": "Došlo je do greške prilikom učitavanja aplikacije.", "error.page.description": "Došlo je do greške prilikom učitavanja aplikacije.",
"error.page.description.localServerStartup": "Došlo je do greške pri pokretanju lokalnog servera.", "error.page.description.localServerStartup": "Došlo je do greške pri pokretanju lokalnog servera.",
"error.page.details.label": "Detalji greške", "error.page.details.label": "Detalji greške",
"error.page.action.restart": "Restartuj", "error.page.action.restart": "Ponovo pokreni",
"error.page.action.report": "Prijavi grešku", "error.page.action.report": "Prijavi grešku",
"error.page.action.reported": "Greška prijavljena", "error.page.action.reported": "Greška prijavljena",
"error.page.action.exportLogs": "Izvezi logove", "error.page.action.exportLogs": "Izvezi logove",
@@ -568,7 +703,7 @@ export const dict = {
"error.chain.responseBody": "Tijelo odgovora:\n{{body}}", "error.chain.responseBody": "Tijelo odgovora:\n{{body}}",
"error.chain.didYouMean": "Da li si mislio: {{suggestions}}", "error.chain.didYouMean": "Da li si mislio: {{suggestions}}",
"error.chain.modelNotFound": "Model nije pronađen: {{provider}}/{{model}}", "error.chain.modelNotFound": "Model nije pronađen: {{provider}}/{{model}}",
"error.chain.checkConfig": "Provjeri konfiguraciju (opencode.json) provider/model names", "error.chain.checkConfig": "Provjeri nazive provajdera/modela u konfiguraciji (opencode.json)",
"error.chain.mcpFailed": 'MCP server "{{name}}" nije uspio. Napomena: OpenCode još ne podržava MCP autentifikaciju.', "error.chain.mcpFailed": 'MCP server "{{name}}" nije uspio. Napomena: OpenCode još ne podržava MCP autentifikaciju.',
"error.chain.providerAuthFailed": "Autentifikacija provajdera nije uspjela ({{provider}}): {{message}}", "error.chain.providerAuthFailed": "Autentifikacija provajdera nije uspjela ({{provider}}): {{message}}",
"error.chain.providerInitFailed": "error.chain.providerInitFailed":
@@ -610,7 +745,7 @@ export const dict = {
"home.sessions.group.yesterday": "Jučer", "home.sessions.group.yesterday": "Jučer",
"home.sessions.group.older": "Starije", "home.sessions.group.older": "Starije",
"home.providerTip": "home.providerTip":
"Povežite se s više od 75 pružalaca usluga kako biste koristili druge modele, uključujući Claude, GPT, Gemini i druge", "Poveži se s više od 75 provajdera da koristiš druge modele, uključujući Claude, GPT, Gemini i druge",
"session.tab.session": "Sesija", "session.tab.session": "Sesija",
"session.tab.review": "Pregled", "session.tab.review": "Pregled",
@@ -626,11 +761,11 @@ export const dict = {
"session.review.change.other": "Izmjene", "session.review.change.other": "Izmjene",
"session.review.loadingChanges": "Učitavanje izmjena...", "session.review.loadingChanges": "Učitavanje izmjena...",
"session.review.empty": "Još nema izmjena u ovoj sesiji", "session.review.empty": "Još nema izmjena u ovoj sesiji",
"session.review.noVcs": "Nije detektovan Git sistem kontrole verzija, promjene se ne prikazuju", "session.review.noVcs": "Nije otkriven Git sistem za kontrolu verzija, pa se promjene ne prikazuju",
"session.review.noSnapshot": "session.review.noSnapshot":
"Praćenje snimaka (snapshot) je onemogućeno u konfiguraciji, pa promjene sesije nisu dostupne", "Praćenje snimaka (snapshot) je onemogućeno u konfiguraciji, pa promjene sesije nisu dostupne",
"session.review.noChanges": "Nema izmjena", "session.review.noChanges": "Nema izmjena",
"session.review.noUncommittedChanges": "Još nema nepotvrđenih promjena", "session.review.noUncommittedChanges": "Još nema promjena koje nisu commitovane",
"session.review.noBranchChanges": "Još nema promjena na grani", "session.review.noBranchChanges": "Još nema promjena na grani",
"session.files.selectToOpen": "Odaberi datoteku za otvaranje", "session.files.selectToOpen": "Odaberi datoteku za otvaranje",
@@ -651,14 +786,17 @@ export const dict = {
"session.question.minimize": "Minimiziraj pitanje", "session.question.minimize": "Minimiziraj pitanje",
"session.question.restore": "Vrati pitanje", "session.question.restore": "Vrati pitanje",
"session.question.pending.one": "{{count}} pitanje na čekanju", "session.question.pending.one": "{{count}} pitanje na čekanju",
"session.question.pending.few": "{{count}} pitanja na čekanju",
"session.question.pending.other": "{{count}} pitanja na čekanju", "session.question.pending.other": "{{count}} pitanja na čekanju",
"session.followupDock.summary.one": "{{count}} poruka na čekanju", "session.followupDock.summary.one": "{{count}} poruka na čekanju",
"session.followupDock.summary.few": "{{count}} poruke na čekanju",
"session.followupDock.summary.other": "{{count}} poruka na čekanju", "session.followupDock.summary.other": "{{count}} poruka na čekanju",
"session.followupDock.sendNow": "Pošalji sada", "session.followupDock.sendNow": "Pošalji sada",
"session.followupDock.edit": "Uredi", "session.followupDock.edit": "Uredi",
"session.followupDock.collapse": "Sažmi poruke na čekanju", "session.followupDock.collapse": "Sažmi poruke na čekanju",
"session.followupDock.expand": "Proširi poruke na čekanju", "session.followupDock.expand": "Proširi poruke na čekanju",
"session.revertDock.summary.one": "{{count}} vraćena poruka", "session.revertDock.summary.one": "{{count}} vraćena poruka",
"session.revertDock.summary.few": "{{count}} vraćene poruke",
"session.revertDock.summary.other": "{{count}} vraćenih poruka", "session.revertDock.summary.other": "{{count}} vraćenih poruka",
"session.revertDock.collapse": "Sažmi vraćene poruke", "session.revertDock.collapse": "Sažmi vraćene poruke",
"session.revertDock.expand": "Proširi vraćene poruke", "session.revertDock.expand": "Proširi vraćene poruke",
@@ -716,7 +854,13 @@ export const dict = {
"terminal.close": "Zatvori terminal", "terminal.close": "Zatvori terminal",
"terminal.connectionLost.title": "Veza prekinuta", "terminal.connectionLost.title": "Veza prekinuta",
"terminal.connectionLost.description": "terminal.connectionLost.description":
"Veza s terminalom je prekinuta. Ovo se može desiti kada se server restartuje.", "Veza s terminalom je prekinuta. Ovo se može desiti kada se server ponovo pokrene.",
"terminal.connectTicket.csrfError":
"PTY tiket za povezivanje odbijen je zbog provjera porijekla ili CSRF-a. Provjeri CORS konfiguraciju servera.",
"terminal.connectTicket.statusError": "PTY tiket za povezivanje nije uspio sa statusom {{status}}",
"titlebar.update": "Ažuriraj",
"titlebar.updateVersion": "Ažuriraj {{version}}",
"common.closeTab": "Zatvori karticu", "common.closeTab": "Zatvori karticu",
"common.dismiss": "Odbaci", "common.dismiss": "Odbaci",
@@ -724,7 +868,8 @@ export const dict = {
"common.moreOptions": "Više opcija", "common.moreOptions": "Više opcija",
"common.learnMore": "Saznaj više", "common.learnMore": "Saznaj više",
"common.rename": "Preimenuj", "common.rename": "Preimenuj",
"common.reset": "Resetuj", "common.export": "Izvezi",
"common.reset": "Vrati na početno stanje",
"common.archive": "Arhiviraj", "common.archive": "Arhiviraj",
"common.delete": "Izbriši", "common.delete": "Izbriši",
"common.close": "Zatvori", "common.close": "Zatvori",
@@ -781,7 +926,7 @@ export const dict = {
"settings.general.row.font.title": "Font za kod", "settings.general.row.font.title": "Font za kod",
"settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda", "settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda",
"settings.general.row.terminalFont.title": "Font terminala", "settings.general.row.terminalFont.title": "Font terminala",
"settings.general.row.terminalFont.description": "Prilagodite font koji se koristi u terminalu", "settings.general.row.terminalFont.description": "Prilagodi font koji se koristi u terminalu",
"settings.general.row.uiFont.title": "UI font", "settings.general.row.uiFont.title": "UI font",
"settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu", "settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
"settings.general.row.followup.title": "Ponašanje nadovezivanja", "settings.general.row.followup.title": "Ponašanje nadovezivanja",
@@ -816,15 +961,16 @@ export const dict = {
"settings.general.row.newInterface.title": "Novi raspored", "settings.general.row.newInterface.title": "Novi raspored",
"settings.general.row.newInterface.badge": "Novo", "settings.general.row.newInterface.badge": "Novo",
"settings.general.row.newInterface.description": "settings.general.row.newInterface.description":
"Koristite nove kartice i raspored početne stranice. Ograničeno vrijeme možete se prebacivati između rasporeda.", "Koristi nove kartice i raspored početne stranice. Ograničeno vrijeme možeš se prebacivati između rasporeda.",
"settings.general.row.newInterfaceNotice.title": "Sada koristite novi raspored", "settings.general.row.newInterfaceNotice.title": "Sada koristiš novi raspored",
"settings.general.row.newInterfaceNotice.description": "Prethodni raspored više nije dostupan", "settings.general.row.newInterfaceNotice.description": "Prethodni raspored više nije dostupan",
"settings.general.row.newInterfaceNotice.dismiss": "Odbaci", "settings.general.row.newInterfaceNotice.dismiss": "Odbaci",
"settings.general.row.pinchZoom.title": "Zumiranje štipanjem", "settings.general.row.pinchZoom.title": "Zumiranje štipanjem",
"settings.general.row.pinchZoom.description": "settings.general.row.pinchZoom.description":
"Dozvoli zumiranje gestom štipanja na dodirnoj ploči i pomoću Ctrl-pomjeranja", "Dozvoli zumiranje gestom štipanja na dodirnoj ploči i pomoću Ctrl-pomjeranja",
"settings.general.row.wayland.title": "Koristi nativni Wayland", "settings.general.row.wayland.title": "Koristi nativni Wayland",
"settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.", "settings.general.row.wayland.description":
"Onemogući rezervni X11 način rada na Waylandu. Zahtijeva ponovno pokretanje.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
"Na Linuxu sa monitorima miješanih stopa osvježavanja, nativni Wayland može biti stabilniji.", "Na Linuxu sa monitorima miješanih stopa osvježavanja, nativni Wayland može biti stabilniji.",
@@ -940,7 +1086,7 @@ export const dict = {
"settings.mcp.description": "MCP postavke će se ovdje moći podešavati.", "settings.mcp.description": "MCP postavke će se ovdje moći podešavati.",
"settings.permissions.title": "Dozvole", "settings.permissions.title": "Dozvole",
"settings.permissions.description": "Kontroliši koje alate server smije koristiti po defaultu.", "settings.permissions.description": "Kontroliši koje alate server smije koristiti zadano.",
"settings.permissions.section.tools": "Alati", "settings.permissions.section.tools": "Alati",
"settings.permissions.toast.updateFailed.title": "Neuspjelo ažuriranje dozvola", "settings.permissions.toast.updateFailed.title": "Neuspjelo ažuriranje dozvola",
@@ -952,7 +1098,7 @@ export const dict = {
"settings.permissions.tool.read.description": "Čitanje datoteke (podudara se s putanjom datoteke)", "settings.permissions.tool.read.description": "Čitanje datoteke (podudara se s putanjom datoteke)",
"settings.permissions.tool.edit.title": "Uređivanje", "settings.permissions.tool.edit.title": "Uređivanje",
"settings.permissions.tool.edit.description": "settings.permissions.tool.edit.description":
"Mijenjanje datoteka, uključujući izmjene, pisanja, patch-eve i multi-izmjene", "Mijenjanje datoteka, uključujući uređivanje, pisanje, zakrpe i višestruke izmjene",
"settings.permissions.tool.glob.title": "Glob", "settings.permissions.tool.glob.title": "Glob",
"settings.permissions.tool.glob.description": "Podudaranje datoteka pomoću glob šablona", "settings.permissions.tool.glob.description": "Podudaranje datoteka pomoću glob šablona",
"settings.permissions.tool.grep.title": "Grep", "settings.permissions.tool.grep.title": "Grep",
@@ -988,26 +1134,26 @@ export const dict = {
"workspace.type.sandbox": "sandbox", "workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Neuspješno kreiranje radnog prostora", "workspace.create.failed.title": "Neuspješno kreiranje radnog prostora",
"workspace.delete.failed.title": "Neuspješno brisanje radnog prostora", "workspace.delete.failed.title": "Neuspješno brisanje radnog prostora",
"workspace.resetting.title": "Resetovanje radnog prostora", "workspace.resetting.title": "Vraćanje radnog prostora na početno stanje",
"workspace.resetting.description": "Ovo može potrajati minut.", "workspace.resetting.description": "Ovo može potrajati minut.",
"workspace.reset.failed.title": "Neuspješno resetovanje radnog prostora", "workspace.reset.failed.title": "Neuspješno vraćanje radnog prostora na početno stanje",
"workspace.reset.success.title": "Radni prostor resetovan", "workspace.reset.success.title": "Radni prostor vraćen na početno stanje",
"workspace.reset.success.description": "Radni prostor sada odgovara podrazumijevanoj grani.", "workspace.reset.success.description": "Radni prostor sada odgovara podrazumijevanoj grani.",
"workspace.error.stillPreparing": "Radni prostor se još priprema", "workspace.error.stillPreparing": "Radni prostor se još priprema",
"workspace.status.checking": "Provjera neobjedinjenih promjena...", "workspace.status.checking": "Provjera promjena koje nisu commitovane...",
"workspace.status.error": "Nije moguće provjeriti git status.", "workspace.status.error": "Nije moguće provjeriti git status.",
"workspace.status.clean": "Nisu pronađene neobjedinjene promjene.", "workspace.status.clean": "Nisu pronađene promjene koje nisu commitovane.",
"workspace.status.dirty": "Pronađene su neobjedinjene promjene u ovom radnom prostoru.", "workspace.status.dirty": "Pronađene su promjene koje nisu commitovane u ovom radnom prostoru.",
"workspace.delete.title": "Izbriši radni prostor", "workspace.delete.title": "Izbriši radni prostor",
"workspace.delete.confirm": 'Izbriši radni prostor "{{name}}"?', "workspace.delete.confirm": 'Izbriši radni prostor "{{name}}"?',
"workspace.delete.button": "Izbriši radni prostor", "workspace.delete.button": "Izbriši radni prostor",
"workspace.reset.title": "Resetuj radni prostor", "workspace.reset.title": "Vrati radni prostor na početno stanje",
"workspace.reset.confirm": 'Resetuj radni prostor "{{name}}"?', "workspace.reset.confirm": 'Vrati radni prostor "{{name}}" na početno stanje?',
"workspace.reset.button": "Resetuj radni prostor", "workspace.reset.button": "Vrati radni prostor na početno stanje",
"workspace.reset.archived.none": "Nijedna aktivna sesija neće biti arhivirana.", "workspace.reset.archived.none": "Nijedna aktivna sesija neće biti arhivirana.",
"workspace.reset.archived.one": "1 sesija će biti arhivirana.", "workspace.reset.archived.one": "1 sesija će biti arhivirana.",
"workspace.reset.archived.many": "Biće arhivirano {{count}} sesija.", "workspace.reset.archived.many": "Biće arhivirano {{count}} sesija.",
"workspace.reset.note": "Ovo će resetovati radni prostor da odgovara podrazumijevanoj grani.", "workspace.reset.note": "Ovo će vratiti radni prostor na stanje podrazumijevane grane.",
"common.open": "Otvori", "common.open": "Otvori",
"dialog.releaseNotes.action.getStarted": "Započni", "dialog.releaseNotes.action.getStarted": "Započni",
"dialog.releaseNotes.action.next": "Sljedeće", "dialog.releaseNotes.action.next": "Sljedeće",
@@ -1020,8 +1166,8 @@ export const dict = {
"common.time.minutesAgo.short": "prije {{count}} min", "common.time.minutesAgo.short": "prije {{count}} min",
"common.time.hoursAgo.short": "prije {{count}} h", "common.time.hoursAgo.short": "prije {{count}} h",
"common.time.daysAgo.short": "prije {{count}} d", "common.time.daysAgo.short": "prije {{count}} d",
"settings.providers.connected.environmentDescription": "Povezano sa vašim varijablama okruženja", "settings.providers.connected.environmentDescription": "Povezano s tvojim varijablama okruženja",
"settings.providers.custom.description": "Dodajte provajdera kompatibilnog s OpenAI putem osnovnog URL-a.", "settings.providers.custom.description": "Dodaj provajdera kompatibilnog s OpenAI putem baznog URL-a.",
"app.server.unreachable": "Nije moguće pristupiti {{server}}", "app.server.unreachable": "Nije moguće pristupiti {{server}}",
"app.server.retrying": "Automatski ponovni pokušaj...", "app.server.retrying": "Automatski ponovni pokušaj...",
@@ -1030,13 +1176,16 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "lozinka", "dialog.server.add.passwordPlaceholder": "lozinka",
"server.row.noUsername": "nema korisničkog imena", "server.row.noUsername": "nema korisničkog imena",
"session.review.noVcs.createGit.title": "Kreiraj Git repozitorij", "session.review.noVcs.createGit.title": "Kreiraj Git repozitorij",
"session.review.noVcs.createGit.description": "Pratite, pregledajte i poništite promjene u ovom projektu", "session.review.noVcs.createGit.description": "Prati, pregledaj i poništi promjene u ovom projektu",
"session.review.noVcs.createGit.actionLoading": "Kreiranje Git repozitorija...", "session.review.noVcs.createGit.actionLoading": "Kreiranje Git repozitorija...",
"session.review.noVcs.createGit.action": "Kreiraj Git repozitorij", "session.review.noVcs.createGit.action": "Kreiraj Git repozitorij",
"session.todo.progress": "{{done}} od {{total}} zadataka završeno", "session.todo.progress": "{{done}} od {{total}} zadataka završeno",
"session.question.progress": "{{current}} od {{total}} pitanja", "session.question.progress": "{{current}} od {{total}} pitanja",
"session.header.open.finder": "Finder", "session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "File Explorer", "session.header.open.fileExplorer": "File Explorer",
"session.header.reveal.finder": "Prikaži u Finderu",
"session.header.reveal.fileExplorer": "Prikaži u File Exploreru",
"session.header.reveal.containingFolder": "Otvori fasciklu koja sadrži ovu stavku",
"session.header.open.fileManager": "File Manager", "session.header.open.fileManager": "File Manager",
"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",
@@ -1074,6 +1223,14 @@ export const dict = {
"debugBar.mem.label": "MEM", "debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Korišteni JS heap naspram limita heapa. Samo Chromium.", "debugBar.mem.tipUnavailable": "Korišteni JS heap naspram limita heapa. Samo Chromium.",
"debugBar.mem.tip": "Korišteni JS heap naspram limita heapa. {{used}} od {{limit}}.", "debugBar.mem.tip": "Korišteni JS heap naspram limita heapa. {{used}} od {{limit}}.",
"debugBar.focus.label": "FOCUS",
"debugBar.focus.tip": "Prisilno primijeni stilove fokusa na sve interaktivne elemente",
"debugBar.focus.on": "UKLJUČENO",
"debugBar.focus.off": "ISKLJUČENO",
"debugBar.direction.label": "DIR",
"debugBar.direction.tip": "Prisilno postavi smjer rasporeda cijele aplikacije bez promjene odabranog jezika",
"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",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+194 -38
View File
@@ -92,6 +92,9 @@ export const dict = {
"command.session.unshare": "Stop deling af session", "command.session.unshare": "Stop deling af session",
"command.session.unshare.description": "Stop med at dele denne session", "command.session.unshare.description": "Stop med at dele denne session",
"command.session.export": "Eksportér session",
"command.session.export.description": "Eksportér hele sessionsudskriften som JSON",
"palette.search.placeholder": "Søg i filer, kommandoer og sessioner", "palette.search.placeholder": "Søg i filer, kommandoer og sessioner",
"palette.search.placeholder.home": "Søg i kommandoer og sessioner", "palette.search.placeholder.home": "Søg i kommandoer og sessioner",
"palette.empty": "Ingen resultater fundet", "palette.empty": "Ingen resultater fundet",
@@ -102,6 +105,7 @@ export const dict = {
"dialog.provider.empty": "Ingen udbydere fundet", "dialog.provider.empty": "Ingen udbydere fundet",
"dialog.provider.group.popular": "Populære", "dialog.provider.group.popular": "Populære",
"dialog.provider.group.other": "Andre", "dialog.provider.group.other": "Andre",
"dialog.provider.custom.label": "Brugerdefineret OpenAI-kompatibel udbyder",
"dialog.provider.tag.recommended": "Anbefalet", "dialog.provider.tag.recommended": "Anbefalet",
"dialog.provider.opencode.note": "Udvalgte modeller inklusive Claude, GPT, Gemini og flere", "dialog.provider.opencode.note": "Udvalgte modeller inklusive Claude, GPT, Gemini og flere",
"dialog.provider.opencode.tagline": "Pålidelige optimerede modeller", "dialog.provider.opencode.tagline": "Pålidelige optimerede modeller",
@@ -122,6 +126,7 @@ export const dict = {
"dialog.model.unpaid.freeModels.title": "Gratis modeller leveret af OpenCode", "dialog.model.unpaid.freeModels.title": "Gratis modeller leveret af OpenCode",
"dialog.model.unpaid.addMore.title": "Tilføj flere modeller fra populære udbydere", "dialog.model.unpaid.addMore.title": "Tilføj flere modeller fra populære udbydere",
"dialog.model.unpaid.viewMoreProviders": "Se mere end 70 yderligere udbydere",
"dialog.provider.viewAll": "Vis flere udbydere", "dialog.provider.viewAll": "Vis flere udbydere",
@@ -129,12 +134,14 @@ export const dict = {
"provider.connect.title.anthropicProMax": "Log ind med Claude Pro/Max", "provider.connect.title.anthropicProMax": "Log ind med Claude Pro/Max",
"provider.connect.selectMethod": "Vælg loginmetode for {{provider}}.", "provider.connect.selectMethod": "Vælg loginmetode for {{provider}}.",
"provider.connect.method.apiKey": "API-nøgle", "provider.connect.method.apiKey": "API-nøgle",
"provider.connect.method.browser": "Browser",
"provider.connect.method.headless": "Browserløs",
"provider.connect.status.inProgress": "Godkendelse i gang...", "provider.connect.status.inProgress": "Godkendelse i gang...",
"provider.connect.status.waiting": "Venter på godkendelse...", "provider.connect.status.waiting": "Venter på godkendelse...",
"provider.connect.status.failed": "Godkendelse mislykkedes: {{error}}", "provider.connect.status.failed": "Godkendelse mislykkedes: {{error}}",
"provider.connect.apiKey.description": "provider.connect.apiKey.description":
"Indtast din {{provider}} API-nøgle for at forbinde din konto og bruge {{provider}} modeller i OpenCode.", "Indtast din API-nøgle til {{provider}} for at forbinde din konto og bruge modeller fra {{provider}} i OpenCode.",
"provider.connect.apiKey.label": "{{provider}} API-nøgle", "provider.connect.apiKey.label": "API-nøgle til {{provider}}",
"provider.connect.apiKey.placeholder": "API-nøgle", "provider.connect.apiKey.placeholder": "API-nøgle",
"provider.connect.apiKey.required": "API-nøgle er påkrævet", "provider.connect.apiKey.required": "API-nøgle er påkrævet",
"provider.connect.opencodeZen.line1": "provider.connect.opencodeZen.line1":
@@ -147,7 +154,7 @@ export const dict = {
"provider.connect.oauth.code.visit.prefix": "Besøg ", "provider.connect.oauth.code.visit.prefix": "Besøg ",
"provider.connect.oauth.code.visit.link": "dette link", "provider.connect.oauth.code.visit.link": "dette link",
"provider.connect.oauth.code.visit.suffix": "provider.connect.oauth.code.visit.suffix":
" for at hente din godkendelseskode for at forbinde din konto og bruge {{provider}} modeller i OpenCode.", " for at hente din godkendelseskode, forbinde din konto og bruge modeller fra {{provider}} i OpenCode.",
"provider.connect.oauth.code.label": "{{method}} godkendelseskode", "provider.connect.oauth.code.label": "{{method}} godkendelseskode",
"provider.connect.oauth.code.placeholder": "Godkendelseskode", "provider.connect.oauth.code.placeholder": "Godkendelseskode",
"provider.connect.oauth.code.required": "Godkendelseskode er påkrævet", "provider.connect.oauth.code.required": "Godkendelseskode er påkrævet",
@@ -155,12 +162,13 @@ export const dict = {
"provider.connect.oauth.auto.visit.prefix": "Besøg ", "provider.connect.oauth.auto.visit.prefix": "Besøg ",
"provider.connect.oauth.auto.visit.link": "dette link", "provider.connect.oauth.auto.visit.link": "dette link",
"provider.connect.oauth.auto.visit.suffix": "provider.connect.oauth.auto.visit.suffix":
" og indtast koden nedenfor for at forbinde din konto og bruge {{provider}} modeller i OpenCode.", " og indtast koden nedenfor for at forbinde din konto og bruge modeller fra {{provider}} i OpenCode.",
"provider.connect.oauth.auto.confirmationCode": "Bekræftelseskode", "provider.connect.oauth.auto.confirmationCode": "Bekræftelseskode",
"provider.connect.toast.connected.title": "{{provider}} forbundet", "provider.connect.toast.connected.title": "{{provider}} forbundet",
"provider.connect.toast.connected.description": "{{provider}} modeller er nu tilgængelige.", "provider.connect.toast.connected.description": "Modeller fra {{provider}} er nu tilgængelige.",
"provider.custom.title": "Brugerdefineret udbyder", "provider.custom.title": "Brugerdefineret udbyder",
"provider.custom.unavailable": "Brugerdefinerede udbydere er ikke tilgængelige på denne server",
"provider.custom.description.prefix": "Konfigurer en OpenAI-kompatibel udbyder. Se ", "provider.custom.description.prefix": "Konfigurer en OpenAI-kompatibel udbyder. Se ",
"provider.custom.description.link": "dokumentation for udbyderkonfiguration", "provider.custom.description.link": "dokumentation for udbyderkonfiguration",
"provider.custom.description.suffix": ".", "provider.custom.description.suffix": ".",
@@ -217,9 +225,9 @@ export const dict = {
"model.tooltip.inputs": "Input", "model.tooltip.inputs": "Input",
"model.tooltip.model": "Model", "model.tooltip.model": "Model",
"model.tooltip.provider": "Udbyder", "model.tooltip.provider": "Udbyder",
"model.tooltip.reasoning": "Tænkning", "model.tooltip.reasoning": "Ræsonnering",
"model.tooltip.reasoning.allowed": "Tillader tænkning", "model.tooltip.reasoning.allowed": "Tillader ræsonnering",
"model.tooltip.reasoning.none": "Ingen tænkning", "model.tooltip.reasoning.none": "Ingen ræsonnering",
"model.tooltip.context": "Kontekstgrænse {{limit}}", "model.tooltip.context": "Kontekstgrænse {{limit}}",
"common.search.placeholder": "Søg", "common.search.placeholder": "Søg",
"common.goBack": "Gå tilbage", "common.goBack": "Gå tilbage",
@@ -230,7 +238,7 @@ export const dict = {
"common.clear": "Ryd", "common.clear": "Ryd",
"common.connect": "Forbind", "common.connect": "Forbind",
"common.disconnect": "Frakobl", "common.disconnect": "Frakobl",
"common.continue": "Indsend", "common.continue": "Fortsæt",
"common.submit": "Indsend", "common.submit": "Indsend",
"common.save": "Gem", "common.save": "Gem",
"common.saving": "Gemmer...", "common.saving": "Gemmer...",
@@ -253,7 +261,7 @@ export const dict = {
"prompt.example.3": "Ret ødelagte tests", "prompt.example.3": "Ret ødelagte tests",
"prompt.example.4": "Forklar hvordan godkendelse fungerer", "prompt.example.4": "Forklar hvordan godkendelse fungerer",
"prompt.example.5": "Find og ret sikkerhedshuller", "prompt.example.5": "Find og ret sikkerhedshuller",
"prompt.example.6": "Tilføj enhedstests for brugerservice", "prompt.example.6": "Tilføj enhedstests for brugertjenesten",
"prompt.example.7": "Refaktorer denne funktion så den er mere læsbar", "prompt.example.7": "Refaktorer denne funktion så den er mere læsbar",
"prompt.example.8": "Hvad betyder denne fejl?", "prompt.example.8": "Hvad betyder denne fejl?",
"prompt.example.9": "Hjælp mig med at debugge dette problem", "prompt.example.9": "Hjælp mig med at debugge dette problem",
@@ -296,6 +304,7 @@ export const dict = {
"prompt.action.stop": "Stop", "prompt.action.stop": "Stop",
"prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning", "prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning",
"prompt.toast.attachmentDuplicate.title": "Denne fil er allerede uploadet",
"prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.", "prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.",
"prompt.toast.modelAgentRequired.title": "Vælg en agent og model", "prompt.toast.modelAgentRequired.title": "Vælg en agent og model",
"prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.", "prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.",
@@ -334,7 +343,7 @@ export const dict = {
"dialog.server.search.placeholder": "Søg servere", "dialog.server.search.placeholder": "Søg servere",
"dialog.server.empty": "Ingen servere endnu", "dialog.server.empty": "Ingen servere endnu",
"dialog.server.add.title": "Tilføj en server", "dialog.server.add.title": "Tilføj en server",
"dialog.server.add.url": "Server URL", "dialog.server.add.url": "Serveradresse",
"dialog.server.add.placeholder": "http://localhost:4096", "dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Kunne ikke forbinde til server", "dialog.server.add.error": "Kunne ikke forbinde til server",
"dialog.server.add.checking": "Tjekker...", "dialog.server.add.checking": "Tjekker...",
@@ -346,7 +355,7 @@ export const dict = {
"dialog.server.edit.title": "Rediger server", "dialog.server.edit.title": "Rediger server",
"dialog.server.default.title": "Standardserver", "dialog.server.default.title": "Standardserver",
"dialog.server.default.description": "dialog.server.default.description":
"Forbind til denne server ved start af app i stedet for at starte en lokal server. Kræver genstart.", "Opret forbindelse til denne server, når appen starter, i stedet for at starte en lokal server. Kræver genstart.",
"dialog.server.default.none": "Ingen server valgt", "dialog.server.default.none": "Ingen server valgt",
"dialog.server.default.set": "Sæt nuværende server som standard", "dialog.server.default.set": "Sæt nuværende server som standard",
"dialog.server.default.clear": "Ryd", "dialog.server.default.clear": "Ryd",
@@ -440,6 +449,8 @@ export const dict = {
"dialog.project.edit.worktree.startup": "Opstartsscript for arbejdsområde", "dialog.project.edit.worktree.startup": "Opstartsscript for arbejdsområde",
"dialog.project.edit.worktree.startup.description": "Køres efter oprettelse af et nyt arbejdsområde (worktree).", "dialog.project.edit.worktree.startup.description": "Køres efter oprettelse af et nyt arbejdsområde (worktree).",
"dialog.project.edit.worktree.startup.placeholder": "f.eks. bun install", "dialog.project.edit.worktree.startup.placeholder": "f.eks. bun install",
"dialog.usageExceeded.dontShowAgain": "Vis ikke igen",
"context.breakdown.title": "Kontekstfordeling", "context.breakdown.title": "Kontekstfordeling",
"context.breakdown.note": "context.breakdown.note":
'Omtrentlig fordeling af input-tokens. "Andre" inkluderer værktøjsdefinitioner og overhead.', 'Omtrentlig fordeling af input-tokens. "Andre" inkluderer værktøjsdefinitioner og overhead.',
@@ -452,6 +463,8 @@ export const dict = {
"context.systemPrompt.title": "Systemprompt", "context.systemPrompt.title": "Systemprompt",
"context.rawMessages.title": "Rå beskeder", "context.rawMessages.title": "Rå beskeder",
"context.export.session": "Eksportér session",
"context.stats.session": "Session", "context.stats.session": "Session",
"context.stats.messages": "Beskeder", "context.stats.messages": "Beskeder",
"context.stats.provider": "Udbyder", "context.stats.provider": "Udbyder",
@@ -461,8 +474,8 @@ export const dict = {
"context.stats.usage": "Forbrug", "context.stats.usage": "Forbrug",
"context.stats.inputTokens": "Input-tokens", "context.stats.inputTokens": "Input-tokens",
"context.stats.outputTokens": "Output-tokens", "context.stats.outputTokens": "Output-tokens",
"context.stats.reasoningTokens": "Tænke Tokens", "context.stats.reasoningTokens": "Ræsonneringstokens",
"context.stats.cacheTokens": "Cache Tokens (læs/skriv)", "context.stats.cacheTokens": "Cachetokens (læsning/skrivning)",
"context.stats.userMessages": "Brugerbeskeder", "context.stats.userMessages": "Brugerbeskeder",
"context.stats.assistantMessages": "Assistentbeskeder", "context.stats.assistantMessages": "Assistentbeskeder",
"context.stats.totalCost": "Samlede omkostninger", "context.stats.totalCost": "Samlede omkostninger",
@@ -529,6 +542,11 @@ export const dict = {
"toast.session.unshare.failed.title": "Kunne ikke stoppe deling af session", "toast.session.unshare.failed.title": "Kunne ikke stoppe deling af session",
"toast.session.unshare.failed.description": "Der opstod en fejl under stop af sessionsdeling", "toast.session.unshare.failed.description": "Der opstod en fejl under stop af sessionsdeling",
"toast.session.export.success.title": "Session eksporteret",
"toast.session.export.success.description": "Sessionen blev gemt i {{filename}}",
"toast.session.export.failed.title": "Kunne ikke eksportere session",
"toast.session.export.failed.description": "Der opstod en fejl under eksport af sessionen",
"toast.session.listFailed.title": "Kunne ikke indlæse sessioner for {{project}}", "toast.session.listFailed.title": "Kunne ikke indlæse sessioner for {{project}}",
"toast.update.title": "Opdatering tilgængelig", "toast.update.title": "Opdatering tilgængelig",
@@ -565,7 +583,7 @@ export const dict = {
"error.chain.responseBody": "Svarindhold:\n{{body}}", "error.chain.responseBody": "Svarindhold:\n{{body}}",
"error.chain.didYouMean": "Mente du: {{suggestions}}", "error.chain.didYouMean": "Mente du: {{suggestions}}",
"error.chain.modelNotFound": "Model ikke fundet: {{provider}}/{{model}}", "error.chain.modelNotFound": "Model ikke fundet: {{provider}}/{{model}}",
"error.chain.checkConfig": "Tjek dine konfigurations (opencode.json) udbyder/modelnavne", "error.chain.checkConfig": "Kontrollér udbyder- og modelnavnene i din konfiguration (opencode.json)",
"error.chain.mcpFailed": 'MCP-server "{{name}}" fejlede. Bemærk, OpenCode understøtter ikke MCP-godkendelse endnu.', "error.chain.mcpFailed": 'MCP-server "{{name}}" fejlede. Bemærk, OpenCode understøtter ikke MCP-godkendelse endnu.',
"error.chain.providerAuthFailed": "Udbydergodkendelse mislykkedes ({{provider}}): {{message}}", "error.chain.providerAuthFailed": "Udbydergodkendelse mislykkedes ({{provider}}): {{message}}",
"error.chain.providerInitFailed": "error.chain.providerInitFailed":
@@ -618,12 +636,12 @@ export const dict = {
"session.error.notFound.description": "Denne fane henviser til en session, der ikke længere findes på denne server.", "session.error.notFound.description": "Denne fane henviser til en session, der ikke længere findes på denne server.",
"session.error.notFound.closeTab": "Luk fane", "session.error.notFound.closeTab": "Luk fane",
"session.error.serverConnection": "Kan ikke oprette forbindelse til denne server", "session.error.serverConnection": "Kan ikke oprette forbindelse til denne server",
"session.review.filesChanged": "{{count}} Filer ændret", "session.review.filesChanged": "{{count}} ændrede filer",
"session.review.change.one": "Ændring", "session.review.change.one": "Ændring",
"session.review.change.other": "Ændringer", "session.review.change.other": "Ændringer",
"session.review.loadingChanges": "Indlæser ændringer...", "session.review.loadingChanges": "Indlæser ændringer...",
"session.review.empty": "Ingen ændringer i denne session endnu", "session.review.empty": "Ingen ændringer i denne session endnu",
"session.review.noVcs": "Intet Git versionsstyringssystem fundet, ændringer vises ikke", "session.review.noVcs": "Intet Git-versionsstyringssystem fundet, ændringer vises ikke",
"session.review.noSnapshot": "session.review.noSnapshot":
"Snapshot-sporing er deaktiveret i konfigurationen, så sessionsændringer er ikke tilgængelige", "Snapshot-sporing er deaktiveret i konfigurationen, så sessionsændringer er ikke tilgængelige",
"session.review.noChanges": "Ingen ændringer", "session.review.noChanges": "Ingen ændringer",
@@ -714,12 +732,20 @@ export const dict = {
"terminal.connectionLost.title": "Forbindelse mistet", "terminal.connectionLost.title": "Forbindelse mistet",
"terminal.connectionLost.description": "Terminalforbindelsen blev afbrudt. Dette kan ske, når serveren genstarter.", "terminal.connectionLost.description": "Terminalforbindelsen blev afbrudt. Dette kan ske, når serveren genstarter.",
"terminal.connectTicket.csrfError":
"PTY-forbindelsesticket blev afvist ved kontrollen af oprindelse eller CSRF. Kontrollér serverens CORS-konfiguration.",
"terminal.connectTicket.statusError": "PTY-forbindelsesticket mislykkedes med {{status}}",
"titlebar.update": "Opdater",
"titlebar.updateVersion": "Opdater {{version}}",
"common.closeTab": "Luk fane", "common.closeTab": "Luk fane",
"common.dismiss": "Afvis", "common.dismiss": "Afvis",
"common.requestFailed": "Forespørgsel mislykkedes", "common.requestFailed": "Forespørgsel mislykkedes",
"common.moreOptions": "Flere muligheder", "common.moreOptions": "Flere muligheder",
"common.learnMore": "Lær mere", "common.learnMore": "Lær mere",
"common.rename": "Omdøb", "common.rename": "Omdøb",
"common.export": "Eksportér",
"common.reset": "Nulstil", "common.reset": "Nulstil",
"common.archive": "Arkivér", "common.archive": "Arkivér",
"common.delete": "Slet", "common.delete": "Slet",
@@ -749,7 +775,7 @@ export const dict = {
"settings.tab.general": "Generelt", "settings.tab.general": "Generelt",
"settings.tab.shortcuts": "Genveje", "settings.tab.shortcuts": "Genveje",
"settings.desktop.section.wsl": "WSL", "settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration", "settings.desktop.wsl.title": "WSL-integration",
"settings.desktop.wsl.description": "Kør OpenCode-serveren inde i WSL på Windows.", "settings.desktop.wsl.description": "Kør OpenCode-serveren inde i WSL på Windows.",
"settings.general.section.appearance": "Udseende", "settings.general.section.appearance": "Udseende",
@@ -770,15 +796,16 @@ export const dict = {
"settings.general.row.appearance.title": "Udseende", "settings.general.row.appearance.title": "Udseende",
"settings.general.row.appearance.description": "Tilpas hvordan OpenCode ser ud på din enhed", "settings.general.row.appearance.description": "Tilpas hvordan OpenCode ser ud på din enhed",
"settings.general.row.colorScheme.title": "Farveskema", "settings.general.row.colorScheme.title": "Farveskema",
"settings.general.row.colorScheme.description": "Vælg om OpenCode følger systemets, lyst eller mørkt tema", "settings.general.row.colorScheme.description":
"Vælg, om OpenCode skal følge systemtemaet eller bruge et lyst eller mørkt tema",
"settings.general.row.theme.title": "Tema", "settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.", "settings.general.row.theme.description": "Tilpas OpenCodes tema.",
"settings.general.row.font.title": "Kode-skrifttype", "settings.general.row.font.title": "Kode-skrifttype",
"settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke", "settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke",
"settings.general.row.terminalFont.title": "Terminalskrifttype", "settings.general.row.terminalFont.title": "Terminalskrifttype",
"settings.general.row.terminalFont.description": "Tilpas den skrifttype, der bruges i terminalen", "settings.general.row.terminalFont.description": "Tilpas den skrifttype, der bruges i terminalen",
"settings.general.row.uiFont.title": "UI-skrifttype", "settings.general.row.uiFont.title": "UI-skrifttype",
"settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen", "settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugergrænsefladen",
"settings.general.row.followup.title": "Opfølgningsadfærd", "settings.general.row.followup.title": "Opfølgningsadfærd",
"settings.general.row.followup.description": "Vælg om opfølgende forespørgsler skal styre straks eller vente i kø", "settings.general.row.followup.description": "Vælg om opfølgende forespørgsler skal styre straks eller vente i kø",
"settings.general.row.followup.option.queue": "Kø", "settings.general.row.followup.option.queue": "Kø",
@@ -796,10 +823,11 @@ export const dict = {
"settings.general.row.mobileTitlebarBottom.title": "Navigation nederst", "settings.general.row.mobileTitlebarBottom.title": "Navigation nederst",
"settings.general.row.mobileTitlebarBottom.description": "settings.general.row.mobileTitlebarBottom.description":
"Placer titellinjen og sessionsfanerne nederst på skærmen på mobilenheder", "Placer titellinjen og sessionsfanerne nederst på skærmen på mobilenheder",
"settings.general.row.showCustomAgents.title": "Brugerdefinerede agenter", "settings.general.row.showCustomAgents.title": "Vis agent",
"settings.general.row.showCustomAgents.description": "Vis agentvælgeren i promptfeltet", "settings.general.row.showCustomAgents.description":
"settings.general.row.reasoningSummaries.title": "Vis tænkeoversigter", "Skift mellem agenter i promptfeltet. Når vælgeren er skjult, bruges Build-agenten som standard.",
"settings.general.row.reasoningSummaries.description": "Vis model tænkeoversigter i tidslinjen", "settings.general.row.reasoningSummaries.title": "Vis ræsonneringsoversigter",
"settings.general.row.reasoningSummaries.description": "Vis oversigter over modellens ræsonnering på tidslinjen",
"settings.general.row.shellToolPartsExpanded.title": "Udvid shell-værktøjsdele", "settings.general.row.shellToolPartsExpanded.title": "Udvid shell-værktøjsdele",
"settings.general.row.shellToolPartsExpanded.description": "Vis shell-værktøjsdele udvidet som standard i tidslinjen", "settings.general.row.shellToolPartsExpanded.description": "Vis shell-værktøjsdele udvidet som standard i tidslinjen",
@@ -815,13 +843,13 @@ export const dict = {
"settings.general.row.newInterfaceNotice.dismiss": "Afvis", "settings.general.row.newInterfaceNotice.dismiss": "Afvis",
"settings.general.row.pinchZoom.title": "Knib for at zoome", "settings.general.row.pinchZoom.title": "Knib for at zoome",
"settings.general.row.pinchZoom.description": "Tillad knibebevægelser på pegefeltet og Ctrl-rulning for at zoome", "settings.general.row.pinchZoom.description": "Tillad knibebevægelser på pegefeltet og Ctrl-rulning for at zoome",
"settings.general.row.wayland.title": "Brug native Wayland", "settings.general.row.wayland.title": "Brug indbygget Wayland",
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.", "settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
"På Linux med skærme med blandet opdateringshastighed kan native Wayland være mere stabilt.", "På Linux med skærme med forskellige opdateringshastigheder kan indbygget Wayland være mere stabilt.",
"settings.general.row.releaseNotes.title": "Udgivelsesnoter", "settings.general.row.releaseNotes.title": "Udgivelsesnoter",
"settings.general.row.releaseNotes.description": 'Vis "Hvad er nyt"-popups efter opdateringer', "settings.general.row.releaseNotes.description": 'Vis pop op-vinduer med "Hvad er nyt" efter opdateringer',
"settings.updates.row.startup.title": "Tjek for opdateringer ved opstart", "settings.updates.row.startup.title": "Tjek for opdateringer ved opstart",
"settings.updates.row.startup.description": "Tjek automatisk for opdateringer, når OpenCode starter", "settings.updates.row.startup.description": "Tjek automatisk for opdateringer, når OpenCode starter",
@@ -944,9 +972,9 @@ export const dict = {
"settings.permissions.tool.read.description": "Læsning af en fil (matcher filstien)", "settings.permissions.tool.read.description": "Læsning af en fil (matcher filstien)",
"settings.permissions.tool.edit.title": "Rediger", "settings.permissions.tool.edit.title": "Rediger",
"settings.permissions.tool.edit.description": "settings.permissions.tool.edit.description":
"Ændre filer, herunder redigeringer, skrivninger, patches og multi-redigeringer", "Rediger filer, herunder med redigeringer, skrivning, patches og multiredigeringer",
"settings.permissions.tool.glob.title": "Glob", "settings.permissions.tool.glob.title": "Glob",
"settings.permissions.tool.glob.description": "Match filer ved hjælp af glob-mønstre", "settings.permissions.tool.glob.description": "Find filer ved hjælp af globmønstre",
"settings.permissions.tool.grep.title": "Grep", "settings.permissions.tool.grep.title": "Grep",
"settings.permissions.tool.grep.description": "Søg i filindhold ved hjælp af regulære udtryk", "settings.permissions.tool.grep.description": "Søg i filindhold ved hjælp af regulære udtryk",
"settings.permissions.tool.list.title": "Liste", "settings.permissions.tool.list.title": "Liste",
@@ -959,8 +987,8 @@ export const dict = {
"settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn", "settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler", "settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler",
"settings.permissions.tool.todowrite.title": "Skriv To-do", "settings.permissions.tool.todowrite.title": "Skriv to-do",
"settings.permissions.tool.todowrite.description": "Opdater to-do listen", "settings.permissions.tool.todowrite.description": "Opdater to-do-listen",
"settings.permissions.tool.webfetch.title": "Webhentning", "settings.permissions.tool.webfetch.title": "Webhentning",
"settings.permissions.tool.webfetch.description": "Hent indhold fra en URL", "settings.permissions.tool.webfetch.description": "Hent indhold fra en URL",
"settings.permissions.tool.websearch.title": "Websøgning", "settings.permissions.tool.websearch.title": "Websøgning",
@@ -987,7 +1015,7 @@ export const dict = {
"workspace.reset.success.description": "Arbejdsområdet matcher nu hovedgrenen.", "workspace.reset.success.description": "Arbejdsområdet matcher nu hovedgrenen.",
"workspace.error.stillPreparing": "Arbejdsområdet er stadig ved at blive klargjort", "workspace.error.stillPreparing": "Arbejdsområdet er stadig ved at blive klargjort",
"workspace.status.checking": "Tjekker for uflettede ændringer...", "workspace.status.checking": "Tjekker for uflettede ændringer...",
"workspace.status.error": "Kunne ikke bekræfte git-status.", "workspace.status.error": "Kunne ikke bekræfte Git-status.",
"workspace.status.clean": "Ingen uflettede ændringer fundet.", "workspace.status.clean": "Ingen uflettede ændringer fundet.",
"workspace.status.dirty": "Uflettede ændringer fundet i dette arbejdsområde.", "workspace.status.dirty": "Uflettede ændringer fundet i dette arbejdsområde.",
"workspace.delete.title": "Slet arbejdsområde", "workspace.delete.title": "Slet arbejdsområde",
@@ -1029,6 +1057,9 @@ export const dict = {
"session.question.progress": "{{current}} af {{total}} spørgsmål", "session.question.progress": "{{current}} af {{total}} spørgsmål",
"session.header.open.finder": "Finder", "session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Stifinder", "session.header.open.fileExplorer": "Stifinder",
"session.header.reveal.finder": "Vis i Finder",
"session.header.reveal.fileExplorer": "Vis i Stifinder",
"session.header.reveal.containingFolder": "Åbn den overordnede mappe",
"session.header.open.fileManager": "Filhåndtering", "session.header.open.fileManager": "Filhåndtering",
"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",
@@ -1049,11 +1080,11 @@ export const dict = {
"debugBar.nav.tip": "debugBar.nav.tip":
"Sidste gennemførte ruteovergang, der berører en sessionsside, målt fra routerstart til den første optegning efter den falder til ro.", "Sidste gennemførte ruteovergang, der berører en sessionsside, målt fra routerstart til den første optegning efter den falder til ro.",
"debugBar.fps.label": "FPS", "debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Rullende billeder pr. sekund over de sidste 5 sekunder.", "debugBar.fps.tip": "Rullende antal billedrammer pr. sekund over de sidste 5 sekunder.",
"debugBar.frame.label": "FRAME", "debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Værste billedtid over de sidste 5 sekunder.", "debugBar.frame.tip": "Længste billedrammetid over de sidste 5 sekunder.",
"debugBar.jank.label": "JANK", "debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Billeder over 32ms i de sidste 5 sekunder.", "debugBar.jank.tip": "Billedrammer over 32 ms i de sidste 5 sekunder.",
"debugBar.long.label": "LONG", "debugBar.long.label": "LONG",
"debugBar.long.tip": "Blokeret tid og antal lange opgaver i de sidste 5 sekunder. Maks opgave: {{max}}.", "debugBar.long.tip": "Blokeret tid og antal lange opgaver i de sidste 5 sekunder. Maks opgave: {{max}}.",
"debugBar.delay.label": "DELAY", "debugBar.delay.label": "DELAY",
@@ -1064,8 +1095,16 @@ export const dict = {
"debugBar.cls.label": "CLS", "debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Kumulativt layoutskift for den nuværende app-levetid.", "debugBar.cls.tip": "Kumulativt layoutskift for den nuværende app-levetid.",
"debugBar.mem.label": "MEM", "debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Brugt JS-heap vs heap-grænse. Kun Chromium.", "debugBar.mem.tipUnavailable": "Brugt JS-heap sammenholdt med heapgrænsen. Kun Chromium.",
"debugBar.mem.tip": "Brugt JS-heap vs heap-grænse. {{used}} af {{limit}}.", "debugBar.mem.tip": "Brugt JS-heap sammenholdt med heapgrænsen. {{used}} af {{limit}}.",
"debugBar.focus.label": "FOCUS",
"debugBar.focus.tip": "Gennemtving fokusstile for alle interaktive elementer",
"debugBar.focus.on": "TIL",
"debugBar.focus.off": "FRA",
"debugBar.direction.label": "DIR",
"debugBar.direction.tip": "Gennemtving layoutretningen for hele appen uden at ændre det valgte sprog",
"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",
@@ -1089,4 +1128,121 @@ export const dict = {
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke oprette vedvarende projektikon", "error.childStore.persistedProjectIconCreateFailed": "Kunne ikke oprette vedvarende projektikon",
"error.childStore.storeCreateFailed": "Kunne ikke oprette lager", "error.childStore.storeCreateFailed": "Kunne ikke oprette lager",
"terminal.connectionLost.abnormalClose": "WebSocket lukkede unormalt: {{code}}", "terminal.connectionLost.abnormalClose": "WebSocket lukkede unormalt: {{code}}",
"desktop.menu.app": "OpenCode",
"desktop.menu.file": "Arkiv",
"desktop.menu.edit": "Rediger",
"desktop.menu.view": "Oversigt",
"desktop.menu.go": "Gå",
"desktop.menu.window": "Vindue",
"desktop.menu.help": "Hjælp",
"desktop.menu.checkForUpdates": "Søg efter opdateringer...",
"desktop.menu.settings": "Indstillinger",
"desktop.menu.reloadWebview": "Genindlæs webvisning",
"desktop.menu.restart": "Genstart",
"desktop.menu.exportLogs": "Eksportér logfiler...",
"desktop.menu.newSession": "Ny session",
"desktop.menu.openProject": "Åbn projekt...",
"desktop.menu.newWindow": "Nyt vindue",
"desktop.menu.closeWindow": "Luk vindue",
"desktop.menu.undo": "Fortryd",
"desktop.menu.redo": "Gentag",
"desktop.menu.cut": "Klip",
"desktop.menu.copy": "Kopier",
"desktop.menu.paste": "Sæt ind",
"desktop.menu.delete": "Slet",
"desktop.menu.selectAll": "Vælg alt",
"desktop.menu.toggleSidebar": "Vis eller skjul indholdsoversigt",
"desktop.menu.toggleTerminal": "Vis eller skjul terminal",
"desktop.menu.toggleFileTree": "Vis eller skjul filtræ",
"desktop.menu.reload": "Genindlæs",
"desktop.menu.toggleDeveloperTools": "Vis eller skjul udviklerværktøjer",
"desktop.menu.actualSize": "Faktisk størrelse",
"desktop.menu.zoomIn": "Zoom ind",
"desktop.menu.zoomOut": "Zoom ud",
"desktop.menu.toggleFullScreen": "Slå fuldskærmsvisning til eller fra",
"desktop.menu.back": "Tilbage",
"desktop.menu.forward": "Frem",
"desktop.menu.previousSession": "Forrige session",
"desktop.menu.nextSession": "Næste session",
"desktop.menu.previousProject": "Forrige projekt",
"desktop.menu.nextProject": "Næste projekt",
"desktop.menu.minimize": "Minimer",
"desktop.menu.maximize": "Maksimer",
"desktop.menu.documentation": "OpenCode-dokumentation",
"desktop.menu.supportForum": "Supportforum",
"desktop.menu.shareFeedback": "Giv feedback",
"desktop.menu.reportBug": "Rapportér en fejl",
"desktop.menu.ariaLabel": "OpenCode-menu",
"desktop.updater.dialog.checkFailed.message": "Søgningen efter opdateringer mislykkedes.",
"desktop.updater.dialog.checkFailed.title": "Opdateringsfejl",
"desktop.updater.dialog.upToDate.message": "Du er opdateret.",
"desktop.updater.dialog.upToDate.title": "Ingen opdateringer",
"desktop.updater.dialog.ready.message": "Opdatering {{version}} er downloadet. Vil du genstarte nu?",
"desktop.updater.dialog.ready.title": "Opdateringen er klar",
"desktop.updater.dialog.restart": "Genstart",
"desktop.updater.dialog.later": "Senere",
"desktop.recovery.action.relaunch": "Start igen",
"desktop.recovery.action.exportLogs": "Eksportér logfiler",
"desktop.recovery.action.keepWaiting": "Vent fortsat",
"desktop.recovery.action.quit": "Afslut",
"desktop.recovery.loadFailed": "OpenCode kunne ikke indlæses",
"desktop.recovery.terminated": "OpenCode-vinduet blev uventet afsluttet",
"desktop.recovery.unresponsive": "OpenCode svarer ikke",
"desktop.recovery.unresponsive.detail": "Du kan starte appen igen, åbne logfilerne eller fortsætte med at vente.",
"desktop.recovery.loadFailed.detail": "Vindue: {{window}}\nURL: {{url}}\nFejl: {{code}} {{description}}",
"desktop.recovery.terminated.detail": "Vindue: {{window}}\nÅrsag: {{reason}}\nKode: {{code}}",
"desktop.recovery.unknown": "<ukendt>",
"desktop.dialog.chooseFolder": "Vælg en mappe",
"desktop.dialog.chooseFile": "Vælg en fil",
"desktop.dialog.saveFile": "Gem fil",
"desktop.dialog.files": "Filer",
"desktop.server.local": "Lokal server",
"desktop.wsl.error.windowsOnly": "WSL er kun tilgængelig i Windows",
"desktop.wsl.error.unavailable": "WSL er ikke tilgængelig",
"desktop.wsl.error.listInstalled": "Kunne ikke vise installerede WSL-distributioner",
"desktop.wsl.error.listOnline": "Kunne ikke vise WSL-distributioner på nettet",
"desktop.wsl.error.executeDistro": "Kan ikke udføre kommandoer i distributionen",
"desktop.wsl.error.installWsl": "Installationen af WSL mislykkedes",
"desktop.wsl.error.installDistro": "Kunne ikke installere distributionen: {{distro}}",
"desktop.wsl.error.installOpencode": "Installationen af OpenCode mislykkedes",
"desktop.wsl.error.alreadyAdded": "{{distro}} er allerede tilføjet",
"desktop.wsl.error.opencodeMissing": "opencode er ikke installeret i denne distribution",
"desktop.wsl.error.opencodeCannotRun": "opencode er installeret, men kunne ikke køre",
"desktop.wsl.error.opencodeNotInstalled": "OpenCode er ikke installeret i {{distro}}",
"desktop.wsl.error.updateVersion":
"Opdateringen af OpenCode er fuldført, men {{distro}} rapporterer stadig {{installed}}. Forventet: {{expected}}",
"desktop.wsl.error.noVersion": "ingen version",
"desktop.wsl.error.serverExited": "WSL-serveren blev afsluttet efter opstart (code={{code}} signal={{signal}})",
"desktop.wsl.error.serverExitedBeforeHealthy":
"WSL-serveren blev afsluttet, før den var klar (code={{code}} signal={{signal}}){{output}}",
"desktop.wsl.error.healthTimeout":
"Tilstandskontrollen af sidecar-processen for {{distro}} fik timeout efter {{timeout}} ms",
"desktop.wsl.error.commandTimeout": "{{command}} {{args}} fik timeout efter {{timeout}} ms",
"desktop.wsl.error.failedPort": "Kunne ikke hente porten",
"desktop.picker.error.notSelected": "Filen blev ikke valgt i filvælgeren",
"desktop.picker.error.sizeLimit": "De valgte vedhæftede filer overskrider grænsen på {{limit}} MB",
"command.logs.export": "Eksportér logfiler",
"help.tabs.toast.ariaLabel": "Introduktion til faner. Organiser dit arbejde og dine aktive sessioner med faner",
"help.tabs.toast.dismiss": "Luk oplysninger om faner",
"help.tabs.title": "Introduktion til faner",
"help.tabs.description": "Organiser dit arbejde og dine aktive sessioner med faner",
"help.tabs.date": "14. juli",
"help.tabs.introduction": "OpenCode Desktop er nu bygget op omkring faner.",
"help.tabs.sessions":
"Start en ny session i en fane, eller åbn en eksisterende session fra et af dine projekter. Åbn en ny fane, når du går i gang med noget nyt, og luk den, når du er færdig.",
"help.tabs.organize":
"Når du holder nogle få faner åbne, er det nemmere at organisere dine aktive sessioner. Giv fanerne navne, der er nemme at huske, hvis du vil beholde dem.",
"help.tabs.home":
"Du finder alle dine sessioner og projekter på den nye startskærm. Når du vælger en session, åbnes den i en fane.",
"help.tabs.persistence": "Dine faner er stadig åbne, når du åbner appen igen.",
"help.tabs.worktrees":
"Det nye design understøtter endnu ikke Git Worktrees, men understøttelsen kommer snart. Hvis du foretrækker at fortsætte med det tidligere layout, kan du skifte mellem layoutene under Indstillinger. Bemærk, at det nye layout bliver permanent om nogle få uger.",
} }

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