mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 03:19:59 -04:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b22909f1f2 | |||
| bc7dde9747 | |||
| 0a5df7ab01 | |||
| 5053c1bfdd | |||
| d97aca139f | |||
| 2a4cee42aa | |||
| b2f0f45c9e | |||
| f64b024f46 | |||
| ec11d6ed03 | |||
| 0dcedc199c | |||
| e9c55a15d4 | |||
| 9bc4eea79a | |||
| 68b2b08853 | |||
| 7bd6d002bb | |||
| ab872ffb0e | |||
| 84fd347afa | |||
| e8f215bfbc | |||
| 445af9ce70 | |||
| bc51baa9a4 | |||
| ff0a0b0786 | |||
| 4eff0ee2db | |||
| cc0061f88b | |||
| f43f2b354c | |||
| c2ba0bef6b | |||
| 83b47693c3 | |||
| 1ca86ad973 | |||
| 7afe537c69 | |||
| e566c082f1 | |||
| bc93da4a46 | |||
| 4194d55522 | |||
| f0d1029719 | |||
| b274db88b0 | |||
| 9b7b402737 | |||
| 5e6370363b | |||
| dd6020656e | |||
| 8c758e443b | |||
| 0df6aed6ca | |||
| aa05fd23b3 | |||
| fd09760903 | |||
| 3a4772abb4 | |||
| 2cf65c025a | |||
| f3f1204802 | |||
| b3aea1c39d | |||
| 79a493e7f5 | |||
| 952d4d446f | |||
| 65c96f914b | |||
| d0f1768b74 |
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
|
- v2
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
generate:
|
generate:
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -251,4 +251,3 @@ opencode-drive stop --name demo
|
|||||||
```bash
|
```bash
|
||||||
opencode-drive dir --name demo
|
opencode-drive dir --name demo
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
name: rtl-aware-development
|
||||||
|
description: OpenCode Desktop should be RTL-aware. Use when implementing or reviewing RTL/LTR behavior in the web app, desktop app, CSS, menus, scrolling, resizing, icons, mixed-direction text, or Electron title bars.
|
||||||
|
---
|
||||||
|
|
||||||
|
# RTL-Aware Development
|
||||||
|
|
||||||
|
Treat direction as independent from language. Test English in both directions as well as real RTL and mixed-script content.
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Set `lang` and `dir` on the document, and propagate direction through component providers used by portaled menus and popovers. Do not change the selected locale merely to force RTL.
|
||||||
|
- Keep DOM and focus order semantic. Flexbox and Grid already follow `dir`; do not add `row-reverse`, CSS `order`, or reversed markup just to mirror a layout.
|
||||||
|
- Prefer logical CSS for semantic layout. Reserve physical coordinates for pointer positions, canvas geometry, native window controls, and other genuinely physical placement.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Avoid */
|
||||||
|
padding-left: 12px;
|
||||||
|
right: 0;
|
||||||
|
border-right: 1px solid;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
/* Prefer */
|
||||||
|
padding-inline-start: 12px;
|
||||||
|
inset-inline-end: 0;
|
||||||
|
border-inline-end: 1px solid;
|
||||||
|
text-align: start;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Isolate mixed-direction text. Use `dir="auto"` or `<bdi>` for unknown text; keep code, URLs, IDs, and filesystem paths LTR without forcing the surrounding component LTR.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span class="file-row"><bdi dir="auto">README.md</bdi></span> <bdi dir="ltr"><code>C:\src\app.ts</code></bdi>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Mirror directional meaning, not every image. Back/forward, previous/next, disclosure, indentation, and directional progress may need mirroring. Do not mirror brands, clocks, media controls, charts, or text. Reverse physical gradients, `translateX`, SVG transforms, and animation deltas explicitly.
|
||||||
|
- Map interactions through direction. `clientX` remains physical; resizing a logical edge needs an RTL-aware delta. Logical previous/next keyboard controls may swap ArrowLeft/ArrowRight. Follow the relevant WAI-ARIA widget pattern.
|
||||||
|
- Do not assume LTR scrolling. RTL `scrollLeft` can start at `0` and become negative. Prefer `scrollIntoView({ inline: "nearest" })` or a tested direction-normalizing helper.
|
||||||
|
- For Electron title bars, prefer native caption controls and use `titleBarOverlay` plus `env(titlebar-area-*)` for the safe content rectangle. Keep Windows/macOS native-control avoidance and `trafficLightPosition` physical; keep app navigation inside that rectangle logical. Mark interactive titlebar children `app-region: no-drag`.
|
||||||
|
- Verify behavior, not screenshots alone. Check computed styles, pseudo-element geometry, hit zones, focus order, keyboard behavior, submenu direction, zoom/scaling, and both LTR and RTL scroll endpoints.
|
||||||
|
|
||||||
|
## Test Matrix
|
||||||
|
|
||||||
|
- English + LTR
|
||||||
|
- English + forced RTL
|
||||||
|
- A real RTL locale + RTL
|
||||||
|
- Mixed RTL/LTR content, long labels, numbers, code, and paths
|
||||||
|
- Keyboard, pointer resize, scrolling, menus/submenus, and Electron titlebar controls in both directions
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [RTL Styling 101, Ahmad Shadeed](https://rtlstyling.com/posts/rtl-styling/)
|
||||||
|
- [CSS-Tricks: RTL Styling 101](https://css-tricks.com/rtl-styling-101/)
|
||||||
|
- [CSS-Tricks: CSS Logical Properties and Values](https://css-tricks.com/css-logical-properties-and-values/)
|
||||||
|
- [W3C: Structural markup and right-to-left text](https://www.w3.org/International/questions/qa-html-dir)
|
||||||
|
- [W3C: Inline bidirectional markup](https://www.w3.org/International/articles/inline-bidi-markup/)
|
||||||
|
- [MDN: CSS logical properties and values](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Logical_properties_and_values)
|
||||||
|
- [MDN: `dir`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir)
|
||||||
|
- [MDN: `scrollLeft`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft)
|
||||||
|
- [web.dev: Logical properties](https://web.dev/learn/css/logical-properties/)
|
||||||
|
- [Electron: Custom title bar](https://www.electronjs.org/docs/latest/tutorial/custom-title-bar)
|
||||||
|
- [WAI-ARIA: Window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
|
||||||
|
- [Kobalte: I18n Provider](https://kobalte.dev/docs/core/components/i18n-provider/)
|
||||||
@@ -252,33 +252,6 @@ These are not strictly enforced, they are just general guidelines:
|
|||||||
|
|
||||||
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
|
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:
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
@@ -571,9 +571,23 @@
|
|||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/merman": {
|
||||||
|
"name": "@opencode-ai/merman",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
|
"@opentui/core": "catalog:",
|
||||||
|
"string-width": "catalog:",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/bun": "catalog:",
|
||||||
|
"@types/bun": "catalog:",
|
||||||
|
"@typescript/native-preview": "catalog:",
|
||||||
|
},
|
||||||
|
},
|
||||||
"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:*",
|
||||||
@@ -679,6 +693,7 @@
|
|||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
|
"modal": "0.9.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
@@ -688,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:*",
|
||||||
@@ -703,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:",
|
||||||
@@ -724,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:",
|
||||||
@@ -755,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",
|
||||||
@@ -768,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:",
|
||||||
@@ -802,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",
|
||||||
@@ -821,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:",
|
||||||
@@ -881,6 +891,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
|
"@opencode-ai/merman": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/simulation": "workspace:*",
|
"@opencode-ai/simulation": "workspace:*",
|
||||||
@@ -911,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:",
|
||||||
@@ -927,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",
|
||||||
@@ -937,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": {
|
||||||
@@ -1008,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",
|
||||||
@@ -1063,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:",
|
||||||
@@ -1130,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",
|
||||||
@@ -1140,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",
|
||||||
@@ -1512,6 +1526,18 @@
|
|||||||
|
|
||||||
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
|
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
|
||||||
|
|
||||||
|
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
|
||||||
|
|
||||||
|
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
|
||||||
|
|
||||||
|
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
|
||||||
|
|
||||||
|
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
|
||||||
|
|
||||||
|
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
|
||||||
|
|
||||||
|
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
|
||||||
|
|
||||||
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
|
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
|
||||||
|
|
||||||
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
|
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
|
||||||
@@ -1720,6 +1746,10 @@
|
|||||||
|
|
||||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||||
|
|
||||||
|
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
|
||||||
|
|
||||||
|
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
|
||||||
|
|
||||||
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
||||||
|
|
||||||
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
|
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
|
||||||
@@ -1814,6 +1844,8 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
|
||||||
|
|
||||||
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
|
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
|
||||||
|
|
||||||
"@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="],
|
"@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="],
|
||||||
@@ -2064,6 +2096,8 @@
|
|||||||
|
|
||||||
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
|
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
|
||||||
|
|
||||||
|
"@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"],
|
||||||
|
|
||||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
||||||
|
|
||||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||||
@@ -3298,6 +3332,8 @@
|
|||||||
|
|
||||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||||
|
|
||||||
|
"abort-controller-x": ["abort-controller-x@0.5.0", "", {}, "sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ=="],
|
||||||
|
|
||||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||||
|
|
||||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||||
@@ -3538,6 +3574,10 @@
|
|||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
|
||||||
|
|
||||||
|
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
|
||||||
|
|
||||||
|
"cbor-x": ["cbor-x@1.6.5", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig=="],
|
||||||
|
|
||||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||||
|
|
||||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||||
@@ -3612,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=="],
|
||||||
|
|
||||||
@@ -4150,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=="],
|
||||||
|
|
||||||
@@ -4570,6 +4610,8 @@
|
|||||||
|
|
||||||
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||||
|
|
||||||
|
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
|
||||||
|
|
||||||
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
|
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
|
||||||
|
|
||||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||||
@@ -4622,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=="],
|
||||||
|
|
||||||
@@ -4796,6 +4836,8 @@
|
|||||||
|
|
||||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||||
|
|
||||||
|
"modal": ["modal@0.9.0", "", { "dependencies": { "cbor-x": "^1.6.0", "long": "^5.3.1", "nice-grpc": "^2.1.12", "protobufjs": "^7.5.0", "smol-toml": "^1.3.3", "uuid": "^11.1.0" } }, "sha512-kCXcdJkhbJorf/q/6T9Wdlg6in9JmRnCNQnV6rVBMyeqNV/iXI6BYk4IzY4cvZ6dbauNeDMjk/Q08cbxvoIaXg=="],
|
||||||
|
|
||||||
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
|
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
|
||||||
|
|
||||||
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
|
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
|
||||||
@@ -4834,6 +4876,10 @@
|
|||||||
|
|
||||||
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
|
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
|
||||||
|
|
||||||
|
"nice-grpc": ["nice-grpc@2.1.16", "", { "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", "nice-grpc-common": "^2.0.3" } }, "sha512-Cl3Pn00212Hl8/U6bpgMxmhZj5lyv3nWoJov4cd3FjWarktrMHP4DNvSjCnDwkMWYx4W1tyscEia4JX6Y4GVCQ=="],
|
||||||
|
|
||||||
|
"nice-grpc-common": ["nice-grpc-common@2.0.3", "", { "dependencies": { "ts-error": "^1.0.6" } }, "sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ=="],
|
||||||
|
|
||||||
"nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="],
|
"nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="],
|
||||||
|
|
||||||
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
|
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
|
||||||
@@ -5418,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=="],
|
||||||
@@ -5632,6 +5680,8 @@
|
|||||||
|
|
||||||
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
|
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
|
||||||
|
|
||||||
|
"ts-error": ["ts-error@1.0.6", "", {}, "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA=="],
|
||||||
|
|
||||||
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||||
|
|
||||||
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
|
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
|
||||||
@@ -6330,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=="],
|
||||||
@@ -6572,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=="],
|
||||||
@@ -6590,6 +6636,8 @@
|
|||||||
|
|
||||||
"builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
|
"builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
|
||||||
|
|
||||||
|
"cbor-extract/node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
|
||||||
|
|
||||||
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
@@ -6706,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=="],
|
||||||
@@ -6734,6 +6784,8 @@
|
|||||||
|
|
||||||
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||||
|
|
||||||
|
"modal/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
|
||||||
|
|
||||||
"motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
|
"motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
|
||||||
|
|
||||||
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
|
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
|
||||||
@@ -6834,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=="],
|
||||||
@@ -7662,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=="],
|
||||||
@@ -7734,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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,8 @@ const countHints = (request: LLMRequest) =>
|
|||||||
|
|
||||||
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
||||||
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
|
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
|
||||||
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")) return request
|
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto"))
|
||||||
|
return request
|
||||||
const policy = resolve(request.cache)
|
const policy = resolve(request.cache)
|
||||||
if (!policy.tools && !policy.system && !policy.messages) return request
|
if (!policy.tools && !policy.system && !policy.messages) return request
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -371,12 +371,13 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
|||||||
return text
|
return text
|
||||||
})()
|
})()
|
||||||
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
|
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
|
||||||
|
const cacheControl = options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined)
|
||||||
const result = {
|
const result = {
|
||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
content: content.length > 0 ? content.map((part) => part.text).join("") : toolCalls.length > 0 ? null : "",
|
||||||
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
||||||
reasoning_details: details,
|
...(details !== undefined ? { reasoning_details: details } : {}),
|
||||||
cache_control: options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined),
|
...(cacheControl !== undefined ? { cache_control: cacheControl } : {}),
|
||||||
}
|
}
|
||||||
if (field === undefined || reasoningText === undefined) return result
|
if (field === undefined || reasoningText === undefined) return result
|
||||||
return { ...result, [field]: reasoningText }
|
return { ...result, [field]: reasoningText }
|
||||||
@@ -709,23 +710,20 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
Boolean(delta?.content) ||
|
Boolean(delta?.content) ||
|
||||||
reasoning !== undefined ||
|
reasoning !== undefined ||
|
||||||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
|
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
|
||||||
toolDeltas.some(
|
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
|
||||||
(tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments),
|
|
||||||
)
|
|
||||||
if (state.finishReason !== undefined) {
|
if (state.finishReason !== undefined) {
|
||||||
if (hasLateContent)
|
if (hasLateContent)
|
||||||
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
|
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
|
||||||
return [{ ...state, usage }, events] as const
|
return [{ ...state, usage }, events] as const
|
||||||
}
|
}
|
||||||
|
|
||||||
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
|
const reasoningField = state.reasoningField ?? reasoning?.field
|
||||||
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
||||||
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
||||||
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
|
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
|
||||||
const deltaMetadata = reasoningMetadata(reasoningField)
|
const deltaMetadata = reasoningMetadata(reasoningField)
|
||||||
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
|
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
|
||||||
if (!state.lifecycle.text.has("text-0") && text !== undefined)
|
if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
|
||||||
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
|
|
||||||
else if (
|
else if (
|
||||||
reasoningDetailsObserved &&
|
reasoningDetailsObserved &&
|
||||||
!lifecycle.reasoning.has("reasoning-0") &&
|
!lifecycle.reasoning.has("reasoning-0") &&
|
||||||
@@ -751,8 +749,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
const fallback = toolDeltas.length > 1 ? position : (latestToolIndex ?? position)
|
const fallback = toolDeltas.length > 1 ? position : (latestToolIndex ?? position)
|
||||||
const fallbackTool = tools[fallback] ?? pendingTools[fallback]
|
const fallbackTool = tools[fallback] ?? pendingTools[fallback]
|
||||||
const index =
|
const index =
|
||||||
tool.index ?? matched ??
|
tool.index ?? matched ?? (tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
|
||||||
(tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
|
|
||||||
const current = tools[index]
|
const current = tools[index]
|
||||||
const pending = pendingTools[index]
|
const pending = pendingTools[index]
|
||||||
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
|
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
|
||||||
@@ -815,14 +812,18 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
|
|
||||||
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const hasToolCalls = state.toolCallEvents.length > 0
|
const toolCallEvents =
|
||||||
|
state.finishReason === undefined && Object.keys(state.tools).length > 0
|
||||||
|
? Effect.runSync(ToolStream.finishAll(ADAPTER, state.tools)).events
|
||||||
|
: state.toolCallEvents
|
||||||
|
const hasToolCalls = toolCallEvents.length > 0
|
||||||
const reason = state.finishReason
|
const reason = state.finishReason
|
||||||
? {
|
? {
|
||||||
...state.finishReason,
|
...state.finishReason,
|
||||||
normalized:
|
normalized:
|
||||||
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
|
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
|
||||||
}
|
}
|
||||||
: undefined
|
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("unknown" as const) }
|
||||||
const metadata = reasoningMetadata(
|
const metadata = reasoningMetadata(
|
||||||
state.reasoningField,
|
state.reasoningField,
|
||||||
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||||
@@ -832,9 +833,9 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
|||||||
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
|
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
|
||||||
: state.lifecycle
|
: state.lifecycle
|
||||||
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
|
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
|
||||||
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
|
const lifecycle = toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
|
||||||
events.push(...state.toolCallEvents)
|
events.push(...toolCallEvents)
|
||||||
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
|
Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
|
||||||
return events
|
return events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -255,13 +255,7 @@ const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent,
|
|||||||
if (LLMEvent.is.finish(event) || LLMEvent.is.providerError(event)) terminal = true
|
if (LLMEvent.is.finish(event) || LLMEvent.is.providerError(event)) terminal = true
|
||||||
return Effect.succeed(event)
|
return Effect.succeed(event)
|
||||||
}),
|
}),
|
||||||
Stream.onEnd(
|
Stream.onEnd(Effect.suspend(() => (terminal ? Effect.void : Effect.fail(incompleteStreamError(route))))),
|
||||||
Effect.suspend(() =>
|
|
||||||
terminal
|
|
||||||
? Effect.void
|
|
||||||
: Effect.fail(incompleteStreamError(route)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Cause, Context, Effect, Layer } from "effect"
|
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
|
||||||
import {
|
import {
|
||||||
FetchHttpClient,
|
FetchHttpClient,
|
||||||
Headers,
|
Headers,
|
||||||
@@ -198,8 +198,20 @@ const responseBody = (body: string | void, request: HttpClientRequest.HttpClient
|
|||||||
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
|
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const decodeProviderBody = Schema.decodeUnknownOption(
|
||||||
|
Schema.fromJsonString(
|
||||||
|
Schema.Struct({
|
||||||
|
message: Schema.optionalKey(Schema.String),
|
||||||
|
error: Schema.optionalKey(Schema.Struct({ message: Schema.optionalKey(Schema.String) })),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const providerMessage = (status: number, body: { readonly body?: string }) => {
|
const providerMessage = (status: number, body: { readonly body?: string }) => {
|
||||||
if (body.body && body.body.length <= 500) return `Provider request failed with HTTP ${status}: ${body.body}`
|
if (body.body && body.body.length <= 500) {
|
||||||
|
const decoded = Option.getOrUndefined(decodeProviderBody(body.body))
|
||||||
|
return `Provider request failed with HTTP ${status}: ${decoded?.error?.message ?? decoded?.message ?? body.body}`
|
||||||
|
}
|
||||||
return `Provider request failed with HTTP ${status}`
|
return `Provider request failed with HTTP ${status}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,35 +45,34 @@ const isToolResultValue = (value: unknown): value is ToolResultValue =>
|
|||||||
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
|
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
|
||||||
"value" in value
|
"value" in value
|
||||||
|
|
||||||
export const ToolResultValue = Object.assign(
|
const toolResultValueSchema = Schema.Union([
|
||||||
Schema.Union([
|
Schema.Struct({
|
||||||
Schema.Struct({
|
type: Schema.Literal("json"),
|
||||||
type: Schema.Literal("json"),
|
value: Schema.Unknown,
|
||||||
value: Schema.Unknown,
|
}),
|
||||||
}),
|
Schema.Struct({
|
||||||
Schema.Struct({
|
type: Schema.Literal("text"),
|
||||||
type: Schema.Literal("text"),
|
value: Schema.Unknown,
|
||||||
value: Schema.Unknown,
|
}),
|
||||||
}),
|
Schema.Struct({
|
||||||
Schema.Struct({
|
type: Schema.Literal("error"),
|
||||||
type: Schema.Literal("error"),
|
value: Schema.Unknown,
|
||||||
value: Schema.Unknown,
|
}),
|
||||||
}),
|
Schema.Struct({
|
||||||
Schema.Struct({
|
type: Schema.Literal("content"),
|
||||||
type: Schema.Literal("content"),
|
value: Schema.Array(Tool.Content),
|
||||||
value: Schema.Array(Tool.Content),
|
}),
|
||||||
}),
|
]).annotate({ identifier: "LLM.ToolResult" })
|
||||||
]).annotate({ identifier: "LLM.ToolResult" }),
|
export type ToolResultValue = Schema.Schema.Type<typeof toolResultValueSchema>
|
||||||
{
|
|
||||||
is: isToolResultValue,
|
export const ToolResultValue = Object.assign(toolResultValueSchema, {
|
||||||
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
|
is: isToolResultValue,
|
||||||
if (isToolResultValue(value)) return value
|
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
|
||||||
if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
|
if (isToolResultValue(value)) return value
|
||||||
return { type, value }
|
if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
|
||||||
},
|
return { type, value }
|
||||||
},
|
},
|
||||||
)
|
})
|
||||||
export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
|
|
||||||
|
|
||||||
export interface ToolOutput {
|
export interface ToolOutput {
|
||||||
readonly structured: unknown
|
readonly structured: unknown
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { Effect, JsonSchema, Schema } from "effect"
|
import { Effect, JsonSchema, Schema } from "effect"
|
||||||
import { Tool } from "@opencode-ai/schema/tool"
|
import { Tool } from "@opencode-ai/schema/tool"
|
||||||
import type {
|
import type { ToolCallPart, ToolDefinition as ToolDefinitionClass, ToolOutput as ToolOutputType } from "./schema"
|
||||||
ToolCallPart,
|
|
||||||
ToolDefinition as ToolDefinitionClass,
|
|
||||||
ToolOutput as ToolOutputType,
|
|
||||||
} from "./schema"
|
|
||||||
import { ToolDefinition, ToolFailure, ToolOutput } from "./schema"
|
import { ToolDefinition, ToolFailure, ToolOutput } from "./schema"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -244,8 +240,7 @@ const project = (
|
|||||||
): ToolOutputType =>
|
): ToolOutputType =>
|
||||||
ToolOutput.make(
|
ToolOutput.make(
|
||||||
toStructuredOutput?.(output) ?? output,
|
toStructuredOutput?.(output) ?? output,
|
||||||
toModelOutput?.({ id, parameters, output }) ??
|
toModelOutput?.({ id, parameters, output }) ?? (typeof output === "string" ? [{ type: "text", text: output }] : []),
|
||||||
(typeof output === "string" ? [{ type: "text", text: output }] : []),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export { ToolFailure }
|
export { ToolFailure }
|
||||||
|
|||||||
@@ -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", {})
|
|
||||||
|
|||||||
@@ -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
-7
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:anthropic-messages-cache", "provider:anthropic", "protocol:anthropic-messages", "cache", "tool"],
|
||||||
"prefix:anthropic-messages-cache",
|
|
||||||
"provider:anthropic",
|
|
||||||
"protocol:anthropic-messages",
|
|
||||||
"cache",
|
|
||||||
"tool"
|
|
||||||
],
|
|
||||||
"name": "anthropic-messages-cache/keeps-a-long-tool-turn-inside-the-cache-lookback",
|
"name": "anthropic-messages-cache/keeps-a-long-tool-turn-inside-the-cache-lookback",
|
||||||
"recordedAt": "2026-07-24T16:22:29.494Z"
|
"recordedAt": "2026-07-24T16:22:29.494Z"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-6
@@ -1,12 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:bedrock-converse-cache", "provider:amazon-bedrock", "protocol:bedrock-converse", "cache"],
|
||||||
"prefix:bedrock-converse-cache",
|
|
||||||
"provider:amazon-bedrock",
|
|
||||||
"protocol:bedrock-converse",
|
|
||||||
"cache"
|
|
||||||
],
|
|
||||||
"name": "bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call",
|
"name": "bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call",
|
||||||
"recordedAt": "2026-07-23T02:29:10.955Z"
|
"recordedAt": "2026-07-23T02:29:10.955Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:google-images", "provider:google", "protocol:google-images"],
|
||||||
"prefix:google-images",
|
|
||||||
"provider:google",
|
|
||||||
"protocol:google-images"
|
|
||||||
],
|
|
||||||
"name": "google-images/generates-an-image",
|
"name": "google-images/generates-an-image",
|
||||||
"recordedAt": "2026-07-19T16:05:51.868Z"
|
"recordedAt": "2026-07-19T16:05:51.868Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:openai-images", "provider:openai", "protocol:openai-images"],
|
||||||
"prefix:openai-images",
|
|
||||||
"provider:openai",
|
|
||||||
"protocol:openai-images"
|
|
||||||
],
|
|
||||||
"name": "openai-images/generates-an-image",
|
"name": "openai-images/generates-an-image",
|
||||||
"recordedAt": "2026-07-19T14:41:43.188Z"
|
"recordedAt": "2026-07-19T14:41:43.188Z"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-5
@@ -1,11 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:openai-responses-images", "provider:openai", "protocol:openai-responses"],
|
||||||
"prefix:openai-responses-images",
|
|
||||||
"provider:openai",
|
|
||||||
"protocol:openai-responses"
|
|
||||||
],
|
|
||||||
"name": "openai-responses-images/generates-and-edits-an-image-with-the-hosted-tool",
|
"name": "openai-responses-images/generates-and-edits-an-image-with-the-hosted-tool",
|
||||||
"recordedAt": "2026-07-19T14:57:16.284Z"
|
"recordedAt": "2026-07-19T14:57:16.284Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,12 +2,7 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"tags": [
|
"tags": ["prefix:openai-compatible-chat", "provider:openrouter", "protocol:openai-chat", "reasoning"],
|
||||||
"prefix:openai-compatible-chat",
|
|
||||||
"provider:openrouter",
|
|
||||||
"protocol:openai-chat",
|
|
||||||
"reasoning"
|
|
||||||
],
|
|
||||||
"name": "openrouter-reasoning",
|
"name": "openrouter-reasoning",
|
||||||
"recordedAt": "2026-07-18T11:28:39.267Z"
|
"recordedAt": "2026-07-18T11:28:39.267Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:anthropic", "protocol:anthropic-messages", "tool", "tool-result"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:anthropic",
|
|
||||||
"protocol:anthropic-messages",
|
|
||||||
"tool",
|
|
||||||
"tool-result"
|
|
||||||
],
|
|
||||||
"name": "pdf/anthropic-tool-result",
|
"name": "pdf/anthropic-tool-result",
|
||||||
"recordedAt": "2026-07-22T18:15:39.002Z"
|
"recordedAt": "2026-07-22T18:15:39.002Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:anthropic", "protocol:anthropic-messages", "user-input"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:anthropic",
|
|
||||||
"protocol:anthropic-messages",
|
|
||||||
"user-input"
|
|
||||||
],
|
|
||||||
"name": "pdf/anthropic-user-input",
|
"name": "pdf/anthropic-user-input",
|
||||||
"recordedAt": "2026-07-22T18:15:37.979Z"
|
"recordedAt": "2026-07-22T18:15:37.979Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:amazon-bedrock", "protocol:bedrock-converse", "tool", "tool-result"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:amazon-bedrock",
|
|
||||||
"protocol:bedrock-converse",
|
|
||||||
"tool",
|
|
||||||
"tool-result"
|
|
||||||
],
|
|
||||||
"name": "pdf/bedrock-tool-result",
|
"name": "pdf/bedrock-tool-result",
|
||||||
"recordedAt": "2026-07-22T18:15:52.400Z"
|
"recordedAt": "2026-07-22T18:15:52.400Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:amazon-bedrock", "protocol:bedrock-converse", "user-input"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:amazon-bedrock",
|
|
||||||
"protocol:bedrock-converse",
|
|
||||||
"user-input"
|
|
||||||
],
|
|
||||||
"name": "pdf/bedrock-user-input",
|
"name": "pdf/bedrock-user-input",
|
||||||
"recordedAt": "2026-07-22T18:15:48.408Z"
|
"recordedAt": "2026-07-22T18:15:48.408Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:google", "protocol:gemini", "tool", "tool-result"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:google",
|
|
||||||
"protocol:gemini",
|
|
||||||
"tool",
|
|
||||||
"tool-result"
|
|
||||||
],
|
|
||||||
"name": "pdf/gemini-tool-result",
|
"name": "pdf/gemini-tool-result",
|
||||||
"recordedAt": "2026-07-22T18:21:59.606Z"
|
"recordedAt": "2026-07-22T18:21:59.606Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:google", "protocol:gemini", "user-input"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:google",
|
|
||||||
"protocol:gemini",
|
|
||||||
"user-input"
|
|
||||||
],
|
|
||||||
"name": "pdf/gemini-user-input",
|
"name": "pdf/gemini-user-input",
|
||||||
"recordedAt": "2026-07-22T18:20:55.140Z"
|
"recordedAt": "2026-07-22T18:20:55.140Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "tool", "tool-result"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:openai",
|
|
||||||
"protocol:openai-responses",
|
|
||||||
"tool",
|
|
||||||
"tool-result"
|
|
||||||
],
|
|
||||||
"name": "pdf/openai-tool-result",
|
"name": "pdf/openai-tool-result",
|
||||||
"recordedAt": "2026-07-22T18:15:36.438Z"
|
"recordedAt": "2026-07-22T18:15:36.438Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "user-input"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:openai",
|
|
||||||
"protocol:openai-responses",
|
|
||||||
"user-input"
|
|
||||||
],
|
|
||||||
"name": "pdf/openai-user-input",
|
"name": "pdf/openai-user-input",
|
||||||
"recordedAt": "2026-07-22T18:15:34.867Z"
|
"recordedAt": "2026-07-22T18:15:34.867Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:openai-responses", "tool", "tool-result"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:xai",
|
|
||||||
"protocol:openai-responses",
|
|
||||||
"tool",
|
|
||||||
"tool-result"
|
|
||||||
],
|
|
||||||
"name": "pdf/xai-tool-result",
|
"name": "pdf/xai-tool-result",
|
||||||
"recordedAt": "2026-07-22T18:15:43.608Z"
|
"recordedAt": "2026-07-22T18:15:43.608Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:openai-responses", "user-input"],
|
||||||
"prefix:pdf",
|
|
||||||
"pdf",
|
|
||||||
"provider:xai",
|
|
||||||
"protocol:openai-responses",
|
|
||||||
"user-input"
|
|
||||||
],
|
|
||||||
"name": "pdf/xai-user-input",
|
"name": "pdf/xai-user-input",
|
||||||
"recordedAt": "2026-07-22T18:15:42.429Z"
|
"recordedAt": "2026-07-22T18:15:42.429Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,12 +2,7 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"tags": [
|
"tags": ["prefix:openai-compatible-chat", "provider:vercel-ai-gateway", "protocol:openai-chat", "reasoning"],
|
||||||
"prefix:openai-compatible-chat",
|
|
||||||
"provider:vercel-ai-gateway",
|
|
||||||
"protocol:openai-chat",
|
|
||||||
"reasoning"
|
|
||||||
],
|
|
||||||
"name": "vercel-ai-gateway-reasoning",
|
"name": "vercel-ai-gateway-reasoning",
|
||||||
"recordedAt": "2026-07-18T11:28:42.077Z"
|
"recordedAt": "2026-07-18T11:28:42.077Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"tags": [
|
"tags": ["prefix:xai-images", "provider:xai", "protocol:xai-images"],
|
||||||
"prefix:xai-images",
|
|
||||||
"provider:xai",
|
|
||||||
"protocol:xai-images"
|
|
||||||
],
|
|
||||||
"name": "xai-images/generates-an-image",
|
"name": "xai-images/generates-an-image",
|
||||||
"recordedAt": "2026-07-19T15:56:20.098Z"
|
"recordedAt": "2026-07-19T15:56:20.098Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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" } },
|
|
||||||
})
|
|
||||||
@@ -519,14 +519,8 @@ describe("Bedrock Converse route", () => {
|
|||||||
fixedBytes(
|
fixedBytes(
|
||||||
eventStreamBody(
|
eventStreamBody(
|
||||||
["messageStart", { role: "assistant" }],
|
["messageStart", { role: "assistant" }],
|
||||||
[
|
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
|
||||||
"contentBlockDelta",
|
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
|
||||||
{ contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"contentBlockDelta",
|
|
||||||
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
|
|
||||||
],
|
|
||||||
["messageStop", { stopReason: "end_turn" }],
|
["messageStop", { stopReason: "end_turn" }],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -561,10 +555,7 @@ describe("Bedrock Converse route", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = eventStreamBody(
|
const body = eventStreamBody(
|
||||||
["messageStart", { role: "assistant" }],
|
["messageStart", { role: "assistant" }],
|
||||||
[
|
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
|
||||||
"contentBlockDelta",
|
|
||||||
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
|
|
||||||
],
|
|
||||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||||
["messageStop", { stopReason: "end_turn" }],
|
["messageStop", { stopReason: "end_turn" }],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -102,6 +102,24 @@ describe("OpenAI Chat route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("concatenates assistant text parts without adding separators", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([
|
||||||
|
{ type: "text", text: "Hello" },
|
||||||
|
{ type: "text", text: " world" },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.messages).toEqual([{ role: "assistant", content: "Hello world" }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("writes reasoning to a configured custom field on every assistant message", () =>
|
it.effect("writes reasoning to a configured custom field on every assistant message", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
@@ -578,7 +596,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(prepared.body.messages).toEqual([{ role: "assistant", content: null, reasoning_content: "hidden" }])
|
expect(prepared.body.messages).toEqual([{ role: "assistant", content: "", reasoning_content: "hidden" }])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -827,7 +845,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("ignores scalar reasoning after content starts", () =>
|
it.effect("preserves scalar reasoning after content starts", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
|
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
@@ -843,11 +861,11 @@ describe("OpenAI Chat route", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.reasoning).toBe("detail")
|
expect(response.reasoning).toBe("detailscalar")
|
||||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(2)
|
||||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(2)
|
||||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||||
openai: { reasoningDetails: details },
|
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -947,7 +965,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||||
|
|
||||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
|
expect(replay.body.messages).toEqual([{ role: "assistant", content: "", reasoning_details: details }])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -997,7 +1015,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(replay.body.messages).toEqual([
|
expect(replay.body.messages).toEqual([
|
||||||
{ role: "assistant", content: null, reasoning: "firstsecond", reasoning_details: [first, second] },
|
{ role: "assistant", content: "", reasoning: "firstsecond", reasoning_details: [first, second] },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1022,7 +1040,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(replay.body.messages).toEqual([
|
expect(replay.body.messages).toEqual([
|
||||||
{ role: "assistant", content: null, reasoning_content: "AB", reasoning_details: [detail] },
|
{ role: "assistant", content: "", reasoning_content: "AB", reasoning_details: [detail] },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1044,7 +1062,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(replay.body.messages).toEqual([
|
expect(replay.body.messages).toEqual([
|
||||||
{ role: "assistant", content: null, reasoning_content: "thinking", reasoning_details: details },
|
{ role: "assistant", content: "", reasoning_content: "thinking", reasoning_details: details },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1152,7 +1170,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
|
it.effect("finalizes a streamed tool call when the provider ends without a finish reason", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
const body = sseEvents(
|
||||||
deltaChunk({
|
deltaChunk({
|
||||||
@@ -1164,27 +1182,31 @@ describe("OpenAI Chat route", () => {
|
|||||||
const input = LLMRequest.update(request, {
|
const input = LLMRequest.update(request, {
|
||||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||||
})
|
})
|
||||||
const events: LLMEvent[] = []
|
const response = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)))
|
||||||
const streamError = yield* LLMClient.stream(input).pipe(
|
|
||||||
Stream.runForEach((event) => Effect.sync(() => events.push(event))),
|
|
||||||
Effect.flip,
|
|
||||||
Effect.provide(fixedResponse(body)),
|
|
||||||
)
|
|
||||||
const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
|
|
||||||
|
|
||||||
expect(events).toEqual([
|
expect(response.events).toEqual([
|
||||||
{ type: "step-start", index: 0 },
|
{ type: "step-start", index: 0 },
|
||||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
|
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
||||||
|
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
input: { query: "weather" },
|
||||||
|
providerExecuted: undefined,
|
||||||
|
providerMetadata: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "step-finish",
|
||||||
|
index: 0,
|
||||||
|
reason: { normalized: "tool-calls" },
|
||||||
|
usage: undefined,
|
||||||
|
providerMetadata: undefined,
|
||||||
|
},
|
||||||
|
{ type: "finish", reason: { normalized: "tool-calls" }, usage: undefined },
|
||||||
])
|
])
|
||||||
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
|
|
||||||
expect(streamError.reason).toMatchObject({
|
|
||||||
_tag: "InvalidProviderOutput",
|
|
||||||
classification: "incomplete-stream",
|
|
||||||
})
|
|
||||||
expect(streamError.message).toContain("The provider response ended unexpectedly.")
|
|
||||||
expect(error.message).toContain("The provider response ended unexpectedly.")
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -300,7 +300,9 @@ describe("OpenAI-compatible Chat route", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(
|
const response = yield* LLMClient.generate(
|
||||||
LLMRequest.update(request, {
|
LLMRequest.update(request, {
|
||||||
tools: [ToolDefinition.make({ name: "weather", description: "Get weather", inputSchema: { type: "object" } })],
|
tools: [
|
||||||
|
ToolDefinition.make({ name: "weather", description: "Get weather", inputSchema: { type: "object" } }),
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,8 +177,8 @@ test("shows all and expands historical diff summary without overlap", async ({ p
|
|||||||
const firstUser = userMessage(undefined, {
|
const firstUser = userMessage(undefined, {
|
||||||
summary: {
|
summary: {
|
||||||
diffs: Array.from({ length: 12 }, (_, index) => ({
|
diffs: Array.from({ length: 12 }, (_, index) => ({
|
||||||
file: `src/diff-${index}.ts`,
|
file: `src/diff-${index}.ts`,
|
||||||
status: "modified",
|
status: "modified",
|
||||||
additions: 1,
|
additions: 1,
|
||||||
deletions: 1,
|
deletions: 1,
|
||||||
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
||||||
|
|||||||
@@ -85,8 +85,7 @@ async function mockServers(page: Page, requests: string[]) {
|
|||||||
const current = url.origin === serverA ? sessionA : sessionB
|
const current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/api/event")
|
if (url.pathname === "/api/event") return sse(route)
|
||||||
return sse(route)
|
|
||||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -181,12 +181,17 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
|||||||
return json(route, true)
|
return json(route, true)
|
||||||
}
|
}
|
||||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/api/event")
|
if (url.pathname === "/api/event") return sse(route)
|
||||||
return sse(route)
|
|
||||||
if (url.pathname === "/api/provider")
|
if (url.pathname === "/api/provider")
|
||||||
return json(route, {
|
return json(route, {
|
||||||
location: { directory },
|
location: { directory },
|
||||||
data: [{ id: remote ? "server-b" : "server-a", name: remote ? "Server B Provider" : "Server A Provider", package: "test" }],
|
data: [
|
||||||
|
{
|
||||||
|
id: remote ? "server-b" : "server-a",
|
||||||
|
name: remote ? "Server B Provider" : "Server A Provider",
|
||||||
|
package: "test",
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
if (url.pathname === "/api/model") return json(route, { location: { directory }, data: [model(remote)] })
|
if (url.pathname === "/api/model") return json(route, { location: { directory }, data: [model(remote)] })
|
||||||
if (url.pathname === "/api/model/default") return json(route, { location: { directory }, data: model(remote) })
|
if (url.pathname === "/api/model/default") return json(route, { location: { directory }, data: model(remote) })
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ async function mockServers(page: Page) {
|
|||||||
const current = url.origin === serverA ? sessionA : sessionB
|
const current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/api/event")
|
if (url.pathname === "/api/event") return sse(route, url.pathname === "/api/event")
|
||||||
return sse(route, url.pathname === "/api/event")
|
|
||||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||||
if (url.pathname === "/api/session/active")
|
if (url.pathname === "/api/session/active")
|
||||||
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -86,15 +86,10 @@ test.describe("session timeline projection", () => {
|
|||||||
],
|
],
|
||||||
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
||||||
)
|
)
|
||||||
const aborted = assistantMessage(
|
const aborted = assistantMessage([{ id: "prt_before_abort", type: "text", text: "Before interruption" }], {
|
||||||
[
|
id: "msg_1001_assistant_aborted",
|
||||||
{ id: "prt_before_abort", type: "text", text: "Before interruption" },
|
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
||||||
],
|
})
|
||||||
{
|
|
||||||
id: "msg_1001_assistant_aborted",
|
|
||||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
|
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
|
||||||
id: "msg_1002_assistant_failed",
|
id: "msg_1002_assistant_failed",
|
||||||
error: {
|
error: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,8 +89,7 @@ async function mockServer(page: Page) {
|
|||||||
if (url.origin !== server) return route.fallback()
|
if (url.origin !== server) return route.fallback()
|
||||||
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
||||||
return new Promise(() => {})
|
return new Promise(() => {})
|
||||||
if (url.pathname === "/api/event")
|
if (url.pathname === "/api/event") return sse(route)
|
||||||
return sse(route)
|
|
||||||
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -300,9 +300,8 @@ export const fixture = {
|
|||||||
.filter((message) => message.info.role === "user")
|
.filter((message) => message.info.role === "user")
|
||||||
.map((message) => message.info.id),
|
.map((message) => message.info.id),
|
||||||
targetPartIDs: targetMessages.flatMap(currentPartIDs),
|
targetPartIDs: targetMessages.flatMap(currentPartIDs),
|
||||||
expandedShellPartID: targetMessages
|
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!
|
||||||
.flatMap((message) => message.parts)
|
.callID,
|
||||||
.find((part) => part.tool === "bash")!.callID,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,7 +144,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
location: location(config),
|
location: location(config),
|
||||||
data: currentProviders(providerConfig(config)),
|
data: currentProviders(providerConfig(config)),
|
||||||
})
|
})
|
||||||
if (path === "/api/model") return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
|
if (path === "/api/model")
|
||||||
|
return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
|
||||||
if (path === "/api/model/default")
|
if (path === "/api/model/default")
|
||||||
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
|
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
|
||||||
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
|
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
|
||||||
@@ -206,7 +207,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
|
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
|
||||||
if (fileRead && config.fileContent) {
|
if (fileRead && config.fileContent) {
|
||||||
const value = await config.fileContent(decodeURIComponent(fileRead))
|
const value = await config.fileContent(decodeURIComponent(fileRead))
|
||||||
const content = value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
|
const content =
|
||||||
|
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
|
||||||
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
|
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
|
||||||
}
|
}
|
||||||
if (path === "/api/fs/find" && config.findFiles) {
|
if (path === "/api/fs/find" && config.findFiles) {
|
||||||
@@ -281,7 +283,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
}
|
}
|
||||||
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") return json(route, true)
|
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST")
|
||||||
|
return json(route, true)
|
||||||
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST")
|
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST")
|
||||||
return json(route, true)
|
return json(route, true)
|
||||||
if (
|
if (
|
||||||
@@ -386,11 +389,13 @@ function providerConfig(config: MockServerConfig) {
|
|||||||
|
|
||||||
function currentProviders(value: unknown) {
|
function currentProviders(value: unknown) {
|
||||||
if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : []
|
if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : []
|
||||||
return value.all.filter(record).flatMap((provider) =>
|
return value.all
|
||||||
typeof provider.id === "string" && typeof provider.name === "string"
|
.filter(record)
|
||||||
? [{ id: provider.id, name: provider.name, package: provider.id }]
|
.flatMap((provider) =>
|
||||||
: [],
|
typeof provider.id === "string" && typeof provider.name === "string"
|
||||||
)
|
? [{ id: provider.id, name: provider.name, package: provider.id }]
|
||||||
|
: [],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentModels(value: unknown) {
|
function currentModels(value: unknown) {
|
||||||
@@ -440,9 +445,7 @@ function currentDefaultModel(value: unknown) {
|
|||||||
if (!record(value) || !record(value.default)) return null
|
if (!record(value) || !record(value.default)) return null
|
||||||
const selected = value.default
|
const selected = value.default
|
||||||
const models = currentModels(value)
|
const models = currentModels(value)
|
||||||
return models.find(
|
return models.find((model) => model.providerID === selected.providerID && model.id === selected.modelID) ?? null
|
||||||
(model) => model.providerID === selected.providerID && model.id === selected.modelID,
|
|
||||||
) ?? null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentPermission(value: unknown) {
|
function currentPermission(value: unknown) {
|
||||||
@@ -562,22 +565,16 @@ function legacyAgent(part: Record<string, unknown>): PromptAgentAttachment[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mentionFrom(value: Record<string, unknown> | undefined) {
|
function mentionFrom(value: Record<string, unknown> | undefined) {
|
||||||
if (
|
if (!value || typeof value.value !== "string" || typeof value.start !== "number" || typeof value.end !== "number")
|
||||||
!value ||
|
|
||||||
typeof value.value !== "string" ||
|
|
||||||
typeof value.start !== "number" ||
|
|
||||||
typeof value.end !== "number"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
return { text: value.value, start: value.start, end: value.end }
|
return { text: value.value, start: value.start, end: value.end }
|
||||||
}
|
}
|
||||||
|
|
||||||
function legacyAssistantContent(
|
function legacyAssistantContent(part: Record<string, unknown>, created: number): SessionMessageAssistant["content"] {
|
||||||
part: Record<string, unknown>,
|
|
||||||
created: number,
|
|
||||||
): SessionMessageAssistant["content"] {
|
|
||||||
if (part.type === "text" && typeof part.text === "string")
|
if (part.type === "text" && typeof part.text === "string")
|
||||||
return [{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) }]
|
return [
|
||||||
|
{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) },
|
||||||
|
]
|
||||||
if (part.type === "reasoning" && typeof part.text === "string") {
|
if (part.type === "reasoning" && typeof part.text === "string") {
|
||||||
const time = record(part.time) ? part.time : undefined
|
const time = record(part.time) ? part.time : undefined
|
||||||
return [
|
return [
|
||||||
@@ -618,7 +615,12 @@ function legacyAssistantContent(
|
|||||||
...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
|
...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
|
||||||
}
|
}
|
||||||
if (state.status === "pending")
|
if (state.status === "pending")
|
||||||
return [{ ...base, state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) } }]
|
return [
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) },
|
||||||
|
},
|
||||||
|
]
|
||||||
if (state.status === "completed")
|
if (state.status === "completed")
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -174,8 +174,7 @@ export async function installSseTransport<T>(
|
|||||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const request = new Request(input, init)
|
const request = new Request(input, init)
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
if (url.origin !== server || url.pathname !== "/api/event")
|
if (url.origin !== server || url.pathname !== "/api/event") return originalFetch(request)
|
||||||
return originalFetch(request)
|
|
||||||
|
|
||||||
const id = ++nextConnectionID
|
const id = ++nextConnectionID
|
||||||
const record = {
|
const record = {
|
||||||
@@ -235,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 })
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,10 @@
|
|||||||
<meta property="twitter:image" content="/social-share.png" />
|
<meta property="twitter:image" content="/social-share.png" />
|
||||||
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body data-new-layout class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep">
|
<body
|
||||||
|
data-new-layout
|
||||||
|
class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep"
|
||||||
|
>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
|
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
|
||||||
<script src="/src/entry.tsx" type="module"></script>
|
<script src="/src/entry.tsx" type="module"></script>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+17
-16
@@ -3,20 +3,12 @@ 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"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import {
|
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
||||||
type BaseRouterProps,
|
|
||||||
Navigate,
|
|
||||||
Route,
|
|
||||||
Router,
|
|
||||||
useParams,
|
|
||||||
useSearchParams,
|
|
||||||
} from "@solidjs/router"
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import {
|
import {
|
||||||
@@ -162,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 {
|
||||||
@@ -225,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?.()
|
||||||
@@ -274,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 />
|
||||||
@@ -283,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) => {
|
||||||
@@ -294,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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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={
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -123,9 +123,7 @@ export const SettingsProvidersV2: Component<{
|
|||||||
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
||||||
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
credentials.map((credential) =>
|
credentials.map((credential) => serverSdk().api.credential.remove({ credentialID: credential.id, location })),
|
||||||
serverSdk().api.credential.remove({ credentialID: credential.id, location }),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
|
|||||||
@@ -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%);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,16 @@ 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 { useNavigate } from "@solidjs/router"
|
||||||
import { type Accessor, createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
|
import {
|
||||||
|
type Accessor,
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createResource,
|
||||||
|
For,
|
||||||
|
type JSXElement,
|
||||||
|
onCleanup,
|
||||||
|
Show,
|
||||||
|
} from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -291,7 +300,10 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
const lspCount = createMemo(() => lspItems().length)
|
const lspCount = createMemo(() => lspItems().length)
|
||||||
const [pluginList] = createResource(
|
const [pluginList] = createResource(
|
||||||
() => (props.shown() ? sdk().directory : undefined),
|
() => (props.shown() ? sdk().directory : undefined),
|
||||||
(directory) => sdk().api.plugin.list({ location: { directory } }).then((result) => result.data),
|
(directory) =>
|
||||||
|
sdk()
|
||||||
|
.api.plugin.list({ location: { directory } })
|
||||||
|
.then((result) => result.data),
|
||||||
)
|
)
|
||||||
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
|
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
|
||||||
const pluginCount = createMemo(() => plugins().length)
|
const pluginCount = createMemo(() => plugins().length)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user