mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 13:49:25 -04:00
Compare commits
296 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fdb104bb5 | |||
| b02419621b | |||
| 95f098a721 | |||
| 8c39e13b77 | |||
| ed11c895c6 | |||
| 8bc87d3c6a | |||
| 1ec78c705a | |||
| 2cba7e227d | |||
| ede6c4f1a6 | |||
| c34171845e | |||
| 4a7a497597 | |||
| b7f72b1bd4 | |||
| 4d68d30b48 | |||
| cff8ba313a | |||
| 5a0e07efcf | |||
| cba6b5f2f7 | |||
| 1c965451b5 | |||
| a0f8dccbfe | |||
| fb8344f3c2 | |||
| 3fd77ae980 | |||
| 976c185172 | |||
| 4643e65ad6 | |||
| e23586af26 | |||
| 92d29ba4a3 | |||
| 886fd98f52 | |||
| 722e717e99 | |||
| 6d635007ab | |||
| 0e3474509a | |||
| d8bf79225f | |||
| 8a55ba75b5 | |||
| 2449581543 | |||
| 3e25e80f7a | |||
| f06e9491e1 | |||
| d0c2b41adf | |||
| c7af47f9ed | |||
| 6c035e1fd7 | |||
| ab7cbc808f | |||
| 62387f39d4 | |||
| 864889ab9f | |||
| cc4b456129 | |||
| 14b37df391 | |||
| 91df883231 | |||
| 6fea419feb | |||
| beeabe2e4b | |||
| 502310f4df | |||
| 37fe5c83dc | |||
| dab2637217 | |||
| 39fb919a05 | |||
| 999be62662 | |||
| 521906f5fa | |||
| 069165eb5b | |||
| df09c3ec61 | |||
| 6d3ae4d63d | |||
| 284187ac55 | |||
| 7e0353cca9 | |||
| 959c8bd498 | |||
| ca3df21b7f | |||
| 8571a922db | |||
| d92d1e654b | |||
| 46a14e685a | |||
| 1f94d8a3c8 | |||
| c78986831c | |||
| d470434746 | |||
| 561afb401a | |||
| 2b8a5969e9 | |||
| 36b205370d | |||
| 6afef2f881 | |||
| 0d927ba03f | |||
| 9fdd4824d3 | |||
| d041eee55c | |||
| d62e50075a | |||
| 5d953482ab | |||
| 3a90639cb5 | |||
| b9f3b382fc | |||
| 550d1ffd24 | |||
| 941e71dbbb | |||
| d90532a595 | |||
| 0bff28de09 | |||
| 38e10eb140 | |||
| fe82a1b6ca | |||
| 2ea728f731 | |||
| 836fcfb742 | |||
| 284214c78d | |||
| b05a0d1fbe | |||
| be25c905fa | |||
| 5347b5e009 | |||
| b467518dae | |||
| 1ec6bdc8c6 | |||
| 265472ef72 | |||
| 325529761b | |||
| 741244b69d | |||
| 4abddbbfdd | |||
| 5aa5cb3523 | |||
| 9113255114 | |||
| 23cc677108 | |||
| 28bcc0e4f4 | |||
| 8bf5062b89 | |||
| 20750c332e | |||
| db581e47a3 | |||
| a54a693af2 | |||
| d468201952 | |||
| 31c5c4e924 | |||
| 8d65dbdd04 | |||
| b7f9363393 | |||
| 69f2cbaa3a | |||
| dc898ca2c3 | |||
| 03bff6500a | |||
| b379d71d19 | |||
| def7220bfc | |||
| f6c5afe5a9 | |||
| 847771fe06 | |||
| b8bd88901a | |||
| f1adabcddc | |||
| 24470e52a5 | |||
| 23bbc5cd14 | |||
| ebf6fc07a1 | |||
| 04513d9692 | |||
| 146720e197 | |||
| b1f8cc04af | |||
| 082fe93e16 | |||
| 709c195905 | |||
| 3355b78d91 | |||
| b84c63d034 | |||
| 057b5a9dee | |||
| 61aefc0759 | |||
| f929f8f100 | |||
| 4a57013cf8 | |||
| 2f17fc9613 | |||
| b8ea3ea091 | |||
| 82a5796159 | |||
| 9f38562237 | |||
| 5b4fb1f770 | |||
| cb88db6ce3 | |||
| 66fdd51f0d | |||
| 98dd65cd60 | |||
| f0afb6750e | |||
| 703d09f306 | |||
| aefaf140c1 | |||
| 44614c79c4 | |||
| dc67b7a3be | |||
| de1bd36a47 | |||
| 6c97a46dae | |||
| c387fe190b | |||
| 8721a2710b | |||
| c75e3b5bae | |||
| edc8d1d91e | |||
| e85735028c | |||
| 6c3299103c | |||
| 60e23fee16 | |||
| 0c380f31ac | |||
| 26aefa68f5 | |||
| 638788f8d0 | |||
| b005a15a4a | |||
| 0efa33cf33 | |||
| 7e94ddafe8 | |||
| 7fe993879f | |||
| def14d96ef | |||
| b0b1149233 | |||
| 7becbbf711 | |||
| 87481f2bb6 | |||
| 9535a8f929 | |||
| e917c12c80 | |||
| 89130db6b0 | |||
| 9465a6c97d | |||
| 53fd02970d | |||
| 6ff0adef22 | |||
| 3307f99f1f | |||
| 8de7a3c739 | |||
| 28d0637406 | |||
| ef7f712867 | |||
| 3016830e25 | |||
| e9e7472456 | |||
| 9f9fbf815c | |||
| c88facb2cc | |||
| d4b85f8d83 | |||
| 1882c33827 | |||
| 01624c8c87 | |||
| 0891ecd8e0 | |||
| 32f278b48f | |||
| bb6bec9070 | |||
| 0eac0964af | |||
| f67e80c275 | |||
| 19231fce4b | |||
| d0a7ca6d1b | |||
| d80e86be9d | |||
| e4bd9757a3 | |||
| 4087cf1a97 | |||
| 14f0bf64a1 | |||
| 2df47ee394 | |||
| ad82616445 | |||
| d4ad650f73 | |||
| 2039c90c06 | |||
| da59457ca4 | |||
| db4dbaa289 | |||
| d3f30df180 | |||
| 7ea343cabd | |||
| e024e2ef92 | |||
| ceb4890ca3 | |||
| 03d1099933 | |||
| 3f239b3834 | |||
| c1ee3c6e34 | |||
| a1ab489e61 | |||
| 8c38d260eb | |||
| a4f25a94b4 | |||
| ff0382e971 | |||
| ac5380614f | |||
| c08350b83e | |||
| c5cf416633 | |||
| 3dbce3dade | |||
| a277557a5f | |||
| 005bf088c9 | |||
| eca5e68a5e | |||
| 5b11635d97 | |||
| a9eda2e99e | |||
| 73009f0b09 | |||
| f720490219 | |||
| 341c64cc97 | |||
| 1e17856ba4 | |||
| a6f7fe739c | |||
| 02f50a6e80 | |||
| 7565e03536 | |||
| 035a5a1a67 | |||
| e8b0992788 | |||
| 8cbea4fbb7 | |||
| f256a4c538 | |||
| a45c2b917e | |||
| f28d72d15e | |||
| d8a9d8a76d | |||
| 982a9044c5 | |||
| 7edefb3347 | |||
| 017a5977d2 | |||
| 7336cc5a7b | |||
| e1587588c4 | |||
| be93cec9ba | |||
| 7c898249a2 | |||
| 9e432a6785 | |||
| 1ead8d84a2 | |||
| 8c81f9a40b | |||
| 95636bd3ca | |||
| 7dae9a1083 | |||
| d46be565aa | |||
| 8021dbd80f | |||
| c8487bac54 | |||
| 484f00ebf4 | |||
| c3be6c4965 | |||
| 172d08cb98 | |||
| 5e39051183 | |||
| d50b9e8f9a | |||
| 921b1c6a34 | |||
| 9c8060d96d | |||
| 87db7a7f0a | |||
| 237e694df0 | |||
| 3f9dad3fd0 | |||
| 3cc70160de | |||
| b72d7b6fa2 | |||
| 029f5aa3a0 | |||
| 284e97068c | |||
| 970c38257c | |||
| c39ad38424 | |||
| 91508f6c88 | |||
| 1274408b89 | |||
| 7ae1be5bf6 | |||
| 40e4d730ca | |||
| 8dc539b4ae | |||
| 8e8d38c3cd | |||
| a9bebd3653 | |||
| 78f5764319 | |||
| 55b0211690 | |||
| 4f62295e44 | |||
| e1c41941a9 | |||
| 0b4edfc64c | |||
| 4469cecc4e | |||
| b06768aa8e | |||
| bc2d3df05f | |||
| e41e416beb | |||
| 2d16759723 | |||
| a41ed1cdf2 | |||
| 24479e2df9 | |||
| 365a95c91b | |||
| 49ca92621d | |||
| 92aa12a609 | |||
| 484b662665 | |||
| e8d955a40c | |||
| 7adfe50b9a | |||
| 5613ebb514 | |||
| c0ecd5da0e | |||
| be5f5dbc29 | |||
| 3cf9cfb6f3 | |||
| fcc21f3dc5 | |||
| fa40659b7f | |||
| fdf14a8462 | |||
| db43ee0b05 | |||
| 2b53cf0f4a | |||
| dffa1045d3 | |||
| d862d02d0e | |||
| 4abac2a431 |
@@ -10,6 +10,7 @@ kitlangton
|
|||||||
kommander
|
kommander
|
||||||
ludvigrask
|
ludvigrask
|
||||||
MrMushrooooom
|
MrMushrooooom
|
||||||
|
neriousy
|
||||||
nexxeln
|
nexxeln
|
||||||
R44VC0RP
|
R44VC0RP
|
||||||
rekram1-node
|
rekram1-node
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
name: beta
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 * * * *"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
sync:
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: ./.github/actions/setup-bun
|
|
||||||
|
|
||||||
- name: Setup Git Committer
|
|
||||||
id: setup-git-committer
|
|
||||||
uses: ./.github/actions/setup-git-committer
|
|
||||||
with:
|
|
||||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
|
||||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
|
||||||
|
|
||||||
- name: Install OpenCode
|
|
||||||
run: bun i -g opencode-ai
|
|
||||||
|
|
||||||
- name: Sync beta branch
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }}
|
|
||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
|
||||||
run: bun script/beta.ts
|
|
||||||
@@ -35,6 +35,7 @@ jobs:
|
|||||||
|
|
||||||
- run: bun sst deploy --stage=${{ github.ref_name }}
|
- run: bun sst deploy --stage=${{ github.ref_name }}
|
||||||
env:
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
|
PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
|
||||||
PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
|
PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
|
||||||
|
|||||||
@@ -219,7 +219,6 @@ jobs:
|
|||||||
|
|
||||||
build-electron:
|
build-electron:
|
||||||
needs:
|
needs:
|
||||||
- build-cli
|
|
||||||
- version
|
- version
|
||||||
if: github.repository == 'anomalyco/opencode'
|
if: github.repository == 'anomalyco/opencode'
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
@@ -316,16 +315,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 }}
|
||||||
@@ -343,8 +340,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
CSC_KEYCHAIN: build.keychain
|
||||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
|
||||||
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
|
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
|
||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
||||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
name: rtl-aware-development
|
||||||
|
description: OpenCode Desktop should be RTL-aware. Use when implementing or reviewing RTL/LTR behavior in the web app, desktop app, CSS, menus, scrolling, resizing, icons, mixed-direction text, or Electron title bars.
|
||||||
|
---
|
||||||
|
|
||||||
|
# RTL-Aware Development
|
||||||
|
|
||||||
|
Treat direction as independent from language. Test English in both directions as well as real RTL and mixed-script content.
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Set `lang` and `dir` on the document, and propagate direction through component providers used by portaled menus and popovers. Do not change the selected locale merely to force RTL.
|
||||||
|
- Keep DOM and focus order semantic. Flexbox and Grid already follow `dir`; do not add `row-reverse`, CSS `order`, or reversed markup just to mirror a layout.
|
||||||
|
- Prefer logical CSS for semantic layout. Reserve physical coordinates for pointer positions, canvas geometry, native window controls, and other genuinely physical placement.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Avoid */
|
||||||
|
padding-left: 12px;
|
||||||
|
right: 0;
|
||||||
|
border-right: 1px solid;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
/* Prefer */
|
||||||
|
padding-inline-start: 12px;
|
||||||
|
inset-inline-end: 0;
|
||||||
|
border-inline-end: 1px solid;
|
||||||
|
text-align: start;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Isolate mixed-direction text. Use `dir="auto"` or `<bdi>` for unknown text; keep code, URLs, IDs, and filesystem paths LTR without forcing the surrounding component LTR.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span class="file-row"><bdi dir="auto">README.md</bdi></span> <bdi dir="ltr"><code>C:\src\app.ts</code></bdi>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Mirror directional meaning, not every image. Back/forward, previous/next, disclosure, indentation, and directional progress may need mirroring. Do not mirror brands, clocks, media controls, charts, or text. Reverse physical gradients, `translateX`, SVG transforms, and animation deltas explicitly.
|
||||||
|
- Map interactions through direction. `clientX` remains physical; resizing a logical edge needs an RTL-aware delta. Logical previous/next keyboard controls may swap ArrowLeft/ArrowRight. Follow the relevant WAI-ARIA widget pattern.
|
||||||
|
- Do not assume LTR scrolling. RTL `scrollLeft` can start at `0` and become negative. Prefer `scrollIntoView({ inline: "nearest" })` or a tested direction-normalizing helper.
|
||||||
|
- For Electron title bars, prefer native caption controls and use `titleBarOverlay` plus `env(titlebar-area-*)` for the safe content rectangle. Keep Windows/macOS native-control avoidance and `trafficLightPosition` physical; keep app navigation inside that rectangle logical. Mark interactive titlebar children `app-region: no-drag`.
|
||||||
|
- Verify behavior, not screenshots alone. Check computed styles, pseudo-element geometry, hit zones, focus order, keyboard behavior, submenu direction, zoom/scaling, and both LTR and RTL scroll endpoints.
|
||||||
|
|
||||||
|
## Test Matrix
|
||||||
|
|
||||||
|
- English + LTR
|
||||||
|
- English + forced RTL
|
||||||
|
- A real RTL locale + RTL
|
||||||
|
- Mixed RTL/LTR content, long labels, numbers, code, and paths
|
||||||
|
- Keyboard, pointer resize, scrolling, menus/submenus, and Electron titlebar controls in both directions
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [RTL Styling 101, Ahmad Shadeed](https://rtlstyling.com/posts/rtl-styling/)
|
||||||
|
- [CSS-Tricks: RTL Styling 101](https://css-tricks.com/rtl-styling-101/)
|
||||||
|
- [CSS-Tricks: CSS Logical Properties and Values](https://css-tricks.com/css-logical-properties-and-values/)
|
||||||
|
- [W3C: Structural markup and right-to-left text](https://www.w3.org/International/questions/qa-html-dir)
|
||||||
|
- [W3C: Inline bidirectional markup](https://www.w3.org/International/articles/inline-bidi-markup/)
|
||||||
|
- [MDN: CSS logical properties and values](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Logical_properties_and_values)
|
||||||
|
- [MDN: `dir`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir)
|
||||||
|
- [MDN: `scrollLeft`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft)
|
||||||
|
- [web.dev: Logical properties](https://web.dev/learn/css/logical-properties/)
|
||||||
|
- [Electron: Custom title bar](https://www.electronjs.org/docs/latest/tutorial/custom-title-bar)
|
||||||
|
- [WAI-ARIA: Window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
|
||||||
|
- [Kobalte: I18n Provider](https://kobalte.dev/docs/core/components/i18n-provider/)
|
||||||
@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
|
|||||||
const TEAM = {
|
const TEAM = {
|
||||||
tui: ["kommander", "simonklee"],
|
tui: ["kommander", "simonklee"],
|
||||||
desktop_web: ["Hona", "Brendonovich"],
|
desktop_web: ["Hona", "Brendonovich"],
|
||||||
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
|
core: ["jlongster", "rekram1-node", "neriousy", "nexxeln", "kitlangton"],
|
||||||
inference: ["fwang", "MrMushrooooom", "starptech"],
|
inference: ["fwang", "MrMushrooooom", "starptech"],
|
||||||
windows: ["Hona"],
|
windows: ["Hona"],
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@corvu/drawer": "catalog:",
|
"@corvu/drawer": "catalog:",
|
||||||
"@dnd-kit/abstract": "0.5.0",
|
"@dnd-kit/abstract": "0.5.0",
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lildax": "./bin/lildax.cjs",
|
"lildax": "./bin/lildax.cjs",
|
||||||
},
|
},
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
},
|
},
|
||||||
"packages/codemode": {
|
"packages/codemode": {
|
||||||
"name": "@opencode-ai/codemode",
|
"name": "@opencode-ai/codemode",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"acorn": "8.15.0",
|
"acorn": "8.15.0",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -194,7 +194,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.82",
|
"@ai-sdk/anthropic": "3.0.82",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -243,7 +243,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -267,7 +267,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/support": {
|
"packages/console/support": {
|
||||||
"name": "@opencode-ai/console-support",
|
"name": "@opencode-ai/console-support",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@opencode-ai/console-core": "workspace:*",
|
"@opencode-ai/console-core": "workspace:*",
|
||||||
@@ -287,7 +287,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -332,14 +332,14 @@
|
|||||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@silvia-odwyer/photon-node": "0.3.4",
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"ai-gateway-provider": "3.1.2",
|
"ai-gateway-provider": "3.2.0",
|
||||||
"bun-pty": "0.4.8",
|
"bun-pty": "0.4.8",
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"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",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
@@ -381,16 +381,16 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"electron-context-menu": "4.1.2",
|
"electron-context-menu": "4.1.2",
|
||||||
"electron-log": "^5",
|
"electron-log": "^5",
|
||||||
"electron-store": "11.0.2",
|
"electron-store": "11.0.2",
|
||||||
"electron-updater": "6.8.9",
|
"electron-updater": "6.8.9",
|
||||||
"electron-window-state": "^5.0.3",
|
"electron-window-state": "^5.0.3",
|
||||||
"marked": "^15",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/artifact": "4.0.0",
|
"@actions/artifact": "4.0.0",
|
||||||
@@ -435,7 +435,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-drizzle-sqlite": {
|
"packages/effect-drizzle-sqlite": {
|
||||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -449,7 +449,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-sqlite-node": {
|
"packages/effect-sqlite-node": {
|
||||||
"name": "@opencode-ai/effect-sqlite-node",
|
"name": "@opencode-ai/effect-sqlite-node",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -461,7 +461,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/standard-validator": "catalog:",
|
"@hono/standard-validator": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -493,7 +493,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -509,7 +509,7 @@
|
|||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "4.0.0-beta.83",
|
"@effect/platform-node": "4.0.0-beta.83",
|
||||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||||
@@ -540,7 +540,7 @@
|
|||||||
},
|
},
|
||||||
"packages/llm": {
|
"packages/llm": {
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
@@ -559,7 +559,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -623,7 +623,7 @@
|
|||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
"ai": "catalog:",
|
"ai": "catalog:",
|
||||||
"ai-gateway-provider": "3.1.2",
|
"ai-gateway-provider": "3.2.0",
|
||||||
"bonjour-service": "1.3.0",
|
"bonjour-service": "1.3.0",
|
||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
@@ -632,7 +632,7 @@
|
|||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "6.11.1",
|
"gitlab-ai-provider": "6.12.1",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
@@ -690,7 +690,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -766,7 +766,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -781,7 +781,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@opencode-ai/server",
|
"name": "@opencode-ai/server",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/protocol": "workspace:*",
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
@@ -796,7 +796,7 @@
|
|||||||
},
|
},
|
||||||
"packages/session-ui": {
|
"packages/session-ui": {
|
||||||
"name": "@opencode-ai/session-ui",
|
"name": "@opencode-ai/session-ui",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
|
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
|
||||||
@@ -811,15 +811,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:",
|
||||||
@@ -832,7 +828,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:",
|
||||||
@@ -841,7 +836,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -854,7 +849,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/app": {
|
"packages/stats/app": {
|
||||||
"name": "@opencode-ai/stats-app",
|
"name": "@opencode-ai/stats-app",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
@@ -888,7 +883,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/core": {
|
"packages/stats/core": {
|
||||||
"name": "@opencode-ai/stats-core",
|
"name": "@opencode-ai/stats-core",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"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",
|
||||||
@@ -907,7 +902,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/server": {
|
"packages/stats/server": {
|
||||||
"name": "@opencode-ai/stats-server",
|
"name": "@opencode-ai/stats-server",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-firehose": "3.933.0",
|
"@aws-sdk/client-firehose": "3.933.0",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
@@ -949,7 +944,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tui": {
|
"packages/tui": {
|
||||||
"name": "@opencode-ai/tui",
|
"name": "@opencode-ai/tui",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
@@ -976,7 +971,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
@@ -992,7 +987,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",
|
||||||
@@ -1002,6 +996,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": {
|
||||||
@@ -1027,7 +1022,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -1080,8 +1075,11 @@
|
|||||||
"@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",
|
||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
|
"@ai-sdk/groq@3.0.31": "patches/@ai-sdk%2Fgroq@3.0.31.patch",
|
||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||||
|
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
||||||
|
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
@@ -1136,7 +1134,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",
|
||||||
@@ -1145,6 +1143,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",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
@@ -1184,15 +1183,15 @@
|
|||||||
|
|
||||||
"@ai-sdk/cohere": ["@ai-sdk/cohere@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OqcCq2PiFY1dbK/0Ck45KuvE8jfdxRuuAE9Y5w46dAk6U+9vPOeg1CDcmR+ncqmrYrhRl3nmyDttyDahyjCzAw=="],
|
"@ai-sdk/cohere": ["@ai-sdk/cohere@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OqcCq2PiFY1dbK/0Ck45KuvE8jfdxRuuAE9Y5w46dAk6U+9vPOeg1CDcmR+ncqmrYrhRl3nmyDttyDahyjCzAw=="],
|
||||||
|
|
||||||
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-VscTV68g6sXRY4O1yl72/O8y6+tBDvSQax6bqX06hRKWBGxsJ8Jr3LZsNmZnK9Od5Icx565ijK0QgrlNaN4TdQ=="],
|
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@2.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DPoKiCXDwzopJI/pHcZnXaycZ5qqCL4xCWcfsb1s8M8OUPchK2GUHcCXt6v056fAqtKWLF/hrW+RcHFFY0qyHQ=="],
|
||||||
|
|
||||||
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="],
|
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="],
|
||||||
|
|
||||||
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="],
|
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="],
|
||||||
|
|
||||||
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="],
|
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XFONX6rAsu6d13cJVUfZfkq4a+qdThlxvoEfzYlSRa1AvALlzwX7Y6bunXxevuifT3n882+nDdWrdiYvFP0+Fw=="],
|
||||||
|
|
||||||
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.53", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.48", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HjeiGsdxSzrCkOf2l2V+K+opzlqxBtduBq6BCiohAdgQk2KdZmI/67SMkBM6Kdze/BjUXiZlv0d7zNICPhxVDA=="],
|
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.76", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.67", "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yg1ulgemh6BLMrs2vBNbtjV70NhenFI3Z7psB7s94FOEcGevvyV1qdFoqsgBZ7QyuUGwnC645c+eBFFtPAr5SQ=="],
|
||||||
|
|
||||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="],
|
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="],
|
||||||
|
|
||||||
@@ -3064,7 +3063,7 @@
|
|||||||
|
|
||||||
"ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="],
|
"ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="],
|
||||||
|
|
||||||
"ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
|
"ai-gateway-provider": ["ai-gateway-provider@3.2.0", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.117", "@ai-sdk/anthropic": "^3.0.84", "@ai-sdk/azure": "^3.0.74", "@ai-sdk/cerebras": "^2.0.56", "@ai-sdk/cohere": "^3.0.38", "@ai-sdk/deepgram": "^2.0.35", "@ai-sdk/deepseek": "^2.0.38", "@ai-sdk/elevenlabs": "^2.0.35", "@ai-sdk/fireworks": "^2.0.56", "@ai-sdk/google": "^3.0.82", "@ai-sdk/google-vertex": "^4.0.145", "@ai-sdk/groq": "^3.0.41", "@ai-sdk/mistral": "^3.0.39", "@ai-sdk/openai": "^3.0.71", "@ai-sdk/perplexity": "^3.0.35", "@ai-sdk/xai": "^3.0.95", "@openrouter/ai-sdk-provider": "^2.10.0" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-IGSV96IqAfiZd20CWSMVQk5sVeLcJR2uQcoWLB8GdkxyvQrsU3x4U0o1Ok6bfVJug7SrlX6I8ibz9cHIkUyRtg=="],
|
||||||
|
|
||||||
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||||
|
|
||||||
@@ -3854,7 +3853,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=="],
|
||||||
|
|
||||||
@@ -4316,9 +4315,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=="],
|
||||||
|
|
||||||
@@ -5126,6 +5123,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=="],
|
||||||
@@ -5668,9 +5667,9 @@
|
|||||||
|
|
||||||
"@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
"@ai-sdk/deepgram/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
"@ai-sdk/deepgram/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="],
|
||||||
|
|
||||||
"@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
"@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="],
|
||||||
|
|
||||||
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
@@ -5678,15 +5677,15 @@
|
|||||||
|
|
||||||
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||||
|
|
||||||
"@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
"@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="],
|
||||||
|
|
||||||
"@ai-sdk/elevenlabs/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
"@ai-sdk/elevenlabs/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="],
|
||||||
|
|
||||||
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q=="],
|
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-glcEJC2mBXJKj7joFI0fRhcbdDYKTBgXMPcT6Vcnlym67tTzuNG9pFx3zblxVv8TdOxhojJja5zGG19yeGJxuA=="],
|
||||||
|
|
||||||
"@ai-sdk/fireworks/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
"@ai-sdk/fireworks/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="],
|
||||||
|
|
||||||
"@ai-sdk/fireworks/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
"@ai-sdk/fireworks/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="],
|
||||||
|
|
||||||
"@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
"@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||||
|
|
||||||
@@ -6040,8 +6039,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/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
|
"@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
|
||||||
@@ -6174,21 +6171,25 @@
|
|||||||
|
|
||||||
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.107", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8nT08pGPy25rleJNk56ep00UHK6kCtCmu+ZNqVVSSPDieADlIZqcaN1iRXAFBoCH0Fb9F6C2EjFDaySdsargfQ=="],
|
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.153", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.110", "@ai-sdk/openai": "3.0.96", "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iEXrLgWylCHJmznqlKLU3CqRh8UWibv+illrwmsk136FVBBvyXiGnpQrI1pGWCScVLQjBQSFQu7GJDkUEomf/A=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
|
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.110", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rNkamQCeAUOUGr5Npg5pXZyYFH4fS1U6Mbdy3dF/NNBEI3D2Chc/ruRrwNegP0gfpX3cllP3O4jSibGBbWPZ7A=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
|
"ai-gateway-provider/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
|
"ai-gateway-provider/@ai-sdk/cohere": ["@ai-sdk/cohere@3.0.54", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cXLjIsSzUriPHe704IH6d+ipJ/OvczTB700p9Zma7DPgQzvxG/diyr8q/2LEsbTRiTopiKhky8dn1PJNQcJToQ=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
|
"ai-gateway-provider/@ai-sdk/google": ["@ai-sdk/google@3.0.108", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kwvYpRNghqt0VRKE7Hx1UWZQCUJJFqUITj24baxy+ApS0Hru0PkBJHD75a36Wc+e6e+wHcKR2MconTeJiBZigA=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
"ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.181", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.110", "@ai-sdk/google": "3.0.108", "@ai-sdk/openai-compatible": "2.0.67", "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-57b5Qor8V53vubkxCj09tbHWpzpCLUbzmll2FwShuLvyEAsCH6mh3sAowDhiwUWPXnLzU+rC3RVMKCPscqICcg=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
|
"ai-gateway-provider/@ai-sdk/groq": ["@ai-sdk/groq@3.0.59", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-X4h60TGq4pIOXPsthatUr+bfTaYCaKGX597hG9JgcueEl4+nboCdw99ixjFKGkvYlBJwLCCfI957EmGA2QlF0w=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="],
|
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bL3SWrPltTuxVNg/bZ5APbJLVe0+BIU+BYAbT8Yo0eSAZ5eMTImN2murQHZa08Wi8lUMm8MNolaYBQkb0JDbFw=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.10.0", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-FMsAEjLUt5pWuRE2LDC/LCvVrFjLlrEzUITH5+5SZtfq7KZ2wrOHjQVxzz92sju8S9ltpzW87CLW8/b0oBXVCw=="],
|
||||||
|
|
||||||
"ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
"ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||||
|
|
||||||
@@ -6598,14 +6599,20 @@
|
|||||||
|
|
||||||
"@ai-sdk/deepgram/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@ai-sdk/deepgram/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@ai-sdk/deepgram/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||||
|
|
||||||
"@ai-sdk/deepinfra/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@ai-sdk/deepinfra/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@ai-sdk/elevenlabs/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@ai-sdk/elevenlabs/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@ai-sdk/elevenlabs/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||||
|
|
||||||
"@ai-sdk/fireworks/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@ai-sdk/fireworks/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@ai-sdk/fireworks/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||||
|
|
||||||
"@ai-sdk/google-vertex/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@ai-sdk/google-vertex/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@ai-sdk/google/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@ai-sdk/google/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
@@ -6980,29 +6987,51 @@
|
|||||||
|
|
||||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/openai": ["@ai-sdk/openai@3.0.96", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Pex8vOj1y05j7jtBS39cJJRDjJbMIyCY9+01cSIp1hwEJTKImrFejMgsAazMWXSi/HU+B9ZE6ElftCOwvg4mmQ=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
|
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
|
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
|
"ai-gateway-provider/@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.54", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OyXt0zK8y2/ZIyWlbxTv2r1M7AK227S+Gl4BYOEF42q0wz1n5m4fwR8L4Fy/MQ4Ho6xje47MPsFcRdIqIyP6Rw=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"ai-gateway-provider/@ai-sdk/cerebras/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
"ai-gateway-provider/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
"ai-gateway-provider/@ai-sdk/cohere/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"ai-gateway-provider/@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
"ai-gateway-provider/@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-glcEJC2mBXJKj7joFI0fRhcbdDYKTBgXMPcT6Vcnlym67tTzuNG9pFx3zblxVv8TdOxhojJja5zGG19yeGJxuA=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/groq/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/perplexity/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="],
|
||||||
|
|
||||||
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
|
|
||||||
@@ -7422,13 +7451,35 @@
|
|||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"ai-gateway-provider/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"ai-gateway-provider/@ai-sdk/cohere/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/cohere/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/google/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/google/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/groq/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/groq/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/perplexity/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/perplexity/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||||
|
|
||||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
|
|||||||
+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 }]
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+13
-1
@@ -181,6 +181,16 @@ const statsSyncConfig = new sst.Linkable("StatsSyncConfig", {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const r2SqlAuthToken = new sst.Secret("R2SqlAuthToken")
|
||||||
|
const r2Sql = new sst.Linkable("R2Sql", {
|
||||||
|
properties: {
|
||||||
|
accountId: "15d29c8639fd3733b1b5486a2acfd968",
|
||||||
|
bucket: `platform-${$app.stage}-lake`,
|
||||||
|
namespace: "inference",
|
||||||
|
table: "generation",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const statSync = new sst.aws.Service("StatsSyncService", {
|
export const statSync = new sst.aws.Service("StatsSyncService", {
|
||||||
cluster: lakeCluster,
|
cluster: lakeCluster,
|
||||||
architecture: "arm64",
|
architecture: "arm64",
|
||||||
@@ -193,7 +203,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
|
|||||||
dockerfile: "packages/stats/server/Dockerfile",
|
dockerfile: "packages/stats/server/Dockerfile",
|
||||||
},
|
},
|
||||||
command: ["bun", "src/stat-sync.ts"],
|
command: ["bun", "src/stat-sync.ts"],
|
||||||
link: [database, inferenceEvent, statsSyncConfig],
|
// Keep the legacy Athena link and IAM permissions during the first R2-backed
|
||||||
|
// release so reverting the application code remains a one-deploy rollback.
|
||||||
|
link: [database, inferenceEvent, r2Sql, r2SqlAuthToken, statsSyncConfig],
|
||||||
permissions: lakeQueryPermissions,
|
permissions: lakeQueryPermissions,
|
||||||
scaling: {
|
scaling: {
|
||||||
min: 1,
|
min: 1,
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-2suqkFAUeT2YgppKqP14s0C+GE3TR3uNKOOD8vWEiAg=",
|
"x86_64-linux": "sha256-kDCnJMnaK/Jq7ckcpPB7Vl9v98EMSdcehZAtf8jNjTs=",
|
||||||
"aarch64-linux": "sha256-cJ/XYTodYmgs9ZFQhHQbfZPqxjIMmEvz062OsX6/6p4=",
|
"aarch64-linux": "sha256-0aR+OJGXS5HMlXbe/BHybjIRvdNJJw6gjW+jr6Dk7Pk=",
|
||||||
"aarch64-darwin": "sha256-tKyqHeI6r1aHEk4vBS39+eA6wh9KcIwsa2ctcktKkEc=",
|
"aarch64-darwin": "sha256-loLrV6xiorhwS/N2hlpiKSKX172Qxy+auNiPzFBhQSc=",
|
||||||
"x86_64-darwin": "sha256-1u7+dNjRcqKSvUTCEarVJ9fMrRUyg9y+4seZAm6Fmus="
|
"x86_64-darwin": "sha256-PNEpQBLAz8M274bSyTpp0jofETn2L+D0uBiJHUV7nB0="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -70,7 +70,7 @@
|
|||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
"marked": "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",
|
||||||
@@ -90,6 +90,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"
|
||||||
}
|
}
|
||||||
@@ -143,6 +144,7 @@
|
|||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
|
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
||||||
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
|
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
|
||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
@@ -156,6 +158,8 @@
|
|||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||||
|
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
|
||||||
|
"@ai-sdk/groq@3.0.31": "patches/@ai-sdk%2Fgroq@3.0.31.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -197,7 +197,9 @@ export async function setupTimeline(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
async waitForPart(partID: string) {
|
async waitForPart(partID: string) {
|
||||||
await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible()
|
const part = page.locator(`[data-timeline-part-id="${partID}"]`)
|
||||||
|
await expect(part).toHaveCount(1)
|
||||||
|
await expect(part).toBeVisible()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/OpenFileExpand"
|
||||||
|
const projectID = "proj_open_file_expand"
|
||||||
|
const sessionID = "ses_open_file_expand"
|
||||||
|
const title = "Open file expand"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
|
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "open-file-expand",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
|
},
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sessionID,
|
||||||
|
slug: sessionID,
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vcsDiff: [],
|
||||||
|
fileList: (path) => {
|
||||||
|
if (path === "frontend\\" || path === "frontend") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "app.ts",
|
||||||
|
path: "frontend\\app.ts",
|
||||||
|
absolute: `${directory}/frontend/app.ts`,
|
||||||
|
type: "file" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (path) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "frontend",
|
||||||
|
path: "frontend\\",
|
||||||
|
absolute: `${directory}/frontend`,
|
||||||
|
type: "directory" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "README.md",
|
||||||
|
path: "README.md",
|
||||||
|
absolute: `${directory}/README.md`,
|
||||||
|
type: "file" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.addInitScript(
|
||||||
|
({ directory, server, sessionID }) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"settings.v3",
|
||||||
|
JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||||
|
lastProject: { local: directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:layout",
|
||||||
|
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:review-panel-v2",
|
||||||
|
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, server, sessionID },
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
|
const panel = page.locator("#review-panel")
|
||||||
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
|
|
||||||
|
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
|
||||||
|
await expect(sidebar).toBeVisible()
|
||||||
|
|
||||||
|
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
|
||||||
|
await expect(frontendRow).toBeVisible()
|
||||||
|
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
|
||||||
|
await frontendRow.click()
|
||||||
|
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
|
||||||
|
|
||||||
|
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
|
||||||
|
await expect(appRow).toBeVisible()
|
||||||
|
await appRow.click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectAppVisible } from "../utils/waits"
|
||||||
|
|
||||||
|
const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
|
||||||
|
const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)
|
||||||
|
|
||||||
|
// The sixth project sits outside the five-item recent cap, so it is only reachable if the
|
||||||
|
// dialog hands every recent project to the list filter instead of a pre-truncated slice.
|
||||||
|
const OUTSIDE_CAP = "foxtrot-docs"
|
||||||
|
|
||||||
|
// Dialog rows carry data-directory-path; the sidebar project list does not, so this
|
||||||
|
// scopes assertions to the picker instead of matching the sidebar entry of the same name.
|
||||||
|
const rows = (page: Page) => page.locator("[data-directory-path]")
|
||||||
|
const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)
|
||||||
|
|
||||||
|
async function openProjectDialog(page: Page) {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
sessions: fixture.sessions,
|
||||||
|
provider: fixture.provider,
|
||||||
|
directory: fixture.directory,
|
||||||
|
project: fixture.project,
|
||||||
|
pageMessages,
|
||||||
|
fileList: () => [],
|
||||||
|
findFiles: () => [],
|
||||||
|
})
|
||||||
|
await page.addInitScript((dirs) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
|
||||||
|
lastProject: {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, worktrees)
|
||||||
|
await page.goto("/")
|
||||||
|
const add = page.getByRole("button", { name: "Add project" }).first()
|
||||||
|
await expectAppVisible(add)
|
||||||
|
await add.click()
|
||||||
|
await expect(rows(page)).toHaveCount(5)
|
||||||
|
return page.getByRole("textbox").last()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("searches every recent project, not just the five most recent", async ({ page }) => {
|
||||||
|
const search = await openProjectDialog(page)
|
||||||
|
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
|
||||||
|
|
||||||
|
await search.fill("foxtrot")
|
||||||
|
|
||||||
|
await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("still caps the idle recent list at five projects", async ({ page }) => {
|
||||||
|
await openProjectDialog(page)
|
||||||
|
|
||||||
|
await expect(row(page, NAMES[4])).toHaveCount(1)
|
||||||
|
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -18,6 +18,7 @@ test("opens the comment editor when code is clicked", async ({ page }) => {
|
|||||||
await line.click()
|
await line.click()
|
||||||
|
|
||||||
await expect(review.getByRole("textbox")).toBeVisible()
|
await expect(review.getByRole("textbox")).toBeVisible()
|
||||||
|
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("opens the comment editor when a line number is clicked", async ({ page }) => {
|
test("opens the comment editor when a line number is clicked", async ({ page }) => {
|
||||||
@@ -27,6 +28,7 @@ test("opens the comment editor when a line number is clicked", async ({ page })
|
|||||||
await lineNumber.click()
|
await lineNumber.click()
|
||||||
|
|
||||||
await expect(review.getByRole("textbox")).toBeVisible()
|
await expect(review.getByRole("textbox")).toBeVisible()
|
||||||
|
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("opens the comment editor for a line number range", async ({ page }) => {
|
test("opens the comment editor for a line number range", async ({ page }) => {
|
||||||
@@ -36,15 +38,10 @@ test("opens the comment editor for a line number range", async ({ page }) => {
|
|||||||
await expectAppVisible(start)
|
await expectAppVisible(start)
|
||||||
await expectAppVisible(end)
|
await expectAppVisible(end)
|
||||||
|
|
||||||
const from = await start.boundingBox()
|
await start.dragTo(end)
|
||||||
const to = await end.boundingBox()
|
|
||||||
if (!from || !to) throw new Error("Missing line number bounds")
|
|
||||||
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
|
|
||||||
await page.mouse.down()
|
|
||||||
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2)
|
|
||||||
await page.mouse.up()
|
|
||||||
|
|
||||||
await expect(review.getByRole("textbox")).toBeVisible()
|
await expect(review.getByRole("textbox")).toBeVisible()
|
||||||
|
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("shows a comment button when a line number is hovered", async ({ page }) => {
|
test("shows a comment button when a line number is hovered", async ({ page }) => {
|
||||||
@@ -54,31 +51,38 @@ test("shows a comment button when a line number is hovered", async ({ page }) =>
|
|||||||
|
|
||||||
const comment = review.getByRole("button", { name: "Comment", exact: true })
|
const comment = review.getByRole("button", { name: "Comment", exact: true })
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
await page.mouse.move(0, 0)
|
|
||||||
await lineNumber.hover()
|
await lineNumber.hover()
|
||||||
await expect(comment).toBeVisible({ timeout: 500 })
|
await expect(lineNumber).toHaveAttribute("data-hovered", "")
|
||||||
await comment.click({ timeout: 500 })
|
await expect(comment).toHaveCount(1)
|
||||||
}).toPass()
|
await expect(comment).toHaveCSS("pointer-events", "auto")
|
||||||
|
await comment.focus()
|
||||||
|
await expect(comment).toBeFocused()
|
||||||
|
}).toPass({ timeout: 10_000 })
|
||||||
|
await comment.press("Enter")
|
||||||
await expect(review.getByRole("textbox")).toBeVisible()
|
await expect(review.getByRole("textbox")).toBeVisible()
|
||||||
|
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("stages a submitted line comment in the prompt context", async ({ page }) => {
|
test("stages a submitted line comment in the prompt context", async ({ page }) => {
|
||||||
const requests: string[] = []
|
|
||||||
page.on("request", (request) => {
|
page.on("request", (request) => {
|
||||||
if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`)
|
expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET")
|
||||||
})
|
})
|
||||||
|
|
||||||
const review = page.locator('[data-component="session-review"]')
|
const review = page.locator('[data-component="session-review"]')
|
||||||
await review.getByText("export const value = 'after'", { exact: true }).click()
|
await review.getByText("export const value = 'after'", { exact: true }).click()
|
||||||
await review.getByRole("textbox").fill("Use the existing value instead")
|
const textbox = review.getByRole("textbox")
|
||||||
await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click()
|
await expect(textbox).toBeVisible()
|
||||||
|
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
|
||||||
|
await textbox.fill("Use the existing value instead")
|
||||||
|
const submit = review.locator('[data-slot="line-comment-action"][data-variant="primary"]')
|
||||||
|
await expect(submit).toBeEnabled()
|
||||||
|
await submit.click()
|
||||||
|
|
||||||
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
|
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
|
||||||
await page.getByRole("tab", { name: "Session" }).click()
|
await page.getByRole("tab", { name: "Session" }).click()
|
||||||
const context = page.getByText("Use the existing value instead", { exact: true }).last()
|
const context = page.getByText("Use the existing value instead", { exact: true }).last()
|
||||||
await expect(context).toBeVisible()
|
await expect(context).toBeVisible()
|
||||||
await expect(context.locator("..")).toContainText("review.ts:2")
|
await expect(context.locator("..")).toContainText("review.ts:2")
|
||||||
expect(requests).toEqual([])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function openReview(page: Page) {
|
async function openReview(page: Page) {
|
||||||
@@ -144,15 +148,22 @@ async function openReview(page: Page) {
|
|||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff")
|
const changes = page.getByRole("tab", { name: "Changes" })
|
||||||
await page.getByRole("tab", { name: "Changes" }).click()
|
const diffResponse = page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.request().method() === "GET" && response.ok() && new URL(response.url()).pathname === "/api/vcs/diff",
|
||||||
|
)
|
||||||
|
await changes.click()
|
||||||
expect((await (await diffResponse).json()).data).toHaveLength(1)
|
expect((await (await diffResponse).json()).data).toHaveLength(1)
|
||||||
|
await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/)
|
||||||
|
|
||||||
const review = page.locator('[data-component="session-review"]')
|
const review = page.locator('[data-component="session-review"]')
|
||||||
await expectAppVisible(review)
|
await expectAppVisible(review)
|
||||||
await review
|
const file = review.locator('[data-file="src/review.ts"]')
|
||||||
.getByRole("heading", { name: /review\.ts/ })
|
await expectAppVisible(file)
|
||||||
.getByRole("button")
|
const trigger = file.getByRole("button", { expanded: false })
|
||||||
.first()
|
await expect(trigger).toHaveCount(1)
|
||||||
.click()
|
await trigger.click()
|
||||||
|
await expect(file.getByRole("button", { expanded: true })).toBeVisible()
|
||||||
|
await expect(file.getByText("export const value = 'after'", { exact: true })).toBeVisible()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"
|
|||||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||||
|
|
||||||
for (const profile of [
|
for (const profile of [
|
||||||
{ locale: "de", label: "Erkundet" },
|
{ locale: "de", label: "Erkundung abgeschlossen" },
|
||||||
{ locale: "ar", label: "تم الاستكشاف" },
|
{ locale: "ar", label: "تم الاستكشاف" },
|
||||||
] as const) {
|
] as const) {
|
||||||
test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
|
test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
import {
|
import {
|
||||||
assistantMessage,
|
assistantMessage,
|
||||||
partUpdated,
|
partUpdated,
|
||||||
setupTimeline,
|
setupTimeline,
|
||||||
status,
|
|
||||||
textPart,
|
textPart,
|
||||||
userMessage,
|
userMessage,
|
||||||
} from "../performance/timeline-stability/fixture"
|
} from "../performance/timeline-stability/fixture"
|
||||||
@@ -17,7 +16,7 @@ test("keeps one connection open while delivering multiple events", async ({ page
|
|||||||
await timeline.waitForPart("prt_transport_first")
|
await timeline.waitForPart("prt_transport_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 +50,28 @@ test("parses split JSON and a split multibyte code point", async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
|
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
|
||||||
|
const sentinelID = "prt_transport_heartbeat_sentinel"
|
||||||
const timeline = await setupTimeline(page, {
|
const timeline = await setupTimeline(page, {
|
||||||
messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])],
|
messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])],
|
||||||
})
|
})
|
||||||
const before = await page.locator("[data-timeline-row]").allTextContents()
|
await timeline.waitForPart("prt_transport_steady")
|
||||||
|
const before = await stableTimelineRows(page)
|
||||||
|
|
||||||
await timeline.transport.heartbeat()
|
await timeline.transport.writeRaw(": heartbeat\n\n")
|
||||||
await timeline.settle()
|
await timeline.transport.send(partUpdated(textPart(sentinelID, "heartbeat processed")))
|
||||||
|
await timeline.waitForPart(sentinelID)
|
||||||
|
|
||||||
expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before)
|
await expect
|
||||||
expect(await timeline.transport.connections()).toHaveLength(1)
|
.poll(async () => {
|
||||||
|
const rows = await timelineRows(page)
|
||||||
|
return rows.filter((row) => before.some((item) => item.key === row.key))
|
||||||
|
})
|
||||||
|
.toEqual(before)
|
||||||
|
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("reconnects after a clean close", async ({ page }) => {
|
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,20 +84,21 @@ 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")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||||
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
|
const timeline = await setupTimeline(page, { protocol: "v2" })
|
||||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||||
id: "timeline-event-7",
|
id: "timeline-event-7",
|
||||||
})
|
})
|
||||||
@@ -112,5 +120,35 @@ test("passes through non-event fetches", async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(health).toEqual({ healthy: true })
|
expect(health).toEqual({ healthy: true })
|
||||||
expect(await timeline.transport.connections()).toHaveLength(1)
|
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function stableTimelineRows(page: Page) {
|
||||||
|
let previous: Awaited<ReturnType<typeof timelineRows>> | undefined
|
||||||
|
let stable = 0
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const next = await timelineRows(page)
|
||||||
|
stable = JSON.stringify(next) === JSON.stringify(previous) ? stable + 1 : 0
|
||||||
|
previous = next
|
||||||
|
return stable
|
||||||
|
},
|
||||||
|
{ intervals: [50, 50, 100] },
|
||||||
|
)
|
||||||
|
.toBeGreaterThanOrEqual(2)
|
||||||
|
return previous!
|
||||||
|
}
|
||||||
|
|
||||||
|
function timelineRows(page: Page) {
|
||||||
|
return page.locator("[data-timeline-key]").evaluateAll((elements) =>
|
||||||
|
elements.map((element) => ({
|
||||||
|
key: element.getAttribute("data-timeline-key"),
|
||||||
|
row: element.querySelector("[data-timeline-row]")?.getAttribute("data-timeline-row"),
|
||||||
|
parts: Array.from(element.querySelectorAll("[data-timeline-part-id]"), (part) =>
|
||||||
|
part.getAttribute("data-timeline-part-id"),
|
||||||
|
),
|
||||||
|
text: element.textContent,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { currentSession } from "../utils/mock-server"
|
|||||||
const server = "http://127.0.0.1:4096"
|
const server = "http://127.0.0.1:4096"
|
||||||
const sessionA = session("ses_tab_a", "Tab A session")
|
const sessionA = session("ses_tab_a", "Tab A session")
|
||||||
const sessionB = session("ses_tab_b", "Tab B session")
|
const sessionB = session("ses_tab_b", "Tab B session")
|
||||||
|
const sessionC = session("ses_tab_c", "Tab C session")
|
||||||
|
const unresolvedSessionID = "ses_tab_unresolved"
|
||||||
|
|
||||||
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
||||||
await mockServer(page)
|
await mockServer(page)
|
||||||
@@ -40,6 +42,34 @@ test("pressing mouse down on a tab navigates before mouse up", async ({ page })
|
|||||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keyboard navigation follows the visible tab order", async ({ page }) => {
|
||||||
|
await mockServer(page)
|
||||||
|
await page.addInitScript(
|
||||||
|
({ server, sessionA, unresolved, sessionC }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify([
|
||||||
|
{ type: "session", server, sessionId: sessionA },
|
||||||
|
{ type: "session", server, sessionId: unresolved },
|
||||||
|
{ type: "session", server, sessionId: sessionC },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id },
|
||||||
|
)
|
||||||
|
|
||||||
|
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||||
|
const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}`
|
||||||
|
await page.goto(hrefA)
|
||||||
|
await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2)
|
||||||
|
await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press("Control+Alt+ArrowRight")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
|
})
|
||||||
|
|
||||||
function session(id: string, title: string) {
|
function session(id: string, title: string) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -53,10 +83,12 @@ function session(id: string, title: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function mockServer(page: Page) {
|
async function mockServer(page: Page) {
|
||||||
const sessions = [sessionA, sessionB]
|
const sessions = [sessionA, sessionB, sessionC]
|
||||||
await page.route("**/*", async (route) => {
|
await page.route("**/*", async (route) => {
|
||||||
const url = new URL(route.request().url())
|
const url = new URL(route.request().url())
|
||||||
if (url.origin !== server) return route.fallback()
|
if (url.origin !== server) return route.fallback()
|
||||||
|
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
||||||
|
return new Promise(() => {})
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||||
return sse(route)
|
return sse(route)
|
||||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||||
|
|||||||
@@ -247,18 +247,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,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.6",
|
"version": "1.18.18",
|
||||||
"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",
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import * as Sentry from "@sentry/solid"
|
|||||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
|
||||||
import { File } from "@opencode-ai/session-ui/file"
|
import { File } from "@opencode-ai/session-ui/file"
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
import { Splash } from "@opencode-ai/ui/logo"
|
import { Splash } from "@opencode-ai/ui/logo"
|
||||||
@@ -234,7 +233,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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LayoutCompatibility(props: ParentProps) {
|
function LayoutCompatibility(props: ParentProps) {
|
||||||
@@ -327,7 +332,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?.()
|
||||||
@@ -385,7 +390,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 />
|
||||||
@@ -394,7 +404,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) => {
|
||||||
@@ -405,9 +415,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>
|
||||||
|
|||||||
@@ -28,9 +28,8 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { useQueryClient } from "@tanstack/solid-query"
|
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { Link } from "@/components/link"
|
import { ExternalLink } from "@/components/external-link"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -38,7 +37,6 @@ import { useSettings } from "@/context/settings"
|
|||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { pathKey } from "@/utils/path-key"
|
|
||||||
|
|
||||||
const CUSTOM_ID = "_custom"
|
const CUSTOM_ID = "_custom"
|
||||||
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||||
@@ -386,7 +384,6 @@ function ProviderConnection(props: {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
@@ -512,7 +509,12 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,9 +709,8 @@ function ProviderConnection(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function complete() {
|
async function complete() {
|
||||||
const value = directory()
|
await serverSync()
|
||||||
await queryClient
|
.refreshProviders()
|
||||||
.refetchQueries(serverSync().queryOptions.providers(value ? pathKey(value) : null))
|
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
showToast({
|
showToast({
|
||||||
@@ -840,12 +841,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>
|
||||||
@@ -890,9 +891,9 @@ function ProviderConnection(props: {
|
|||||||
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
|
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.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>
|
||||||
@@ -971,9 +972,9 @@ function ProviderConnection(props: {
|
|||||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
<div 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={store.authorization!.url} class="text-v2-text-text-base">
|
||||||
{language.t("provider.connect.oauth.code.visit.link")}
|
{language.t("provider.connect.oauth.code.visit.link")}
|
||||||
</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">
|
||||||
@@ -1010,7 +1011,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={store.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">
|
||||||
@@ -1081,7 +1084,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.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={store.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
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
|||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { batch, For } from "solid-js"
|
import { batch, For } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { ExternalLink } from "@/components/external-link"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -131,7 +131,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
|||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
const saveMutation = useMutation(() => ({
|
||||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||||
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
|
if ((await serverSDK().protocol) !== "v1") throw new Error(language.t("provider.custom.unavailable"))
|
||||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
@@ -185,9 +185,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
|||||||
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
||||||
<p class="text-14-regular text-text-base">
|
<p class="text-14-regular text-text-base">
|
||||||
{language.t("provider.custom.description.prefix")}
|
{language.t("provider.custom.description.prefix")}
|
||||||
<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
|
||||||
@@ -57,9 +59,9 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
const [filter, setFilter] = createSignal("")
|
const [filter, setFilter] = createSignal("")
|
||||||
let list: ListRef | undefined
|
let list: ListRef | undefined
|
||||||
|
|
||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
const missingHome = createMemo(() => !sync.data.path.home)
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingBase() ? true : undefined),
|
() => (missingHome() ? true : undefined),
|
||||||
async (): Promise<Path | undefined> => {
|
async (): Promise<Path | undefined> => {
|
||||||
if ((await sdk.protocol) !== "v1") return
|
if ((await sdk.protocol) !== "v1") return
|
||||||
return sdk.client.path
|
return sdk.client.path
|
||||||
@@ -102,7 +104,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)
|
||||||
@@ -116,7 +117,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) {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={6}
|
gutter={6}
|
||||||
delay="intent"
|
openDelay={0}
|
||||||
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
||||||
value={
|
value={
|
||||||
<ModelTooltip
|
<ModelTooltip
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||||
import {
|
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
|
||||||
Component,
|
|
||||||
ComponentProps,
|
|
||||||
createEffect,
|
|
||||||
createMemo,
|
|
||||||
For,
|
|
||||||
JSX,
|
|
||||||
onCleanup,
|
|
||||||
Show,
|
|
||||||
ValidComponent,
|
|
||||||
} from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -29,6 +19,7 @@ import { ModelTooltip } from "./model-tooltip"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||||
|
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
|
||||||
import { createEventListener } from "@solid-primitives/event-listener"
|
import { createEventListener } from "@solid-primitives/event-listener"
|
||||||
import { matchesModelSearch } from "./dialog-select-model-search"
|
import { matchesModelSearch } from "./dialog-select-model-search"
|
||||||
|
|
||||||
@@ -122,14 +113,13 @@ const ModelList: Component<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
||||||
|
type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
|
||||||
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
||||||
|
|
||||||
export function ModelSelectorPopover(props: {
|
export function ModelSelectorPopover(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
model?: ModelState
|
model?: ModelState
|
||||||
children?: JSX.Element
|
trigger: ModelSelectorTrigger
|
||||||
triggerAs?: ValidComponent
|
|
||||||
triggerProps?: ModelSelectorTriggerProps
|
|
||||||
onClose?: (cause: "escape" | "select") => void
|
onClose?: (cause: "escape" | "select") => void
|
||||||
}) {
|
}) {
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
@@ -174,9 +164,7 @@ export function ModelSelectorPopover(props: {
|
|||||||
placement="top-start"
|
placement="top-start"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
>
|
>
|
||||||
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
<Kobalte.Trigger as={props.trigger} />
|
||||||
{props.children}
|
|
||||||
</Kobalte.Trigger>
|
|
||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
<Kobalte.Content
|
<Kobalte.Content
|
||||||
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||||
@@ -237,66 +225,101 @@ export function ModelSelectorPopover(props: {
|
|||||||
export function ModelSelectorPopoverV2(props: {
|
export function ModelSelectorPopoverV2(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
model?: ModelState
|
model?: ModelState
|
||||||
children?: JSX.Element
|
trigger: ModelSelectorTrigger
|
||||||
triggerAs?: ValidComponent
|
|
||||||
triggerProps?: ModelSelectorTriggerProps
|
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}) {
|
}) {
|
||||||
const model = props.model ?? useLocal().model
|
|
||||||
const language = useLanguage()
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
const controller = createModelSelectorController({
|
||||||
let searchRef: HTMLInputElement | undefined
|
model: props.model,
|
||||||
let contentRef: HTMLDivElement | undefined
|
provider: () => props.provider,
|
||||||
let restoreTrigger = true
|
onSelect: () => props.onClose?.(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModelSelectorPopoverV2View
|
||||||
|
trigger={props.trigger}
|
||||||
|
models={controller.models}
|
||||||
|
groups={controller.groups}
|
||||||
|
current={controller.current}
|
||||||
|
select={controller.select}
|
||||||
|
onManage={() => {
|
||||||
|
void import("./dialog-manage-models").then((module) => {
|
||||||
|
void dialog.show(() => <module.DialogManageModelsV2 />)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onClose={() => props.onClose?.()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createModelSelectorController(input: {
|
||||||
|
provider: () => string | undefined
|
||||||
|
model?: ModelState
|
||||||
|
onSelect: () => void
|
||||||
|
}) {
|
||||||
|
const model = input.model ?? useLocal().model
|
||||||
const allModels = createMemo(() =>
|
const allModels = createMemo(() =>
|
||||||
model
|
model
|
||||||
.list()
|
.list()
|
||||||
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
||||||
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
.filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
|
||||||
)
|
)
|
||||||
const models = createMemo(() => {
|
|
||||||
const search = store.search.trim()
|
|
||||||
const filtered = search
|
|
||||||
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
|
||||||
: allModels()
|
|
||||||
|
|
||||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
return {
|
||||||
})
|
models: (search: string) => {
|
||||||
const groups = createMemo(() => {
|
const query = search.trim()
|
||||||
const byProvider = new Map<string, ModelItem[]>()
|
const filtered = query
|
||||||
for (const item of models()) {
|
? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name]))
|
||||||
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
: allModels()
|
||||||
}
|
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
},
|
||||||
})
|
groups: (models: ModelItem[]) => {
|
||||||
const keys = () => [...models().map(modelKey), manageKey]
|
const byProvider = new Map<string, ModelItem[]>()
|
||||||
const current = () => {
|
for (const item of models) {
|
||||||
const value = model.current()
|
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
||||||
return value ? `${value.provider.id}:${value.id}` : undefined
|
}
|
||||||
|
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
||||||
|
},
|
||||||
|
current: () => {
|
||||||
|
const value = model.current()
|
||||||
|
return value ? modelKey(value) : undefined
|
||||||
|
},
|
||||||
|
select: (item: ModelItem) => {
|
||||||
|
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||||
|
input.onSelect()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelSelectorPopoverV2View(props: {
|
||||||
|
trigger: ModelSelectorTrigger
|
||||||
|
models: (search: string) => ModelItem[]
|
||||||
|
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
||||||
|
current: () => string | undefined
|
||||||
|
select: (item: ModelItem) => void
|
||||||
|
onManage: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||||
|
let searchRef: HTMLInputElement | undefined
|
||||||
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
const dismiss = createMenuDismissController(() => contentRef)
|
||||||
|
|
||||||
|
const models = createMemo(() => props.models(store.search))
|
||||||
|
const groups = createMemo(() => props.groups(models()))
|
||||||
|
const keys = () => [...models().map(modelKey), manageKey]
|
||||||
const initialActive = () => {
|
const initialActive = () => {
|
||||||
const selected = current()
|
const selected = props.current()
|
||||||
const options = keys()
|
const options = keys()
|
||||||
if (selected && options.includes(selected)) return selected
|
if (selected && options.includes(selected)) return selected
|
||||||
return options[0] ?? ""
|
return options[0] ?? ""
|
||||||
}
|
}
|
||||||
const activeItem = () =>
|
const activeItem = () =>
|
||||||
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
||||||
const afterClose = (callback: () => void) => {
|
|
||||||
const complete = () => {
|
|
||||||
if (contentRef?.isConnected) {
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(callback))
|
|
||||||
}
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
}
|
|
||||||
const setOpen = (open: boolean) => {
|
const setOpen = (open: boolean) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
restoreTrigger = true
|
dismiss.allowTriggerRestore()
|
||||||
setStore({ open: true, active: initialActive() })
|
setStore({ open: true, active: initialActive() })
|
||||||
setTimeout(() =>
|
setTimeout(() =>
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -308,23 +331,15 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
}
|
}
|
||||||
setStore({ open: false, search: "", active: "" })
|
setStore({ open: false, search: "", active: "" })
|
||||||
}
|
}
|
||||||
const select = (item: ModelItem) => {
|
|
||||||
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
|
||||||
props.onClose?.()
|
|
||||||
}
|
|
||||||
const selectModel = (item: ModelItem) => {
|
const selectModel = (item: ModelItem) => {
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
afterClose(() => select(item))
|
dismiss.afterClose(() => props.select(item))
|
||||||
}
|
}
|
||||||
const manage = () => {
|
const manage = () => {
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
afterClose(() => {
|
dismiss.afterClose(props.onManage)
|
||||||
void import("./dialog-manage-models").then((x) => {
|
|
||||||
dialog.show(() => <x.DialogManageModelsV2 />)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
const selectActive = () => {
|
const selectActive = () => {
|
||||||
const item = models().find((item) => modelKey(item) === store.active)
|
const item = models().find((item) => modelKey(item) === store.active)
|
||||||
@@ -343,10 +358,7 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||||
}
|
}
|
||||||
const setSearch = (value: string) => {
|
const setSearch = (value: string) => {
|
||||||
const search = value.trim()
|
const first = props.models(value)[0]
|
||||||
const first = [...allModels()]
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
|
||||||
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,18 +374,14 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
||||||
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
<MenuV2.Trigger as={props.trigger} />
|
||||||
{props.children}
|
|
||||||
</MenuV2.Trigger>
|
|
||||||
<MenuV2.Portal>
|
<MenuV2.Portal>
|
||||||
<MenuV2.Content
|
<MenuV2.Content
|
||||||
ref={(el: HTMLDivElement) => (contentRef = el)}
|
ref={(element: HTMLDivElement) => (contentRef = element)}
|
||||||
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||||
onPointerDownOutside={() => (restoreTrigger = false)}
|
onPointerDownOutside={dismiss.preventTriggerRestore}
|
||||||
onFocusOutside={() => (restoreTrigger = false)}
|
onFocusOutside={dismiss.preventTriggerRestore}
|
||||||
onCloseAutoFocus={(event) => {
|
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
||||||
if (!restoreTrigger) event.preventDefault()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col p-0.5">
|
<div class="flex flex-col p-0.5">
|
||||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||||
@@ -393,9 +401,9 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
afterClose(() => props.onClose?.())
|
dismiss.afterClose(props.onClose)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.altKey || event.metaKey) return
|
if (event.altKey || event.metaKey) return
|
||||||
@@ -445,14 +453,14 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
<MenuV2.GroupLabel class="gap-2 px-3">
|
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||||
</MenuV2.GroupLabel>
|
</MenuV2.GroupLabel>
|
||||||
<MenuV2.RadioGroup value={current()}>
|
<MenuV2.RadioGroup value={props.current()}>
|
||||||
<For each={group.items}>
|
<For each={group.items}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<TooltipV2
|
<TooltipV2
|
||||||
class="w-full"
|
class="w-full"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={6}
|
gutter={6}
|
||||||
delay="intent"
|
openDelay={0}
|
||||||
value={
|
value={
|
||||||
<ModelTooltip
|
<ModelTooltip
|
||||||
model={item}
|
model={item}
|
||||||
@@ -465,7 +473,7 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
<MenuV2.RadioItem
|
<MenuV2.RadioItem
|
||||||
value={modelKey(item)}
|
value={modelKey(item)}
|
||||||
data-option-key={modelKey(item)}
|
data-option-key={modelKey(item)}
|
||||||
data-selected-model={current() === modelKey(item) ? true : undefined}
|
data-selected-model={props.current() === modelKey(item) ? true : undefined}
|
||||||
class="scroll-my-6 w-full"
|
class="scroll-my-6 w-full"
|
||||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
|
|||||||
@@ -1,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,9 +1,11 @@
|
|||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
|
import { isRTL } from "@kobalte/core/i18n"
|
||||||
import { createSignal, Show } from "solid-js"
|
import { createSignal, Show } from "solid-js"
|
||||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
||||||
import homeImage from "@/assets/help/home.png"
|
import homeImage from "@/assets/help/home.png"
|
||||||
import tabsImage from "@/assets/help/tabs.png"
|
import tabsImage from "@/assets/help/tabs.png"
|
||||||
@@ -15,20 +17,22 @@ const showPopover = () => true
|
|||||||
export function TabsInfoPopup() {
|
export function TabsInfoPopup() {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const language = useLanguage()
|
||||||
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
||||||
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
||||||
|
const rtl = () => isRTL(language.intl())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side={rtl() ? "left" : "right"}>
|
||||||
<Show when={settings.general.shouldDisplayTabsToast()}>
|
<Show when={settings.general.shouldDisplayTabsToast()}>
|
||||||
<div
|
<div
|
||||||
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
class="fixed bottom-5 end-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
||||||
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
|
aria-label={language.t("help.tabs.toast.ariaLabel")}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Dismiss Tabs information"
|
aria-label={language.t("help.tabs.toast.dismiss")}
|
||||||
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
class="absolute top-3 end-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
||||||
onClick={settings.general.dismissTabsToast}
|
onClick={settings.general.dismissTabsToast}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -44,7 +48,7 @@ export function TabsInfoPopup() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
settings.general.dismissTabsToast()
|
settings.general.dismissTabsToast()
|
||||||
setDrawerOpen(true)
|
setDrawerOpen(true)
|
||||||
@@ -62,10 +66,10 @@ export function TabsInfoPopup() {
|
|||||||
/>
|
/>
|
||||||
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
|
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
|
||||||
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
|
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
|
||||||
Introducing Tabs
|
{language.t("help.tabs.title")}
|
||||||
</p>
|
</p>
|
||||||
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
||||||
Organize your work and active sessions with tabs
|
{language.t("help.tabs.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -75,7 +79,10 @@ export function TabsInfoPopup() {
|
|||||||
style={
|
style={
|
||||||
windows()
|
windows()
|
||||||
? {
|
? {
|
||||||
inset: "0 0 0 auto",
|
top: "0",
|
||||||
|
bottom: "0",
|
||||||
|
"inset-inline-end": "0",
|
||||||
|
"inset-inline-start": "auto",
|
||||||
"max-height": "100vh",
|
"max-height": "100vh",
|
||||||
"max-width": "100vw",
|
"max-width": "100vw",
|
||||||
"border-radius": "0",
|
"border-radius": "0",
|
||||||
@@ -89,9 +96,9 @@ export function TabsInfoPopup() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="small"
|
size="small"
|
||||||
variant="neutral"
|
variant="neutral"
|
||||||
aria-label="Close"
|
aria-label={language.t("common.close")}
|
||||||
icon={<IconV2 name="xmark-small" />}
|
icon={<IconV2 name="xmark-small" />}
|
||||||
class="absolute top-[10px] left-[-36px]"
|
class="absolute top-[10px] start-[-36px]"
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<div
|
<div
|
||||||
@@ -102,7 +109,7 @@ export function TabsInfoPopup() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
||||||
July 14
|
{language.t("help.tabs.date")}
|
||||||
</p>
|
</p>
|
||||||
<Show when={!windows()}>
|
<Show when={!windows()}>
|
||||||
<DrawerClose
|
<DrawerClose
|
||||||
@@ -110,36 +117,24 @@ export function TabsInfoPopup() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="small"
|
size="small"
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
aria-label="Close"
|
aria-label={language.t("common.close")}
|
||||||
icon={<IconV2 name="xmark-small" />}
|
icon={<IconV2 name="xmark-small" />}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
|
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
|
||||||
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
||||||
Introducing Tabs
|
{language.t("help.tabs.title")}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||||
<p>OpenCode Desktop is now built around tabs.</p>
|
<p>{language.t("help.tabs.introduction")}</p>
|
||||||
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||||
<p>
|
<p>{language.t("help.tabs.sessions")}</p>
|
||||||
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
|
<p>{language.t("help.tabs.organize")}</p>
|
||||||
you're starting something new, and close it when you're done.
|
<p>{language.t("help.tabs.home")}</p>
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
|
|
||||||
memorable if you plan to keep them around.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
|
|
||||||
</p>
|
|
||||||
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||||
<p>When you reopen the app, your tabs are still open.</p>
|
<p>{language.t("help.tabs.persistence")}</p>
|
||||||
<p>
|
<p>{language.t("help.tabs.worktrees")}</p>
|
||||||
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
|
|
||||||
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
|
|
||||||
will become permanent in a few weeks.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
|||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import type { Prompt, ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createEffect, createMemo, on, Show } from "solid-js"
|
import { createEffect, createMemo, on, Show } from "solid-js"
|
||||||
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||||
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||||
@@ -37,11 +37,9 @@ export type PromptInputV2ComposerProps = {
|
|||||||
class?: string
|
class?: string
|
||||||
controller: PromptInputV2ComposerController
|
controller: PromptInputV2ComposerController
|
||||||
borderUnderlay?: boolean
|
borderUnderlay?: boolean
|
||||||
edit?: PromptInputProps["edit"]
|
|
||||||
onEditLoaded?: PromptInputProps["onEditLoaded"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
|
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
||||||
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
|
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
|
||||||
readonly model: PromptInputProps["controls"]["model"]
|
readonly model: PromptInputProps["controls"]["model"]
|
||||||
}
|
}
|
||||||
@@ -51,15 +49,13 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
|||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
useCommands(props)
|
|
||||||
useEditHandler(props)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<PromptInputV2
|
<PromptInputV2
|
||||||
controller={props.controller}
|
controller={props.controller}
|
||||||
borderUnderlay={props.borderUnderlay}
|
borderUnderlay={props.borderUnderlay}
|
||||||
class={props.class}
|
class={props.class}
|
||||||
|
variantControlVisible={!props.controller.model.loading}
|
||||||
attachKeybind={command.keybindParts("file.attach")}
|
attachKeybind={command.keybindParts("file.attach")}
|
||||||
attachShortcut={command.keybind("file.attach")}
|
attachShortcut={command.keybind("file.attach")}
|
||||||
modelControl={
|
modelControl={
|
||||||
@@ -82,70 +78,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const useEditHandler = (props: PromptInputV2ComposerProps) => {
|
|
||||||
const prompt = usePrompt()
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => props.edit?.id,
|
|
||||||
(id) => {
|
|
||||||
const edit = props.edit
|
|
||||||
if (!id || !edit) return
|
|
||||||
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
|
|
||||||
edit.context.forEach((item) =>
|
|
||||||
prompt.context.add({
|
|
||||||
type: item.type,
|
|
||||||
path: item.path,
|
|
||||||
selection: item.selection,
|
|
||||||
comment: item.comment,
|
|
||||||
commentID: item.commentID,
|
|
||||||
commentOrigin: item.commentOrigin,
|
|
||||||
preview: item.preview,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
props.controller.dispatch({ type: "mode.normal" })
|
|
||||||
props.controller.resetHistory()
|
|
||||||
prompt.set(edit.prompt, promptLength(edit.prompt))
|
|
||||||
props.controller.restoreFocus()
|
|
||||||
props.onEditLoaded?.()
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const useCommands = (props: PromptInputV2ComposerProps) => {
|
|
||||||
const command = useCommand()
|
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
command.register("prompt-input", () => [
|
|
||||||
{
|
|
||||||
id: "file.attach",
|
|
||||||
title: language.t("prompt.action.attachFile"),
|
|
||||||
category: language.t("command.category.file"),
|
|
||||||
keybind: "mod+u",
|
|
||||||
disabled: props.controller.state.mode !== "normal",
|
|
||||||
onSelect: () => props.controller.attach(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "prompt.mode.shell",
|
|
||||||
title: language.t("command.prompt.mode.shell"),
|
|
||||||
category: language.t("command.category.session"),
|
|
||||||
keybind: "mod+shift+x",
|
|
||||||
disabled: props.controller.state.mode === "shell",
|
|
||||||
onSelect: () => props.controller.dispatch({ type: "mode.shell" }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "prompt.mode.normal",
|
|
||||||
title: language.t("command.prompt.mode.normal"),
|
|
||||||
category: language.t("command.category.session"),
|
|
||||||
keybind: "mod+shift+e",
|
|
||||||
disabled: props.controller.state.mode === "normal",
|
|
||||||
onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
@@ -205,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))
|
||||||
@@ -412,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)
|
||||||
@@ -436,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",
|
||||||
@@ -444,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,
|
||||||
@@ -472,6 +409,62 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
Object.defineProperty(controller, "model", { get: () => props.controls.model })
|
Object.defineProperty(controller, "model", { get: () => props.controls.model })
|
||||||
|
|
||||||
|
command.register("prompt-input", () => [
|
||||||
|
{
|
||||||
|
id: "file.attach",
|
||||||
|
title: language.t("prompt.action.attachFile"),
|
||||||
|
category: language.t("command.category.file"),
|
||||||
|
keybind: "mod+u",
|
||||||
|
disabled: controller.state.mode !== "normal",
|
||||||
|
onSelect: () => controller.attach(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.shell",
|
||||||
|
title: language.t("command.prompt.mode.shell"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: "mod+shift+x",
|
||||||
|
disabled: controller.state.mode === "shell",
|
||||||
|
onSelect: () => controller.dispatch({ type: "mode.shell" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.normal",
|
||||||
|
title: language.t("command.prompt.mode.normal"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: "mod+shift+e",
|
||||||
|
disabled: controller.state.mode === "normal",
|
||||||
|
onSelect: () => controller.dispatch({ type: "mode.normal" }),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => props.edit?.id,
|
||||||
|
(id) => {
|
||||||
|
const edit = props.edit
|
||||||
|
if (!id || !edit) return
|
||||||
|
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
|
||||||
|
edit.context.forEach((item) =>
|
||||||
|
prompt.context.add({
|
||||||
|
type: item.type,
|
||||||
|
path: item.path,
|
||||||
|
selection: item.selection,
|
||||||
|
comment: item.comment,
|
||||||
|
commentID: item.commentID,
|
||||||
|
commentOrigin: item.commentOrigin,
|
||||||
|
preview: item.preview,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
controller.dispatch({ type: "mode.normal" })
|
||||||
|
controller.resetHistory()
|
||||||
|
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||||
|
controller.restoreFocus()
|
||||||
|
props.onEditLoaded?.()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return controller as PromptInputV2ComposerController
|
return controller as PromptInputV2ComposerController
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,20 +528,22 @@ function PromptInputV2ModelControl(props: {
|
|||||||
>
|
>
|
||||||
<ModelSelectorPopoverV2
|
<ModelSelectorPopoverV2
|
||||||
model={props.model}
|
model={props.model}
|
||||||
triggerAs={ButtonV2}
|
trigger={(triggerProps) => (
|
||||||
triggerProps={{
|
<ButtonV2
|
||||||
variant: "ghost-muted",
|
{...triggerProps}
|
||||||
size: "normal",
|
variant="ghost-muted"
|
||||||
style: { height: "28px" },
|
size="normal"
|
||||||
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
|
style={{ height: "28px" }}
|
||||||
classList: { "animate-in fade-in": shouldAnimate() },
|
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||||
"data-action": "prompt-model",
|
classList={{ "animate-in fade-in": shouldAnimate() }}
|
||||||
"data-control-type": "popover",
|
data-action="prompt-model"
|
||||||
}}
|
data-control-type="popover"
|
||||||
|
>
|
||||||
|
{content()}
|
||||||
|
</ButtonV2>
|
||||||
|
)}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
>
|
/>
|
||||||
{content()}
|
|
||||||
</ModelSelectorPopoverV2>
|
|
||||||
</Show>
|
</Show>
|
||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1723,29 +1724,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<ModelSelectorPopover
|
<ModelSelectorPopover
|
||||||
model={props.controls.model.selection}
|
model={props.controls.model.selection}
|
||||||
triggerAs={Button}
|
trigger={(triggerProps) => (
|
||||||
triggerProps={{
|
<Button
|
||||||
variant: "ghost",
|
{...triggerProps}
|
||||||
size: "normal",
|
variant="ghost"
|
||||||
style: control(),
|
size="normal"
|
||||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
style={control()}
|
||||||
"data-action": "prompt-model",
|
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||||
}}
|
data-action="prompt-model"
|
||||||
|
>
|
||||||
|
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||||
|
<ProviderIcon
|
||||||
|
id={props.controls.model.selection.current()?.provider?.id ?? ""}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<span class="truncate">
|
||||||
|
{props.controls.model.selection.current()?.name ??
|
||||||
|
language.t("dialog.model.select.title")}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
onClose={restoreFocus}
|
onClose={restoreFocus}
|
||||||
>
|
/>
|
||||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
|
||||||
<ProviderIcon
|
|
||||||
id={props.controls.model.selection.current()?.provider?.id ?? ""}
|
|
||||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
|
||||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<span class="truncate">
|
|
||||||
{props.controls.model.selection.current()?.name ??
|
|
||||||
language.t("dialog.model.select.title")}
|
|
||||||
</span>
|
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
|
||||||
</ModelSelectorPopover>
|
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,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({
|
||||||
|
|||||||
@@ -22,7 +22,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
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
placement="top"
|
placement="top"
|
||||||
{...(!props.newLayoutDesigns ? { openDelay: 800 } : {})}
|
openDelay={800}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -52,7 +53,12 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
|||||||
<For each={props.comments ?? []}>
|
<For each={props.comments ?? []}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<div class="relative group shrink-0">
|
<div class="relative group shrink-0">
|
||||||
<TooltipV2 value={item.comment} placement="top" contentClass="max-w-[300px] break-words">
|
<TooltipV2
|
||||||
|
value={item.comment}
|
||||||
|
placement="top"
|
||||||
|
openDelay={800}
|
||||||
|
contentClass="max-w-[300px] break-words"
|
||||||
|
>
|
||||||
<CommentCardV2
|
<CommentCardV2
|
||||||
comment={item.comment ?? ""}
|
comment={item.comment ?? ""}
|
||||||
path={item.path}
|
path={item.path}
|
||||||
@@ -89,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: "@" })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { ScopedKey } from "@/utils/server-scope"
|
|||||||
import { createPromptSubmissionState } from "./submission-state"
|
import { createPromptSubmissionState } from "./submission-state"
|
||||||
import { normalizeSessionInfo } from "@/utils/session"
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
|
import { blobDataUrl } from "@/utils/draft-store"
|
||||||
|
|
||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
@@ -95,10 +96,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) {
|
||||||
@@ -108,10 +111,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,
|
||||||
@@ -516,10 +525,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" })
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||||
|
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
|
||||||
|
|
||||||
export type PromptProject = {
|
export type PromptProject = {
|
||||||
name?: string
|
name?: string
|
||||||
@@ -197,8 +198,8 @@ export function PromptProjectSelector(props: {
|
|||||||
}) {
|
}) {
|
||||||
const [triggerReady, setTriggerReady] = createSignal(false)
|
const [triggerReady, setTriggerReady] = createSignal(false)
|
||||||
let contentRef: HTMLDivElement | undefined
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
const dismiss = createMenuDismissController(() => contentRef)
|
||||||
let triggerFrame: number | undefined
|
let triggerFrame: number | undefined
|
||||||
let restoreTrigger = true
|
|
||||||
|
|
||||||
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
|
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
|
||||||
const setTriggerRef = (element: HTMLButtonElement) => {
|
const setTriggerRef = (element: HTMLButtonElement) => {
|
||||||
@@ -221,25 +222,15 @@ export function PromptProjectSelector(props: {
|
|||||||
props.controller.active()
|
props.controller.active()
|
||||||
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
||||||
: undefined
|
: undefined
|
||||||
const afterClose = (callback: () => void) => {
|
|
||||||
const complete = () => {
|
|
||||||
if (contentRef?.isConnected) {
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(callback))
|
|
||||||
}
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
}
|
|
||||||
const selectProject = (project: PromptProject) => {
|
const selectProject = (project: PromptProject) => {
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
props.controller.setOpen(false)
|
props.controller.setOpen(false)
|
||||||
afterClose(() => props.controller.select(project))
|
dismiss.afterClose(() => props.controller.select(project))
|
||||||
}
|
}
|
||||||
const selectAction = (server?: string) => {
|
const selectAction = (server?: string) => {
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
props.controller.setOpen(false)
|
props.controller.setOpen(false)
|
||||||
afterClose(() => props.controller.add(server))
|
dismiss.afterClose(() => props.controller.add(server))
|
||||||
}
|
}
|
||||||
const selectActive = () => {
|
const selectActive = () => {
|
||||||
const project = props.controller.activeProject()
|
const project = props.controller.activeProject()
|
||||||
@@ -267,7 +258,7 @@ export function PromptProjectSelector(props: {
|
|||||||
)
|
)
|
||||||
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
|
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
|
||||||
.findLast((element) => element.offsetParent !== null)
|
.findLast((element) => element.offsetParent !== null)
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
target?.focus()
|
target?.focus()
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (props.controller.open()) props.controller.setOpen(false)
|
if (props.controller.open()) props.controller.setOpen(false)
|
||||||
@@ -291,7 +282,10 @@ export function PromptProjectSelector(props: {
|
|||||||
placement={props.placement ?? "bottom"}
|
placement={props.placement ?? "bottom"}
|
||||||
gutter={4}
|
gutter={4}
|
||||||
modal={false}
|
modal={false}
|
||||||
onOpenChange={(open) => props.controller.setOpen(open)}
|
onOpenChange={(open) => {
|
||||||
|
if (open) dismiss.allowTriggerRestore()
|
||||||
|
props.controller.setOpen(open)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
|
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
@@ -300,11 +294,9 @@ export function PromptProjectSelector(props: {
|
|||||||
id="prompt-project-menu"
|
id="prompt-project-menu"
|
||||||
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
|
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
onPointerDownOutside={() => (restoreTrigger = false)}
|
onPointerDownOutside={dismiss.preventTriggerRestore}
|
||||||
onFocusOutside={() => (restoreTrigger = false)}
|
onFocusOutside={dismiss.preventTriggerRestore}
|
||||||
onCloseAutoFocus={(event) => {
|
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
||||||
if (!restoreTrigger) event.preventDefault()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col p-0.5">
|
<div class="flex flex-col p-0.5">
|
||||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||||
|
|||||||
@@ -4,4 +4,3 @@ export { SortableTab, FileVisual } from "./session-sortable-tab"
|
|||||||
export { SortableTabV2 } from "./session-sortable-tab-v2"
|
export { SortableTabV2 } from "./session-sortable-tab-v2"
|
||||||
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
|
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
|
||||||
export { NewSessionView } from "./session-new-view"
|
export { NewSessionView } from "./session-new-view"
|
||||||
export { NewSessionDesignView } from "./session-new-design-view"
|
|
||||||
|
|||||||
@@ -5,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 "@opencode-ai/sdk/v2/client"
|
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
@@ -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,
|
||||||
|
client: sdk().client,
|
||||||
|
})
|
||||||
|
const filename = sessionExportFilename(data.info)
|
||||||
|
downloadSessionExport(filename, data)
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("toast.session.export.success.title"),
|
||||||
|
description: language.t("toast.session.export.success.description", { filename }),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("toast.session.export.failed.title"),
|
||||||
|
description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let scroll: HTMLDivElement | undefined
|
let scroll: HTMLDivElement | undefined
|
||||||
let frame: number | undefined
|
let frame: number | undefined
|
||||||
let pending: { x: number; y: number } | undefined
|
let pending: { x: number; y: number } | undefined
|
||||||
@@ -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={
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { JSX } from "solid-js"
|
|
||||||
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
|
|
||||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
|
||||||
|
|
||||||
export function NewSessionDesignView(props: { children: JSX.Element }) {
|
|
||||||
return (
|
|
||||||
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
|
|
||||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
|
||||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
|
||||||
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
|
|
||||||
<div class="mt-8">{props.children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
} from "@/context/settings"
|
} from "@/context/settings"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||||
import { Link } from "./link"
|
import { ExternalLink } from "./external-link"
|
||||||
import { SettingsList } from "./settings-list"
|
import { SettingsList } from "./settings-list"
|
||||||
|
|
||||||
let demoSoundState = {
|
let demoSoundState = {
|
||||||
@@ -482,7 +482,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
{language.t("settings.general.row.theme.description")}{" "}
|
{language.t("settings.general.row.theme.description")}{" "}
|
||||||
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
|
<ExternalLink href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</ExternalLink>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ type KeybindMeta = {
|
|||||||
|
|
||||||
type KeybindMap = Record<string, string | undefined>
|
type KeybindMap = Record<string, string | undefined>
|
||||||
type CommandContext = ReturnType<typeof useCommand>
|
type CommandContext = ReturnType<typeof useCommand>
|
||||||
|
type LanguageContext = ReturnType<typeof useLanguage>
|
||||||
|
type SettingsContext = ReturnType<typeof useSettings>
|
||||||
|
|
||||||
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
|
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
|
||||||
|
|
||||||
@@ -122,7 +124,7 @@ function keybinds(value: unknown): KeybindMap {
|
|||||||
return value as KeybindMap
|
return value as KeybindMap
|
||||||
}
|
}
|
||||||
|
|
||||||
function listFor(command: CommandContext, map: KeybindMap, palette: string) {
|
function listFor(command: Pick<CommandContext, "catalog" | "options">, map: KeybindMap, palette: string) {
|
||||||
const out = new Map<string, KeybindMeta>()
|
const out = new Map<string, KeybindMeta>()
|
||||||
out.set(PALETTE_ID, { title: palette, group: "General" })
|
out.set(PALETTE_ID, { title: palette, group: "General" })
|
||||||
|
|
||||||
@@ -262,7 +264,274 @@ function useKeyCapture(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createKeybindSettingsController(
|
||||||
|
input: {
|
||||||
|
command: Pick<CommandContext, "catalog" | "options" | "keybinds">
|
||||||
|
settings: {
|
||||||
|
current: { keybinds: unknown }
|
||||||
|
keybinds: Pick<SettingsContext["keybinds"], "get" | "set" | "resetAll">
|
||||||
|
}
|
||||||
|
target?: Document
|
||||||
|
notify?: (toast: { title: string; description: string }) => void
|
||||||
|
},
|
||||||
|
language: Pick<LanguageContext, "locale" | "t"> = useLanguage(),
|
||||||
|
) {
|
||||||
|
const [store, setStore] = createStore({ active: null as string | null })
|
||||||
|
const overrides = createMemo(() => keybinds(input.settings.current.keybinds))
|
||||||
|
const list = createMemo(() => {
|
||||||
|
language.locale()
|
||||||
|
return listFor(input.command, overrides(), language.t("command.palette"))
|
||||||
|
})
|
||||||
|
const grouped = createMemo(() => groupedFor(list()))
|
||||||
|
const title = (id: string) => list().get(id)?.title ?? ""
|
||||||
|
const effective = (id: string) => {
|
||||||
|
if (id === PALETTE_ID) return input.settings.keybinds.get(id) ?? DEFAULT_PALETTE_KEYBIND
|
||||||
|
|
||||||
|
const custom = input.settings.keybinds.get(id)
|
||||||
|
if (typeof custom === "string") return custom
|
||||||
|
|
||||||
|
const live = input.command.options.find((item) => item.id === id)
|
||||||
|
if (live?.keybind) return live.keybind
|
||||||
|
return input.command.catalog.find((item) => item.id === id)?.keybind
|
||||||
|
}
|
||||||
|
const used = createMemo(() => {
|
||||||
|
const value = new Map<string, { id: string; title: string }[]>()
|
||||||
|
|
||||||
|
for (const id of list().keys()) {
|
||||||
|
for (const signature of signatures(effective(id))) {
|
||||||
|
const items = value.get(signature)
|
||||||
|
if (items) {
|
||||||
|
items.push({ id, title: title(id) })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value.set(signature, [{ id, title: title(id) }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
const stop = () => {
|
||||||
|
if (!store.active) return
|
||||||
|
setStore("active", null)
|
||||||
|
input.command.keybinds(true)
|
||||||
|
}
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
if (store.active === id) {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (store.active) stop()
|
||||||
|
setStore("active", id)
|
||||||
|
input.command.keybinds(false)
|
||||||
|
}
|
||||||
|
const notify = input.notify ?? ((toast: { title: string; description: string }) => showToast(toast))
|
||||||
|
|
||||||
|
const handle = (event: KeyboardEvent) => {
|
||||||
|
const id = store.active
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear =
|
||||||
|
(event.key === "Backspace" || event.key === "Delete") &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
!event.metaKey &&
|
||||||
|
!event.altKey &&
|
||||||
|
!event.shiftKey
|
||||||
|
if (clear) {
|
||||||
|
input.settings.keybinds.set(id, "none")
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = recordKeybind(event)
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
const conflicts = new Map<string, string>()
|
||||||
|
for (const signature of signatures(next)) {
|
||||||
|
for (const item of used().get(signature) ?? []) {
|
||||||
|
if (item.id === id) continue
|
||||||
|
conflicts.set(item.id, item.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conflicts.size > 0) {
|
||||||
|
notify({
|
||||||
|
title: language.t("settings.shortcuts.conflict.title"),
|
||||||
|
description: language.t("settings.shortcuts.conflict.description", {
|
||||||
|
keybind: formatKeybind(next, language.t),
|
||||||
|
titles: [...conflicts.values()].join(", "),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input.settings.keybinds.set(id, next)
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = input.target ?? (typeof document === "object" ? document : undefined)
|
||||||
|
if (target) makeEventListener(target, "keydown", handle, { capture: true })
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (store.active) input.command.keybinds(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
catalog: {
|
||||||
|
groups: GROUPS,
|
||||||
|
filtered: (query: string) =>
|
||||||
|
filteredFor(query, list(), grouped(), (id) => formatKeybind(effective(id) ?? "", language.t)),
|
||||||
|
title,
|
||||||
|
keybind: (id: string) => formatKeybind(effective(id) ?? "", language.t),
|
||||||
|
},
|
||||||
|
capture: {
|
||||||
|
active: () => store.active,
|
||||||
|
toggle,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
hasOverrides: () => Object.values(overrides()).some((value) => typeof value === "string"),
|
||||||
|
reset: () => {
|
||||||
|
stop()
|
||||||
|
input.settings.keybinds.resetAll()
|
||||||
|
notify({
|
||||||
|
title: language.t("settings.shortcuts.reset.toast.title"),
|
||||||
|
description: language.t("settings.shortcuts.reset.toast.description"),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsKeybindsV2() {
|
||||||
|
const command = useCommand()
|
||||||
|
const settings = useSettings()
|
||||||
|
const controller = createKeybindSettingsController({
|
||||||
|
command,
|
||||||
|
settings,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsKeybindsV2View
|
||||||
|
groups={controller.catalog.groups}
|
||||||
|
filtered={controller.catalog.filtered}
|
||||||
|
title={controller.catalog.title}
|
||||||
|
keybind={controller.catalog.keybind}
|
||||||
|
active={controller.capture.active}
|
||||||
|
onCapture={controller.capture.toggle}
|
||||||
|
hasOverrides={controller.settings.hasOverrides}
|
||||||
|
onReset={controller.settings.reset}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsKeybindsV2View(props: {
|
||||||
|
groups: KeybindGroup[]
|
||||||
|
filtered: (query: string) => Map<KeybindGroup, string[]>
|
||||||
|
title: (id: string) => string
|
||||||
|
keybind: (id: string) => string
|
||||||
|
active: () => string | null
|
||||||
|
onCapture: (id: string) => void
|
||||||
|
hasOverrides: () => boolean
|
||||||
|
onReset: () => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const [store, setStore] = createStore({ filter: "" })
|
||||||
|
const filtered = createMemo(() => props.filtered(store.filter))
|
||||||
|
const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||||
|
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
|
||||||
|
{language.t("settings.shortcuts.reset.button")}
|
||||||
|
</ButtonV2>
|
||||||
|
</div>
|
||||||
|
<div class="settings-v2-tab-search">
|
||||||
|
<TextInputV2
|
||||||
|
type="search"
|
||||||
|
appearance="base"
|
||||||
|
value={store.filter}
|
||||||
|
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
||||||
|
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
aria-label={language.t("settings.shortcuts.search.placeholder")}
|
||||||
|
/>
|
||||||
|
<Show when={store.filter}>
|
||||||
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
class="settings-v2-tab-search-clear"
|
||||||
|
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||||
|
onClick={() => setStore("filter", "")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-v2-tab-body">
|
||||||
|
<div class="settings-v2-shortcuts flex flex-col gap-8">
|
||||||
|
<For each={props.groups}>
|
||||||
|
{(group) => (
|
||||||
|
<Show when={(filtered().get(group) ?? []).length > 0}>
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<h3 class="settings-v2-section-title">{language.t(groupKey[group])}</h3>
|
||||||
|
<SettingsListV2>
|
||||||
|
<For each={filtered().get(group) ?? []}>
|
||||||
|
{(id) => (
|
||||||
|
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||||
|
<span>{props.title(id)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-keybind-id={id}
|
||||||
|
classList={{
|
||||||
|
"settings-v2-keybind-button": true,
|
||||||
|
"settings-v2-keybind-button--active": props.active() === id,
|
||||||
|
}}
|
||||||
|
onClick={() => props.onCapture(id)}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={props.active() === id}
|
||||||
|
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
||||||
|
>
|
||||||
|
{language.t("settings.shortcuts.pressKeys")}
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={store.filter && !hasResults()}>
|
||||||
|
<div class="settings-v2-shortcuts-status">
|
||||||
|
<span>{language.t("settings.shortcuts.search.empty")}</span>
|
||||||
|
<span class="settings-v2-shortcuts-status-filter">"{store.filter}"</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||||
|
if (props.v2) return <SettingsKeybindsV2 />
|
||||||
|
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
@@ -476,78 +745,37 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
when={props.v2}
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
fallback={
|
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
||||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
|
||||||
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
|
||||||
{language.t("settings.shortcuts.reset.button")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
|
||||||
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
|
||||||
<TextField
|
|
||||||
variant="ghost"
|
|
||||||
type="text"
|
|
||||||
value={store.filter}
|
|
||||||
onChange={(v) => setStore("filter", v)}
|
|
||||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
|
||||||
<Show when={store.filter}>
|
|
||||||
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{groups}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<>
|
|
||||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
|
||||||
<div class="settings-v2-tab-header-row">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
|
||||||
<ButtonV2 variant="ghost" onClick={resetAll} disabled={!hasOverrides()}>
|
|
||||||
{language.t("settings.shortcuts.reset.button")}
|
{language.t("settings.shortcuts.reset.button")}
|
||||||
</ButtonV2>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-v2-tab-search">
|
|
||||||
<TextInputV2
|
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
||||||
type="search"
|
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
||||||
appearance="base"
|
<TextField
|
||||||
|
variant="ghost"
|
||||||
|
type="text"
|
||||||
value={store.filter}
|
value={store.filter}
|
||||||
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
onChange={(v) => setStore("filter", v)}
|
||||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
aria-label={language.t("settings.shortcuts.search.placeholder")}
|
class="flex-1"
|
||||||
/>
|
/>
|
||||||
<Show when={store.filter}>
|
<Show when={store.filter}>
|
||||||
<IconButtonV2
|
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
||||||
type="button"
|
|
||||||
variant="ghost-muted"
|
|
||||||
size="small"
|
|
||||||
class="settings-v2-tab-search-clear"
|
|
||||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
|
||||||
onClick={() => setStore("filter", "")}
|
|
||||||
/>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-v2-tab-body">{groups}</div>
|
</div>
|
||||||
</>
|
{groups}
|
||||||
</Show>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,173 @@
|
|||||||
|
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
|
||||||
|
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
|
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||||
|
import { usePermission } from "@/context/permission"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import {
|
||||||
|
monoDefault,
|
||||||
|
monoFontFamily,
|
||||||
|
monoInput,
|
||||||
|
sansDefault,
|
||||||
|
sansFontFamily,
|
||||||
|
sansInput,
|
||||||
|
terminalDefault,
|
||||||
|
terminalFontFamily,
|
||||||
|
terminalInput,
|
||||||
|
useSettings,
|
||||||
|
} from "@/context/settings"
|
||||||
|
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||||
|
import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior"
|
||||||
|
|
||||||
|
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
|
||||||
|
export type { ShellOption, ShellSelectOption } from "./general-controller-behavior"
|
||||||
|
|
||||||
|
export function createPermissionScopeController(sessionID: Accessor<string | undefined>) {
|
||||||
|
const permission = usePermission()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const directory = createMemo(() => {
|
||||||
|
const id = sessionID()
|
||||||
|
if (!id) return undefined
|
||||||
|
return serverSync().session.lineage.peek(id)?.session.directory
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
accepting: createMemo(() => {
|
||||||
|
const id = sessionID()
|
||||||
|
const dir = directory()
|
||||||
|
if (!id || !dir) return false
|
||||||
|
return permission.isAutoAccepting(id, dir)
|
||||||
|
}),
|
||||||
|
enabled: createMemo(() => !!directory()),
|
||||||
|
set: (checked: boolean) => {
|
||||||
|
const id = sessionID()
|
||||||
|
const dir = directory()
|
||||||
|
if (!id || !dir) return
|
||||||
|
if (checked) return permission.enableAutoAccept(id, dir)
|
||||||
|
permission.disableAutoAccept(id, dir)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createShellSettingsController() {
|
||||||
|
const serverSdk = useServerSDK()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const [shells] = createResource(
|
||||||
|
async () => {
|
||||||
|
const sdk = serverSdk()
|
||||||
|
if ((await sdk.protocol) === "v1") return (await sdk.client.pty.shells()).data ?? []
|
||||||
|
return [] as ShellOption[]
|
||||||
|
},
|
||||||
|
{ initialValue: [] as ShellOption[] },
|
||||||
|
)
|
||||||
|
const current = createMemo(() => serverSync().data.config.shell ?? "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
shells: () => shells.latest,
|
||||||
|
current,
|
||||||
|
select: (value: string) => {
|
||||||
|
if (value === current()) return
|
||||||
|
void serverSync().updateConfig({ shell: value })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAppearanceSettingsController() {
|
||||||
|
const settings = useSettings()
|
||||||
|
const theme = useTheme()
|
||||||
|
const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
|
|
||||||
|
onMount(() => void theme.loadThemes())
|
||||||
|
|
||||||
|
return {
|
||||||
|
scheme: {
|
||||||
|
current: theme.colorScheme,
|
||||||
|
select: (value: ColorScheme) => theme.setColorScheme(value),
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
options: themes,
|
||||||
|
current: createMemo(() => themes().find((option) => option.id === theme.themeId())),
|
||||||
|
select: (option: { id: string } | null) => option && theme.setTheme(option.id),
|
||||||
|
},
|
||||||
|
fonts: {
|
||||||
|
ui: createMemo(() => ({
|
||||||
|
value: sansInput(settings.appearance.uiFont()),
|
||||||
|
family: sansFontFamily(settings.appearance.uiFont()),
|
||||||
|
placeholder: sansDefault,
|
||||||
|
})),
|
||||||
|
code: createMemo(() => ({
|
||||||
|
value: monoInput(settings.appearance.font()),
|
||||||
|
family: monoFontFamily(settings.appearance.font()),
|
||||||
|
placeholder: monoDefault,
|
||||||
|
})),
|
||||||
|
terminal: createMemo(() => ({
|
||||||
|
value: terminalInput(settings.appearance.terminalFont()),
|
||||||
|
family: terminalFontFamily(settings.appearance.terminalFont()),
|
||||||
|
placeholder: terminalDefault,
|
||||||
|
})),
|
||||||
|
setUI: (value: string) => settings.appearance.setUIFont(value),
|
||||||
|
setCode: (value: string) => settings.appearance.setFont(value),
|
||||||
|
setTerminal: (value: string) => settings.appearance.setTerminalFont(value),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||||
|
export const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||||
|
export type SoundSelectOption = (typeof soundOptions)[number]
|
||||||
|
|
||||||
|
export function createSoundSettingsController() {
|
||||||
|
const settings = useSettings()
|
||||||
|
const preview = createSoundPreviewController(playSoundById)
|
||||||
|
const channel = (
|
||||||
|
enabled: Accessor<boolean>,
|
||||||
|
current: Accessor<string>,
|
||||||
|
setEnabled: (value: boolean) => void,
|
||||||
|
set: (id: string) => void,
|
||||||
|
) => ({
|
||||||
|
current: createMemo(() =>
|
||||||
|
enabled() ? (soundOptions.find((option) => option.id === current()) ?? noneSound) : noneSound,
|
||||||
|
),
|
||||||
|
highlight: (option: SoundSelectOption | undefined) => {
|
||||||
|
if (!option) return
|
||||||
|
preview.play(option.id === "none" ? undefined : option.id)
|
||||||
|
},
|
||||||
|
select: (option: SoundSelectOption | null) => {
|
||||||
|
if (!option) return
|
||||||
|
if (option.id === "none") {
|
||||||
|
setEnabled(false)
|
||||||
|
preview.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEnabled(true)
|
||||||
|
set(option.id)
|
||||||
|
preview.play(option.id)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
agent: channel(
|
||||||
|
settings.sounds.agentEnabled,
|
||||||
|
settings.sounds.agent,
|
||||||
|
(value) => settings.sounds.setAgentEnabled(value),
|
||||||
|
(id) => settings.sounds.setAgent(id),
|
||||||
|
),
|
||||||
|
permissions: channel(
|
||||||
|
settings.sounds.permissionsEnabled,
|
||||||
|
settings.sounds.permissions,
|
||||||
|
(value) => settings.sounds.setPermissionsEnabled(value),
|
||||||
|
(id) => settings.sounds.setPermissions(id),
|
||||||
|
),
|
||||||
|
errors: channel(
|
||||||
|
settings.sounds.errorsEnabled,
|
||||||
|
settings.sounds.errors,
|
||||||
|
(value) => settings.sounds.setErrorsEnabled(value),
|
||||||
|
(id) => settings.sounds.setErrors(id),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PermissionScopeController = ReturnType<typeof createPermissionScopeController>
|
||||||
|
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
|
||||||
|
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
|
||||||
|
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
|
||||||
@@ -1,182 +1,297 @@
|
|||||||
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 { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePermission } from "@/context/permission"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useUpdaterAction } from "../updater-action"
|
import { useUpdaterAction } from "../updater-action"
|
||||||
import {
|
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 { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||||
|
import {
|
||||||
|
createAppearanceSettingsController,
|
||||||
|
createPermissionScopeController,
|
||||||
|
createShellOptions,
|
||||||
|
createShellSettingsController,
|
||||||
|
createSoundSettingsController,
|
||||||
|
soundOptions,
|
||||||
|
type AppearanceSettingsController,
|
||||||
|
type PermissionScopeController,
|
||||||
|
type ShellSettingsController,
|
||||||
|
type SoundSettingsController,
|
||||||
|
} from "./general-controllers"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
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 dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const serverSync = useServerSync()
|
|
||||||
const serverSdk = useServerSDK()
|
|
||||||
const mobile = createMediaQuery("(max-width: 767px)")
|
const mobile = createMediaQuery("(max-width: 767px)")
|
||||||
|
|
||||||
const updater = useUpdaterAction()
|
const updater = useUpdaterAction()
|
||||||
|
const permissionScope = createPermissionScopeController(() => props.sessionID)
|
||||||
const dir = createMemo(() => {
|
const shell = createShellSettingsController()
|
||||||
if (!props.sessionID) return undefined
|
const appearance = createAppearanceSettingsController()
|
||||||
return serverSync().session.lineage.peek(props.sessionID)?.session.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 () => {
|
|
||||||
const sdk = serverSdk()
|
|
||||||
if ((await sdk.protocol) === "v1") {
|
|
||||||
return (await sdk.client.pty.shells()).data ?? []
|
|
||||||
}
|
|
||||||
// return (await sdk.api.pty.shells()).data
|
|
||||||
return [] as ShellOption[]
|
|
||||||
},
|
|
||||||
{ initialValue: [] as ShellOption[] },
|
|
||||||
)
|
|
||||||
|
|
||||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||||
() => (desktop() && 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)
|
||||||
@@ -184,52 +299,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
void update.catch(() => setPinchZoom(!checked))
|
void update.catch(() => setPinchZoom(!checked))
|
||||||
}
|
}
|
||||||
|
|
||||||
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
|
||||||
{ value: "system", label: language.t("theme.scheme.system") },
|
|
||||||
{ value: "light", label: language.t("theme.scheme.light") },
|
|
||||||
{ value: "dark", label: language.t("theme.scheme.dark") },
|
|
||||||
])
|
|
||||||
|
|
||||||
const languageOptions = createMemo(() =>
|
|
||||||
language.locales.map((locale) => ({
|
|
||||||
value: locale,
|
|
||||||
label: language.label(locale),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
|
||||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
|
||||||
const mono = () => monoInput(settings.appearance.font())
|
|
||||||
const sans = () => sansInput(settings.appearance.uiFont())
|
|
||||||
const terminal = () => terminalInput(settings.appearance.terminalFont())
|
|
||||||
|
|
||||||
const soundSelectProps = (
|
|
||||||
enabled: () => boolean,
|
|
||||||
current: () => string,
|
|
||||||
setEnabled: (value: boolean) => void,
|
|
||||||
set: (id: string) => void,
|
|
||||||
) => ({
|
|
||||||
options: soundOptions,
|
|
||||||
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
|
|
||||||
value: (o: (typeof soundOptions)[number]) => o.id,
|
|
||||||
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
|
||||||
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
|
||||||
if (!option) return
|
|
||||||
playDemoSound(option.id === "none" ? undefined : option.id)
|
|
||||||
},
|
|
||||||
onSelect: (option: (typeof soundOptions)[number] | null) => {
|
|
||||||
if (!option) return
|
|
||||||
if (option.id === "none") {
|
|
||||||
setEnabled(false)
|
|
||||||
stopDemoSound()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setEnabled(true)
|
|
||||||
set(option.id)
|
|
||||||
playDemoSound(option.id)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const InterfaceSection = () => (
|
const InterfaceSection = () => (
|
||||||
<LayoutTransitionToggle
|
<LayoutTransitionToggle
|
||||||
title={language.t("settings.general.row.newInterface.title")}
|
title={language.t("settings.general.row.newInterface.title")}
|
||||||
@@ -251,59 +320,18 @@ export const SettingsGeneralV2: Component<{
|
|||||||
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||||
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||||
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||||
onDismiss={settings.general.dismissNewInterfaceNotice}
|
onDismiss={() => settings.general.dismissNewInterfaceNotice()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
const GeneralSection = () => (
|
const GeneralSection = () => (
|
||||||
<div class="settings-v2-section">
|
<div class="settings-v2-section">
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<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")}
|
||||||
@@ -414,124 +442,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>
|
||||||
@@ -576,68 +486,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>
|
||||||
@@ -659,7 +507,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>
|
||||||
@@ -704,11 +552,11 @@ export const SettingsGeneralV2: Component<{
|
|||||||
|
|
||||||
<GeneralSection />
|
<GeneralSection />
|
||||||
|
|
||||||
<AppearanceSection />
|
<AppearanceSection controller={appearance} />
|
||||||
|
|
||||||
<NotificationsSection />
|
<NotificationsSection />
|
||||||
|
|
||||||
<SoundsSection />
|
<SoundsSection controller={sounds} />
|
||||||
|
|
||||||
<Show when={desktop()}>
|
<Show when={desktop()}>
|
||||||
<UpdatesSection />
|
<UpdatesSection />
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { type Component, For, Show } from "solid-js"
|
import { type Component, For, Show } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useModels } from "@/context/models"
|
import { useModels } from "@/context/models"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
@@ -19,6 +22,11 @@ const PROVIDER_ICON_SIZE = 16
|
|||||||
export const SettingsModelsV2: Component = () => {
|
export const SettingsModelsV2: Component = () => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
|
const serverSdk = useServerSDK()
|
||||||
|
const [store, setStore] = persisted(
|
||||||
|
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
|
||||||
|
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||||
|
)
|
||||||
|
|
||||||
const list = useFilteredList<ModelItem>({
|
const list = useFilteredList<ModelItem>({
|
||||||
items: (_filter) => models.list(),
|
items: (_filter) => models.list(),
|
||||||
@@ -94,41 +102,82 @@ export const SettingsModelsV2: Component = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={list.grouped.latest}>
|
<For each={list.grouped.latest}>
|
||||||
{(group) => (
|
{(group) => {
|
||||||
<div class="settings-v2-section" data-component="settings-models-provider">
|
const searching = () => list.filter().length > 0
|
||||||
<div class="settings-v2-models-group-header">
|
const expanded = () => searching() || !store.collapsed[group.category]
|
||||||
<ProviderIcon
|
|
||||||
id={group.category}
|
return (
|
||||||
width={PROVIDER_ICON_SIZE}
|
<div
|
||||||
height={PROVIDER_ICON_SIZE}
|
class="settings-v2-section"
|
||||||
class="settings-v2-models-provider-icon shrink-0"
|
data-component="settings-models-provider"
|
||||||
/>
|
data-expanded={expanded() ? "" : undefined}
|
||||||
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
|
>
|
||||||
|
<h3 class="settings-v2-models-group-header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="settings-v2-models-group-trigger"
|
||||||
|
aria-expanded={expanded()}
|
||||||
|
disabled={searching()}
|
||||||
|
onClick={() => setStore("collapsed", group.category, expanded())}
|
||||||
|
>
|
||||||
|
<span class="settings-v2-models-group-chevron">
|
||||||
|
<Show
|
||||||
|
when={expanded()}
|
||||||
|
fallback={
|
||||||
|
<svg width="5" height="6" viewBox="0 0 5 6" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M0.75194 5.31663C0.41861 5.51103 0 5.27063 0 4.88473V0.500754C0 0.114854 0.41861 -0.125577 0.75194 0.0688635L4.5096 2.26084C4.8404 2.45378 4.8404 2.93168 4.5096 3.12462L0.75194 5.31663Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M5.37624 6.75194C5.18184 6.41861 5.42224 6 5.80814 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43203 10.5096C8.23909 10.8404 7.76119 10.8404 7.56825 10.5096L5.37624 6.75194Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
<span class="settings-v2-models-group-label">
|
||||||
|
<ProviderIcon
|
||||||
|
id={group.category}
|
||||||
|
width={PROVIDER_ICON_SIZE}
|
||||||
|
height={PROVIDER_ICON_SIZE}
|
||||||
|
class="settings-v2-models-provider-icon shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="settings-v2-section-title">{group.items[0].provider.name}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<Show when={expanded()}>
|
||||||
|
<SettingsListV2>
|
||||||
|
<For each={group.items}>
|
||||||
|
{(item) => {
|
||||||
|
const key = { providerID: item.provider.id, modelID: item.id }
|
||||||
|
return (
|
||||||
|
<SettingsRowV2 title={item.name} description="">
|
||||||
|
<div>
|
||||||
|
<Switch
|
||||||
|
checked={models.visible(key)}
|
||||||
|
onChange={(checked) => {
|
||||||
|
models.setVisibility(key, checked)
|
||||||
|
}}
|
||||||
|
hideLabel
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</SettingsListV2>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<SettingsListV2>
|
)
|
||||||
<For each={group.items}>
|
}}
|
||||||
{(item) => {
|
|
||||||
const key = { providerID: item.provider.id, modelID: item.id }
|
|
||||||
return (
|
|
||||||
<SettingsRowV2 title={item.name} description="">
|
|
||||||
<div>
|
|
||||||
<Switch
|
|
||||||
checked={models.visible(key)}
|
|
||||||
onChange={(checked) => {
|
|
||||||
models.setVisibility(key, checked)
|
|
||||||
}}
|
|
||||||
hideLabel
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -287,7 +287,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-provider-env-hint {
|
.settings-v2-provider-env-hint {
|
||||||
padding-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,26 +361,76 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-models {
|
.settings-v2-models {
|
||||||
gap: 24px;
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models .settings-v2-section {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models .settings-v2-section[data-expanded] {
|
||||||
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-models-group-header {
|
.settings-v2-models-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models-group-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 4px 8px 4px 4px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models-group-trigger:focus-visible {
|
||||||
|
outline: 2px solid var(--v2-border-border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.settings-v2-models-group-trigger:not(:disabled):hover {
|
||||||
|
background-color: var(--v2-background-bg-layer-02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models-group-trigger:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models-group-chevron {
|
||||||
|
display: flex;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--v2-icon-icon-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models-group-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-models .settings-v2-section-title {
|
.settings-v2-models .settings-v2-section-title {
|
||||||
@@ -394,6 +444,10 @@
|
|||||||
color: var(--v2-icon-icon-base);
|
color: var(--v2-icon-icon-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-v2-models [data-component="settings-v2-list"] {
|
||||||
|
padding-inline: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
|
.settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
|
import {
|
||||||
|
hasNonBlockingServiceIssue,
|
||||||
|
hasServiceNeedingAttention,
|
||||||
|
serverStatusDotClass,
|
||||||
|
} from "./status-popover-indicator"
|
||||||
|
|
||||||
describe("serverStatusDotClass", () => {
|
describe("serverStatusDotClass", () => {
|
||||||
test("uses the success token while the server and services are healthy", () => {
|
test("uses the success token while the server and services are healthy", () => {
|
||||||
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
|
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("uses the session attention token when a service needs attention", () => {
|
||||||
|
expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe(
|
||||||
|
"bg-v2-background-bg-accent",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("uses the warning token for non-blocking issues while the server is online", () => {
|
test("uses the warning token for non-blocking issues while the server is online", () => {
|
||||||
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
|
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
|
||||||
})
|
})
|
||||||
@@ -34,3 +44,15 @@ describe("hasNonBlockingServiceIssue", () => {
|
|||||||
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
|
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("hasServiceNeedingAttention", () => {
|
||||||
|
test("detects MCP states that need user attention", () => {
|
||||||
|
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
|
||||||
|
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ignores states that do not need user attention", () => {
|
||||||
|
expect(hasServiceNeedingAttention({ mcp: ["failed"] })).toBe(false)
|
||||||
|
expect(hasServiceNeedingAttention({ mcp: ["connected", "pending", "disabled"] })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { LspStatus } from "@opencode-ai/sdk/v2/client"
|
import type { LspStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { McpServer } from "@opencode-ai/client/promise"
|
import type { McpServer } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
|
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
|
||||||
|
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
|
||||||
|
}
|
||||||
|
|
||||||
export function hasNonBlockingServiceIssue(input: {
|
export function hasNonBlockingServiceIssue(input: {
|
||||||
mcp: Array<McpServer["status"]["status"]>
|
mcp: Array<McpServer["status"]["status"]>
|
||||||
lsp: Array<LspStatus["status"]>
|
lsp: Array<LspStatus["status"]>
|
||||||
@@ -11,9 +15,15 @@ export function hasNonBlockingServiceIssue(input: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
|
export function serverStatusDotClass(input: {
|
||||||
|
ready: boolean
|
||||||
|
serverHealth: boolean | undefined
|
||||||
|
attention?: boolean
|
||||||
|
issue: boolean
|
||||||
|
}) {
|
||||||
if (input.serverHealth === false) return "bg-icon-critical-base"
|
if (input.serverHealth === false) return "bg-icon-critical-base"
|
||||||
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
|
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
|
||||||
|
if (input.attention) return "bg-v2-background-bg-accent"
|
||||||
if (input.issue) return "bg-icon-warning-base"
|
if (input.issue) return "bg-icon-warning-base"
|
||||||
if (input.serverHealth === true) return "bg-icon-success-base"
|
if (input.serverHealth === true) return "bg-icon-success-base"
|
||||||
return "bg-border-weak-base"
|
return "bg-border-weak-base"
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import { ServerConnection, useServer } from "@/context/server"
|
|||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
|
import {
|
||||||
|
hasNonBlockingServiceIssue,
|
||||||
|
hasServiceNeedingAttention,
|
||||||
|
serverStatusDotClass,
|
||||||
|
} from "./status-popover-indicator"
|
||||||
|
|
||||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||||
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
|
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
|
||||||
@@ -22,6 +26,11 @@ export function StatusPopover() {
|
|||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||||
|
const attention = createMemo(() =>
|
||||||
|
hasServiceNeedingAttention({
|
||||||
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
|
}),
|
||||||
|
)
|
||||||
const issue = createMemo(() =>
|
const issue = createMemo(() =>
|
||||||
hasNonBlockingServiceIssue({
|
hasNonBlockingServiceIssue({
|
||||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
@@ -49,6 +58,7 @@ export function StatusPopover() {
|
|||||||
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
|
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
|
||||||
ready: ready(),
|
ready: ready(),
|
||||||
serverHealth: serverHealth(),
|
serverHealth: serverHealth(),
|
||||||
|
attention: attention(),
|
||||||
issue: issue(),
|
issue: issue(),
|
||||||
})}`}
|
})}`}
|
||||||
/>
|
/>
|
||||||
@@ -85,6 +95,11 @@ function DirectoryStatusPopover() {
|
|||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
|
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
|
||||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||||
|
const attention = createMemo(() =>
|
||||||
|
hasServiceNeedingAttention({
|
||||||
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
|
}),
|
||||||
|
)
|
||||||
const issue = createMemo(() =>
|
const issue = createMemo(() =>
|
||||||
hasNonBlockingServiceIssue({
|
hasNonBlockingServiceIssue({
|
||||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
@@ -95,6 +110,7 @@ function DirectoryStatusPopover() {
|
|||||||
shown: shown(),
|
shown: shown(),
|
||||||
ready: ready(),
|
ready: ready(),
|
||||||
serverHealth: serverHealth(),
|
serverHealth: serverHealth(),
|
||||||
|
attention: attention(),
|
||||||
issue: issue(),
|
issue: issue(),
|
||||||
label: language.t("status.popover.trigger"),
|
label: language.t("status.popover.trigger"),
|
||||||
onOpenChange: setShown,
|
onOpenChange: setShown,
|
||||||
@@ -118,6 +134,7 @@ function ServerStatusPopover() {
|
|||||||
shown: shown(),
|
shown: shown(),
|
||||||
ready: serverHealth() !== undefined,
|
ready: serverHealth() !== undefined,
|
||||||
serverHealth: serverHealth(),
|
serverHealth: serverHealth(),
|
||||||
|
attention: false,
|
||||||
issue: false,
|
issue: false,
|
||||||
label: language.t("status.popover.trigger"),
|
label: language.t("status.popover.trigger"),
|
||||||
onOpenChange: setShown,
|
onOpenChange: setShown,
|
||||||
@@ -135,6 +152,7 @@ type StatusPopoverState = {
|
|||||||
shown: boolean
|
shown: boolean
|
||||||
ready: boolean
|
ready: boolean
|
||||||
serverHealth: boolean | undefined
|
serverHealth: boolean | undefined
|
||||||
|
attention: boolean
|
||||||
issue: boolean
|
issue: boolean
|
||||||
label: string
|
label: string
|
||||||
onOpenChange: (value: boolean) => void
|
onOpenChange: (value: boolean) => void
|
||||||
|
|||||||
@@ -382,7 +382,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(() => {
|
||||||
@@ -569,9 +573,8 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
if (!result) return
|
if (!result) return
|
||||||
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
|
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
|
||||||
if (result.response.status === 404 || result.response.status === 405) return
|
if (result.response.status === 404 || result.response.status === 405) return
|
||||||
if (result.response.status === 403)
|
if (result.response.status === 403) throw new Error(language.t("terminal.connectTicket.csrfError"))
|
||||||
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
|
throw new Error(language.t("terminal.connectTicket.statusError", { status: result.response.status }))
|
||||||
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
|
|
||||||
}
|
}
|
||||||
// return sdk()
|
// return sdk()
|
||||||
// .api.pty.connectToken({
|
// .api.pty.connectToken({
|
||||||
@@ -738,6 +741,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 }}
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTM
|
|||||||
if (typeof ref === "function") ref(element)
|
if (typeof ref === "function") ref(element)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
|
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, pending: boolean) {
|
||||||
return !dragging && !editing && !committing
|
return !dragging && !editing && !pending
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"] {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
|
import { createMutation } from "@tanstack/solid-query"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
@@ -8,7 +9,6 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { ServerConnection, serverName } from "@/context/server"
|
import { ServerConnection, serverName } from "@/context/server"
|
||||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||||
import { showToast } from "@/utils/toast"
|
|
||||||
import type { Session } from "@opencode-ai/sdk/v2"
|
import type { Session } from "@opencode-ai/sdk/v2"
|
||||||
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||||
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
||||||
@@ -23,8 +23,7 @@ export function TabNavItem(props: {
|
|||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
session: () => Session | undefined
|
session: () => Session | undefined
|
||||||
fallbackTitle?: string
|
fallbackTitle?: string
|
||||||
onTitleChange?: (title: string) => void
|
onRename: (title: string) => Promise<void>
|
||||||
onTitleChangeFailed?: (title: string) => void
|
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onNavigate: () => void
|
onNavigate: () => void
|
||||||
active?: boolean
|
active?: boolean
|
||||||
@@ -34,13 +33,12 @@ export function TabNavItem(props: {
|
|||||||
pressed?: boolean
|
pressed?: boolean
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
|
||||||
const [editing, setEditing] = createSignal(false)
|
const [editing, setEditing] = createSignal(false)
|
||||||
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
|
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
|
||||||
let tabRoot!: HTMLDivElement
|
let tabRoot!: HTMLDivElement
|
||||||
let titleEl!: HTMLSpanElement
|
let titleEl!: HTMLSpanElement
|
||||||
let committing = false
|
|
||||||
let measureFrame: number | undefined
|
let measureFrame: number | undefined
|
||||||
|
const rename = createMutation(() => ({ mutationFn: props.onRename }))
|
||||||
|
|
||||||
const closeTab = (event: MouseEvent) => {
|
const closeTab = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -116,40 +114,20 @@ export function TabNavItem(props: {
|
|||||||
selection?.addRange(range)
|
selection?.addRange(range)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rename = async (title: string) => {
|
|
||||||
const ctx = serverCtx()
|
|
||||||
const session = props.session()
|
|
||||||
if (!ctx || !session) return
|
|
||||||
await ctx.sdk.api.session.rename({ sessionID: session.id, title })
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeRename = async (save: boolean) => {
|
const closeRename = async (save: boolean) => {
|
||||||
if (committing || !editing()) return
|
if (rename.isPending || !editing()) return
|
||||||
committing = true
|
|
||||||
|
|
||||||
const original = props.session()?.title ?? ""
|
const original = props.session()?.title ?? ""
|
||||||
const next = (titleEl.textContent ?? "").trim()
|
const next = (titleEl.textContent ?? "").trim()
|
||||||
|
|
||||||
titleEl.scrollLeft = 0
|
titleEl.scrollLeft = 0
|
||||||
if (save && next && next !== original) props.onTitleChange?.(next)
|
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
|
|
||||||
if (!save || !next || next === original) {
|
if (!save || !next || next === original) {
|
||||||
committing = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await rename.mutateAsync(next)
|
||||||
await rename(next)
|
|
||||||
} catch (err) {
|
|
||||||
props.onTitleChangeFailed?.(original)
|
|
||||||
showToast({
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
committing = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -163,7 +141,7 @@ export function TabNavItem(props: {
|
|||||||
const openRename = (event: MouseEvent) => {
|
const openRename = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (!canOpenTabRename(props.dragging, editing(), committing)) return
|
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
|
||||||
const session = props.session()
|
const session = props.session()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
titleEl.textContent = session.title
|
titleEl.textContent = session.title
|
||||||
@@ -203,11 +181,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()
|
||||||
@@ -218,90 +196,89 @@ export function TabNavItem(props: {
|
|||||||
closeTab(event)
|
closeTab(event)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={title() !== undefined}>
|
<a
|
||||||
<a
|
data-slot="tab-link"
|
||||||
data-slot="tab-link"
|
data-titlebar-tab-link
|
||||||
data-titlebar-tab-link
|
href={props.href}
|
||||||
href={props.href}
|
draggable={false}
|
||||||
draggable={false}
|
onDragStart={(event) => {
|
||||||
onDragStart={(event) => {
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||||
|
if (event.button !== 0) return
|
||||||
|
if (editing()) return
|
||||||
|
if (props.suppressNavigation?.()) return
|
||||||
|
props.onNavigate()
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||||
|
if (event.detail > 0) return
|
||||||
|
if (editing()) return
|
||||||
|
if (props.suppressNavigation?.()) return
|
||||||
|
props.onNavigate()
|
||||||
|
}}
|
||||||
|
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||||
|
>
|
||||||
|
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
|
||||||
|
<Show
|
||||||
|
when={props.session()}
|
||||||
|
keyed
|
||||||
|
fallback={
|
||||||
|
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(session) => (
|
||||||
|
<SessionTabAvatar
|
||||||
|
project={project()}
|
||||||
|
directory={session.directory}
|
||||||
|
sessionId={session.id}
|
||||||
|
server={props.server}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
ref={(el) => {
|
||||||
|
titleEl = el
|
||||||
|
titleEl.textContent = title() ?? ""
|
||||||
|
}}
|
||||||
|
data-slot="tab-title"
|
||||||
|
data-titlebar-tab-title
|
||||||
|
class="min-w-0 flex-1 outline-none leading-4"
|
||||||
|
classList={{
|
||||||
|
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||||
|
"select-text": editing(),
|
||||||
|
}}
|
||||||
|
contenteditable={editing() ? true : undefined}
|
||||||
|
onDblClick={openRename}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
void closeRename(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key !== "Escape") return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
titleEl.textContent = props.session()?.title ?? ""
|
||||||
|
void closeRename(false)
|
||||||
|
}}
|
||||||
|
onBlur={() => void closeRename(true)}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (!editing()) return
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
onMouseDown={(event) => {
|
|
||||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
|
||||||
if (event.button !== 0) return
|
|
||||||
if (editing()) return
|
|
||||||
if (props.suppressNavigation?.()) return
|
|
||||||
props.onNavigate()
|
|
||||||
}}
|
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
if (!editing()) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
|
||||||
if (event.detail > 0) return
|
|
||||||
if (editing()) return
|
|
||||||
if (props.suppressNavigation?.()) return
|
|
||||||
props.onNavigate()
|
|
||||||
}}
|
}}
|
||||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
/>
|
||||||
>
|
</a>
|
||||||
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
|
|
||||||
<Show
|
|
||||||
when={props.session()}
|
|
||||||
fallback={
|
|
||||||
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(session) => (
|
|
||||||
<SessionTabAvatar
|
|
||||||
project={project()}
|
|
||||||
directory={session().directory}
|
|
||||||
sessionId={session().id}
|
|
||||||
server={props.server}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
ref={(el) => {
|
|
||||||
titleEl = el
|
|
||||||
titleEl.textContent = title() ?? ""
|
|
||||||
}}
|
|
||||||
data-slot="tab-title"
|
|
||||||
data-titlebar-tab-title
|
|
||||||
class="min-w-0 flex-1 outline-none leading-4"
|
|
||||||
classList={{
|
|
||||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
|
||||||
"select-text": editing(),
|
|
||||||
}}
|
|
||||||
contenteditable={editing() ? true : undefined}
|
|
||||||
onDblClick={openRename}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault()
|
|
||||||
void closeRename(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.key !== "Escape") return
|
|
||||||
event.preventDefault()
|
|
||||||
titleEl.textContent = props.session()?.title ?? ""
|
|
||||||
void closeRename(false)
|
|
||||||
}}
|
|
||||||
onBlur={() => void closeRename(true)}
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
if (!editing()) return
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
onClick={(event) => {
|
|
||||||
if (!editing()) return
|
|
||||||
event.preventDefault()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div data-slot="tab-close" 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"
|
||||||
@@ -347,6 +324,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()
|
||||||
@@ -359,8 +337,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
|
||||||
@@ -406,7 +384,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"
|
||||||
@@ -421,7 +399,7 @@ export function DraftTabItem(props: {
|
|||||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
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>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
|
||||||
|
|
||||||
|
describe("adjacentTabKey", () => {
|
||||||
|
test("follows the visible left-to-right order", () => {
|
||||||
|
expect(adjacentTabKey(["c", "a", "b"], "c", 1)).toBe("a")
|
||||||
|
expect(adjacentTabKey(["c", "a", "b"], "a", -1)).toBe("c")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips tabs omitted from the visible order", () => {
|
||||||
|
expect(adjacentTabKey(["a", "c"], "a", 1)).toBe("c")
|
||||||
|
expect(adjacentTabKey(["a", "c"], "c", 1)).toBe("a")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("merges reordered visible tabs around hidden tabs", () => {
|
||||||
|
expect(mergeVisibleTabOrder(["a", "hidden", "b", "c"], ["a", "b", "c"], ["c", "a", "b"])).toEqual([
|
||||||
|
"c",
|
||||||
|
"hidden",
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
])
|
||||||
|
})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export function adjacentTabKey(order: string[], current: string | undefined, offset: -1 | 1) {
|
||||||
|
if (!current || order.length === 0) return
|
||||||
|
const index = order.indexOf(current)
|
||||||
|
if (index === -1) return
|
||||||
|
return order[(index + offset + order.length) % order.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeVisibleTabOrder(all: string[], current: string[], next: string[]) {
|
||||||
|
const visible = new Set(current)
|
||||||
|
const reordered = next.values()
|
||||||
|
return all.map((key) => (visible.has(key) ? (reordered.next().value ?? key) : key))
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js"
|
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount, Show } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||||
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
||||||
@@ -15,7 +16,10 @@ import { useCommand } from "@/context/command"
|
|||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { createTabPromptState } from "@/context/prompt"
|
import { createTabPromptState } from "@/context/prompt"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
||||||
|
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
|
||||||
|
import type { Session } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
function SessionTabSlot(props: {
|
function SessionTabSlot(props: {
|
||||||
tab: SessionTab
|
tab: SessionTab
|
||||||
@@ -23,12 +27,12 @@ function SessionTabSlot(props: {
|
|||||||
index: () => number
|
index: () => number
|
||||||
active: () => boolean
|
active: () => boolean
|
||||||
forceTruncate: boolean
|
forceTruncate: boolean
|
||||||
serverCtx: () => ServerCtx | undefined
|
session: () => Session | undefined
|
||||||
|
fallbackTitle?: string
|
||||||
|
onRename: (title: string) => Promise<void>
|
||||||
onNavigate: (element: HTMLDivElement) => void
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const tabs = useTabs()
|
|
||||||
const language = useLanguage()
|
|
||||||
const sortable = useSortable({
|
const sortable = useSortable({
|
||||||
get id() {
|
get id() {
|
||||||
return props.id
|
return props.id
|
||||||
@@ -38,6 +42,47 @@ function SessionTabSlot(props: {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
let ref!: HTMLDivElement
|
let ref!: HTMLDivElement
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={sortable.ref}
|
||||||
|
data-titlebar-tab-slot
|
||||||
|
data-tab-key={props.id}
|
||||||
|
data-active={props.active()}
|
||||||
|
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||||
|
>
|
||||||
|
<TabNavItem
|
||||||
|
ref={(el) => {
|
||||||
|
ref = el
|
||||||
|
}}
|
||||||
|
href={tabHref(props.tab)}
|
||||||
|
server={props.tab.server}
|
||||||
|
session={props.session}
|
||||||
|
fallbackTitle={props.fallbackTitle}
|
||||||
|
onRename={props.onRename}
|
||||||
|
onNavigate={() => props.onNavigate(ref)}
|
||||||
|
onClose={props.onClose}
|
||||||
|
active={props.active()}
|
||||||
|
forceTruncate={props.forceTruncate}
|
||||||
|
dragging={sortable.isDragSource()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionTabEntry(props: {
|
||||||
|
tab: SessionTab
|
||||||
|
id: string
|
||||||
|
index: () => number
|
||||||
|
active: () => boolean
|
||||||
|
forceTruncate: boolean
|
||||||
|
serverCtx: () => ServerCtx | undefined
|
||||||
|
onVisibleChange: (visible: boolean) => void
|
||||||
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const tabs = useTabs()
|
||||||
|
const language = useLanguage()
|
||||||
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
||||||
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
||||||
const persisted = createMemo(() => tabs.info[props.id])
|
const persisted = createMemo(() => tabs.info[props.id])
|
||||||
@@ -50,8 +95,30 @@ function SessionTabSlot(props: {
|
|||||||
)
|
)
|
||||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||||
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
|
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
|
||||||
|
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
|
||||||
let prefetched = false
|
let prefetched = false
|
||||||
|
|
||||||
|
const rename = async (title: string) => {
|
||||||
|
const value = session()
|
||||||
|
const ctx = props.serverCtx()
|
||||||
|
if (!value || !ctx) return
|
||||||
|
|
||||||
|
ctx.sync.session.remember({ ...value, title })
|
||||||
|
try {
|
||||||
|
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
||||||
|
} catch (err) {
|
||||||
|
const current = session()
|
||||||
|
const currentCtx = props.serverCtx()
|
||||||
|
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
|
||||||
|
showToast({
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => props.onVisibleChange(visible()))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const ctx = props.serverCtx()
|
const ctx = props.serverCtx()
|
||||||
const value = session()
|
const value = session()
|
||||||
@@ -83,39 +150,20 @@ function SessionTabSlot(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Show when={visible()}>
|
||||||
ref={sortable.ref}
|
<SessionTabSlot
|
||||||
data-titlebar-tab-slot
|
tab={props.tab}
|
||||||
data-tab-key={props.id}
|
id={props.id}
|
||||||
data-active={props.active()}
|
index={props.index}
|
||||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
active={props.active}
|
||||||
classList={{ hidden: !session() && !missingSession() && !persisted()?.title }}
|
forceTruncate={props.forceTruncate}
|
||||||
>
|
|
||||||
<TabNavItem
|
|
||||||
ref={(el) => {
|
|
||||||
ref = el
|
|
||||||
}}
|
|
||||||
href={tabHref(props.tab)}
|
|
||||||
server={props.tab.server}
|
|
||||||
session={session}
|
session={session}
|
||||||
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
|
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
|
||||||
onTitleChange={(title) => {
|
onRename={rename}
|
||||||
const value = session()
|
onNavigate={props.onNavigate}
|
||||||
const ctx = props.serverCtx()
|
|
||||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
|
||||||
}}
|
|
||||||
onTitleChangeFailed={(title) => {
|
|
||||||
const value = session()
|
|
||||||
const ctx = props.serverCtx()
|
|
||||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
|
||||||
}}
|
|
||||||
onNavigate={() => props.onNavigate(ref)}
|
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
active={props.active()}
|
|
||||||
forceTruncate={props.forceTruncate}
|
|
||||||
dragging={sortable.isDragSource()}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,11 +220,39 @@ export function TitlebarTabStrip(props: {
|
|||||||
}) {
|
}) {
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const command = useCommand()
|
||||||
let scrollRef!: HTMLDivElement
|
let scrollRef!: HTMLDivElement
|
||||||
let listRef!: HTMLDivElement
|
let listRef!: HTMLDivElement
|
||||||
let resizeFrame: number | undefined
|
let resizeFrame: number | undefined
|
||||||
|
const [visibility, setVisibility] = createStore<Record<string, boolean>>({})
|
||||||
|
const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)]))
|
||||||
|
const visibleTabIds = () => visibleTabs().map(tabKey)
|
||||||
|
|
||||||
const tabIds = () => props.tabs.map(tabKey)
|
command.register("titlebar-tab-cycle", () => [
|
||||||
|
{
|
||||||
|
id: `tab.prev`,
|
||||||
|
category: "tab",
|
||||||
|
title: "",
|
||||||
|
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
|
||||||
|
hidden: true,
|
||||||
|
onSelect: () => selectAdjacentTab(-1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `tab.next`,
|
||||||
|
category: "tab",
|
||||||
|
title: "",
|
||||||
|
keybind: `mod+option+ArrowRight,ctrl+tab`,
|
||||||
|
hidden: true,
|
||||||
|
onSelect: () => selectAdjacentTab(1),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
function selectAdjacentTab(offset: -1 | 1) {
|
||||||
|
const current = props.currentTab()
|
||||||
|
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
|
||||||
|
const next = props.tabs.find((tab) => tabKey(tab) === key)
|
||||||
|
if (next) props.onNavigate(next)
|
||||||
|
}
|
||||||
|
|
||||||
function refreshOverflow() {
|
function refreshOverflow() {
|
||||||
if (!scrollRef) return
|
if (!scrollRef) return
|
||||||
@@ -204,7 +280,7 @@ export function TitlebarTabStrip(props: {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
props.tabs.length
|
props.tabs.length
|
||||||
tabIds()
|
visibleTabIds()
|
||||||
refreshOverflow()
|
refreshOverflow()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -240,22 +316,29 @@ export function TitlebarTabStrip(props: {
|
|||||||
props.onNavigate(tab, tabEl ?? undefined)
|
props.onNavigate(tab, tabEl ?? undefined)
|
||||||
}}
|
}}
|
||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
const current = tabIds()
|
const current = visibleTabIds()
|
||||||
const source = event.operation.source
|
const source = event.operation.source
|
||||||
if (event.canceled || !isSortable(source)) return
|
if (event.canceled || !isSortable(source)) return
|
||||||
|
|
||||||
const { initialIndex, index } = source
|
const { initialIndex, index } = source
|
||||||
if (initialIndex !== index) {
|
if (initialIndex !== index) {
|
||||||
props.onReorder(arrayMove(current, source.initialIndex, source.index))
|
props.onReorder(
|
||||||
|
mergeVisibleTabOrder(
|
||||||
|
props.tabs.map(tabKey),
|
||||||
|
current,
|
||||||
|
arrayMove(current, source.initialIndex, source.index),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
|
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
|
||||||
<For each={props.tabs}>
|
<For each={props.tabs}>
|
||||||
{(tab, index) => {
|
{(tab) => {
|
||||||
const id = tabKey(tab)
|
const id = tabKey(tab)
|
||||||
let ref!: HTMLDivElement
|
let ref!: HTMLDivElement
|
||||||
useTabShortcut(index, () => props.onNavigate(tab, ref))
|
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
|
||||||
|
useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
|
||||||
const serverCtx = createMemo(() => {
|
const serverCtx = createMemo(() => {
|
||||||
if (tab.type !== "session") return
|
if (tab.type !== "session") return
|
||||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
||||||
@@ -264,13 +347,14 @@ export function TitlebarTabStrip(props: {
|
|||||||
|
|
||||||
if (tab.type === "session") {
|
if (tab.type === "session") {
|
||||||
return (
|
return (
|
||||||
<SessionTabSlot
|
<SessionTabEntry
|
||||||
tab={tab}
|
tab={tab}
|
||||||
id={id}
|
id={id}
|
||||||
index={index}
|
index={visibleIndex}
|
||||||
active={() => props.currentTab() === tab}
|
active={() => props.currentTab() === tab}
|
||||||
forceTruncate={props.forceTruncate}
|
forceTruncate={props.forceTruncate}
|
||||||
serverCtx={serverCtx}
|
serverCtx={serverCtx}
|
||||||
|
onVisibleChange={(visible) => setVisibility(id, visible)}
|
||||||
onNavigate={(element) => {
|
onNavigate={(element) => {
|
||||||
ref = element
|
ref = element
|
||||||
props.onNavigate(tab, element)
|
props.onNavigate(tab, element)
|
||||||
@@ -284,7 +368,7 @@ export function TitlebarTabStrip(props: {
|
|||||||
<DraftTabSlot
|
<DraftTabSlot
|
||||||
tab={tab}
|
tab={tab}
|
||||||
id={id}
|
id={id}
|
||||||
index={index}
|
index={visibleIndex}
|
||||||
active={() => props.currentTab() === tab}
|
active={() => props.currentTab() === tab}
|
||||||
title={language.t("command.session.new")}
|
title={language.t("command.session.new")}
|
||||||
onNavigate={(element) => {
|
onNavigate={(element) => {
|
||||||
@@ -318,7 +402,7 @@ function useTabShortcut(index: () => number, onSelect: () => void) {
|
|||||||
|
|
||||||
command.register(() => {
|
command.register(() => {
|
||||||
const number = index() + 1
|
const number = index() + 1
|
||||||
if (number > 9) return []
|
if (number < 1 || number > 9) return []
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: `tab.${number}`,
|
id: `tab.${number}`,
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import { createEffect, createMemo, createResource, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js"
|
import {
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createResource,
|
||||||
|
createSignal,
|
||||||
|
Match,
|
||||||
|
on,
|
||||||
|
onMount,
|
||||||
|
Show,
|
||||||
|
Switch,
|
||||||
|
untrack,
|
||||||
|
} from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
@@ -42,8 +53,11 @@ export type TitlebarUpdate = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useTitlebarRightMount() {
|
export function useTitlebarRightMount() {
|
||||||
|
const language = useLanguage()
|
||||||
const [mount, setMount] = createSignal<HTMLElement | null>(null)
|
const [mount, setMount] = createSignal<HTMLElement | null>(null)
|
||||||
onMount(() => setMount(document.getElementById("opencode-titlebar-right")))
|
const sync = () => setMount(document.getElementById("opencode-titlebar-right"))
|
||||||
|
onMount(sync)
|
||||||
|
createEffect(on(language.direction, sync, { defer: true }))
|
||||||
return mount
|
return mount
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,9 +127,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
return {
|
return {
|
||||||
visible: version !== undefined || installing,
|
visible: version !== undefined || installing,
|
||||||
installing,
|
installing,
|
||||||
label: "Update",
|
label: language.t("titlebar.update"),
|
||||||
ariaLabel: language.t("toast.update.action.installRestart"),
|
ariaLabel: language.t("toast.update.action.installRestart"),
|
||||||
title: version ? `Update ${version}` : undefined,
|
title: version ? language.t("titlebar.updateVersion", { version }) : undefined,
|
||||||
onInstall: () => props.update?.install(),
|
onInstall: () => props.update?.install(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -169,7 +183,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
"padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0,
|
"padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0,
|
||||||
width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||||
"max-width": windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
"max-width": windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||||
"align-self": windows() ? "flex-start" : undefined,
|
// Native Windows caption controls remain on the physical right in both writing directions.
|
||||||
|
"margin-right": windows() ? "auto" : undefined,
|
||||||
}}
|
}}
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
>
|
>
|
||||||
@@ -338,40 +353,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
keybind: "mod+shift+t",
|
keybind: "mod+shift+t",
|
||||||
onSelect: () => tabsStoreActions.reopenClosedTab(),
|
onSelect: () => tabsStoreActions.reopenClosedTab(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: `tab.prev`,
|
|
||||||
category: "tab",
|
|
||||||
title: "",
|
|
||||||
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
|
|
||||||
hidden: true,
|
|
||||||
onSelect: () => {
|
|
||||||
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
|
||||||
if (index === -1) return
|
|
||||||
|
|
||||||
index -= 1
|
|
||||||
if (index === -1) index = tabsStore.length - 1
|
|
||||||
|
|
||||||
const next = tabsStore[index]
|
|
||||||
if (next) tabs.select(next)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: `tab.next`,
|
|
||||||
category: "tab",
|
|
||||||
title: "",
|
|
||||||
keybind: `mod+option+ArrowRight,ctrl+tab`,
|
|
||||||
hidden: true,
|
|
||||||
onSelect: () => {
|
|
||||||
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
|
||||||
if (index === -1) return
|
|
||||||
|
|
||||||
index += 1
|
|
||||||
if (index === tabsStore.length) index = 0
|
|
||||||
|
|
||||||
const next = tabsStore[index]
|
|
||||||
if (next) tabs.select(next)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
].filter((v) => v !== undefined)
|
].filter((v) => v !== undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -429,27 +410,25 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
}}
|
}}
|
||||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||||
/>
|
/>
|
||||||
<Show when={!creating()}>
|
<TooltipV2
|
||||||
<TooltipV2
|
placement="bottom"
|
||||||
placement="bottom"
|
value={
|
||||||
value={
|
<>
|
||||||
<>
|
{language.t("command.session.new")}
|
||||||
{language.t("command.session.new")}
|
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
|
||||||
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
|
</>
|
||||||
</>
|
}
|
||||||
}
|
>
|
||||||
>
|
<IconButtonV2
|
||||||
<IconButtonV2
|
type="button"
|
||||||
type="button"
|
variant="ghost-muted"
|
||||||
variant="ghost-muted"
|
size="large"
|
||||||
size="large"
|
class="shrink-0"
|
||||||
class="shrink-0"
|
icon={<IconV2 name="plus" />}
|
||||||
icon={<IconV2 name="plus" />}
|
onClick={openNewTab}
|
||||||
onClick={openNewTab}
|
aria-label={language.t("command.session.new")}
|
||||||
aria-label={language.t("command.session.new")}
|
/>
|
||||||
/>
|
</TooltipV2>
|
||||||
</TooltipV2>
|
|
||||||
</Show>
|
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
<TitlebarV2Right state={v2RightState()} />
|
<TitlebarV2Right state={v2RightState()} />
|
||||||
</div>
|
</div>
|
||||||
@@ -551,7 +530,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
<div
|
<div
|
||||||
class="flex items-center shrink-0"
|
class="flex items-center shrink-0"
|
||||||
classList={{
|
classList={{
|
||||||
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
|
"ltr:-translate-x-[36px] rtl:translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
|
||||||
"duration-180 ease-out": !layout.sidebar.opened(),
|
"duration-180 ease-out": !layout.sidebar.opened(),
|
||||||
"duration-180 ease-in": layout.sidebar.opened(),
|
"duration-180 ease-in": layout.sidebar.opened(),
|
||||||
}}
|
}}
|
||||||
@@ -649,7 +628,7 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
|||||||
aria-label={props.state.ariaLabel}
|
aria-label={props.state.ariaLabel}
|
||||||
>
|
>
|
||||||
<span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-within:opacity-100 group-focus-within:translate-x-0 motion-reduce:translate-x-0">
|
<span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-within:opacity-100 group-focus-within:translate-x-0 motion-reduce:translate-x-0">
|
||||||
Update
|
{props.state.label}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex size-5 shrink-0 items-center justify-center">
|
<span class="flex size-5 shrink-0 items-center justify-center">
|
||||||
<Show
|
<Show
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const DrawerContent = <T extends ValidComponent = "div">(props: DynamicProps<T,
|
|||||||
<DrawerPrimitive.Content
|
<DrawerPrimitive.Content
|
||||||
class={props.class}
|
class={props.class}
|
||||||
classList={{
|
classList={{
|
||||||
"group/drawer-content fixed inset-y-[6px] right-[6px] left-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true,
|
"group/drawer-content fixed inset-y-[6px] end-[6px] start-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true,
|
||||||
}}
|
}}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
|
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
export function WindowsAppMenu(props: {
|
export function WindowsAppMenu(props: {
|
||||||
command: ReturnType<typeof useCommand>
|
command: ReturnType<typeof useCommand>
|
||||||
@@ -15,6 +16,7 @@ export function WindowsAppMenu(props: {
|
|||||||
variant?: "legacy" | "v2"
|
variant?: "legacy" | "v2"
|
||||||
}) {
|
}) {
|
||||||
let lastFocused: HTMLElement | undefined
|
let lastFocused: HTMLElement | undefined
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
const rememberFocus = () => {
|
const rememberFocus = () => {
|
||||||
const active = document.activeElement
|
const active = document.activeElement
|
||||||
@@ -43,7 +45,7 @@ export function WindowsAppMenu(props: {
|
|||||||
runAction(entry.action)
|
runAction(entry.action)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (entry.href) props.platform.openLink(entry.href)
|
if (entry.href) props.platform.openExternal(entry.href)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -58,7 +60,7 @@ export function WindowsAppMenu(props: {
|
|||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
size="large"
|
size="large"
|
||||||
icon={<IconV2 name="menu" />}
|
icon={<IconV2 name="menu" />}
|
||||||
aria-label="OpenCode menu"
|
aria-label={language.t("desktop.menu.ariaLabel")}
|
||||||
onPointerDown={rememberFocus}
|
onPointerDown={rememberFocus}
|
||||||
onKeyDown={rememberFocus}
|
onKeyDown={rememberFocus}
|
||||||
/>
|
/>
|
||||||
@@ -69,7 +71,7 @@ export function WindowsAppMenu(props: {
|
|||||||
icon="menu"
|
icon="menu"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="titlebar-icon rounded-md shrink-0"
|
class="titlebar-icon rounded-md shrink-0"
|
||||||
aria-label="OpenCode menu"
|
aria-label={language.t("desktop.menu.ariaLabel")}
|
||||||
onPointerDown={rememberFocus}
|
onPointerDown={rememberFocus}
|
||||||
onKeyDown={rememberFocus}
|
onKeyDown={rememberFocus}
|
||||||
/>
|
/>
|
||||||
@@ -79,7 +81,7 @@ export function WindowsAppMenu(props: {
|
|||||||
<DropdownMenu.Group>
|
<DropdownMenu.Group>
|
||||||
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
|
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
|
||||||
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
|
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
|
||||||
<DesktopMenuSubmenu label={menu.label}>
|
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
|
||||||
{menu.items
|
{menu.items
|
||||||
?.filter((entry) => desktopMenuVisible(entry, "windows"))
|
?.filter((entry) => desktopMenuVisible(entry, "windows"))
|
||||||
.map((entry) =>
|
.map((entry) =>
|
||||||
@@ -87,7 +89,7 @@ export function WindowsAppMenu(props: {
|
|||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
) : (
|
) : (
|
||||||
<DesktopMenuItem
|
<DesktopMenuItem
|
||||||
label={entry.label ?? ""}
|
label={entry.labelKey ? language.t(entry.labelKey) : ""}
|
||||||
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
|
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
|
||||||
disabled={entry.command ? commandDisabled(entry.command) : false}
|
disabled={entry.command ? commandDisabled(entry.command) : false}
|
||||||
onSelect={() => runEntry(entry)}
|
onSelect={() => runEntry(entry)}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export const ACCEPTED_FILE_EXTENSIONS = Array.from(
|
|||||||
),
|
),
|
||||||
).sort()
|
).sort()
|
||||||
|
|
||||||
export function filePickerFilters(ext?: string[]) {
|
export function filePickerFilters(name: string, ext?: string[]) {
|
||||||
if (!ext || ext.length === 0) return undefined
|
if (!ext || ext.length === 0) return undefined
|
||||||
return [{ name: "Files", extensions: ext }]
|
return [{ name, extensions: ext }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,29 @@ describe("file path helpers", () => {
|
|||||||
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
|
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("normalizes Windows directory separators", () => {
|
||||||
|
const path = createPathHelpers(() => "C:\\repo")
|
||||||
|
expect(path.normalizeDir("frontend\\")).toBe("frontend")
|
||||||
|
expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src")
|
||||||
|
expect(path.normalizeDir("C:\\repo\\frontend\\")).toBe("frontend")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizes separators for Windows roots written with forward slashes", () => {
|
||||||
|
const path = createPathHelpers(() => "C:/repo")
|
||||||
|
expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizes separators for Windows UNC roots", () => {
|
||||||
|
const path = createPathHelpers(() => "\\\\server\\share")
|
||||||
|
expect(path.normalizeDir("\\\\server\\share\\frontend\\")).toBe("frontend")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves backslashes in POSIX directory names", () => {
|
||||||
|
const path = createPathHelpers(() => "/repo")
|
||||||
|
expect(path.normalizeDir("literal\\name\\")).toBe("literal\\name\\")
|
||||||
|
expect(path.normalizeDir("literal\\name/")).toBe("literal\\name")
|
||||||
|
})
|
||||||
|
|
||||||
test("keeps query/hash stripping behavior stable", () => {
|
test("keeps query/hash stripping behavior stable", () => {
|
||||||
expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts")
|
expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts")
|
||||||
expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts")
|
expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts")
|
||||||
|
|||||||
@@ -140,7 +140,12 @@ export function createPathHelpers(scope: () => string) {
|
|||||||
return normalize(tabValue)
|
return normalize(tabValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "")
|
const normalizeDir = (input: string) => {
|
||||||
|
const path = normalize(input)
|
||||||
|
const root = scope()
|
||||||
|
const windows = /^[A-Za-z]:/.test(root) || root.startsWith("\\\\")
|
||||||
|
return (windows ? path.replace(/\\/g, "/") : path).replace(/\/+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
normalize,
|
normalize,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
bootstrapDirectory,
|
bootstrapDirectory,
|
||||||
loadAgentsQuery,
|
loadAgentsQuery,
|
||||||
loadCommands,
|
loadCommands,
|
||||||
|
loadGlobalConfigQuery,
|
||||||
loadPathQuery,
|
loadPathQuery,
|
||||||
loadProjectsQuery,
|
loadProjectsQuery,
|
||||||
loadProvidersQuery,
|
loadProvidersQuery,
|
||||||
@@ -76,6 +77,7 @@ function directoryState() {
|
|||||||
|
|
||||||
describe("bootstrapDirectory", () => {
|
describe("bootstrapDirectory", () => {
|
||||||
test("uses legacy MCP endpoints while refreshing a v1 directory", async () => {
|
test("uses legacy MCP endpoints while refreshing a v1 directory", async () => {
|
||||||
|
const legacyConfigReads: string[] = []
|
||||||
const mcpReads: string[] = []
|
const mcpReads: string[] = []
|
||||||
const [store, setStore] = directoryState()
|
const [store, setStore] = directoryState()
|
||||||
|
|
||||||
@@ -91,7 +93,12 @@ describe("bootstrapDirectory", () => {
|
|||||||
},
|
},
|
||||||
sdk: {
|
sdk: {
|
||||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||||
config: { get: async () => ({ data: {} }) },
|
config: {
|
||||||
|
get: async () => {
|
||||||
|
legacyConfigReads.push("directory")
|
||||||
|
return { data: {} }
|
||||||
|
},
|
||||||
|
},
|
||||||
session: { status: async () => ({ data: {} }) },
|
session: { status: async () => ({ data: {} }) },
|
||||||
vcs: { get: async () => ({ data: undefined }) },
|
vcs: { get: async () => ({ data: undefined }) },
|
||||||
command: {
|
command: {
|
||||||
@@ -134,8 +141,88 @@ describe("bootstrapDirectory", () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||||
|
|
||||||
expect(store.status).toBe("complete")
|
expect(store.status).toBe("complete")
|
||||||
|
expect(legacyConfigReads).toEqual(["directory"])
|
||||||
expect(mcpReads.sort()).toEqual(["command", "resource", "status"])
|
expect(mcpReads.sort()).toEqual(["command", "resource", "status"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("skips legacy config while refreshing a v2 directory", async () => {
|
||||||
|
const [store, setStore] = directoryState()
|
||||||
|
|
||||||
|
await bootstrapDirectory({
|
||||||
|
directory: "/project",
|
||||||
|
scope: ServerScope.local,
|
||||||
|
mcp: false,
|
||||||
|
global: {
|
||||||
|
config: {} satisfies Config,
|
||||||
|
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||||
|
project: [{ id: "project", worktree: "/project" } as Project],
|
||||||
|
provider,
|
||||||
|
},
|
||||||
|
sdk: {
|
||||||
|
config: {
|
||||||
|
get: async () => {
|
||||||
|
throw new Error("legacy directory config should not be called")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient,
|
||||||
|
api,
|
||||||
|
store,
|
||||||
|
setStore,
|
||||||
|
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||||
|
loadSessions() {},
|
||||||
|
translate: (key) => key,
|
||||||
|
queryClient: new QueryClient(),
|
||||||
|
protocol: Promise.resolve("v2"),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.status).toBe("partial")
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||||
|
|
||||||
|
expect(store.status).toBe("complete")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("config queries", () => {
|
||||||
|
test("skips legacy global config for v2 servers", async () => {
|
||||||
|
const sdk = {
|
||||||
|
global: {
|
||||||
|
config: {
|
||||||
|
get: async () => {
|
||||||
|
throw new Error("legacy global config should not be called")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient
|
||||||
|
|
||||||
|
const result = await new QueryClient().fetchQuery(
|
||||||
|
loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v2")),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads legacy global config for v1 servers", async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
const config = { shell: "zsh" } satisfies Config
|
||||||
|
const sdk = {
|
||||||
|
global: {
|
||||||
|
config: {
|
||||||
|
get: async () => {
|
||||||
|
calls.push("global")
|
||||||
|
return { data: config }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient
|
||||||
|
|
||||||
|
const result = await new QueryClient().fetchQuery(
|
||||||
|
loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v1")),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual(config)
|
||||||
|
expect(calls).toEqual(["global"])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("query keys", () => {
|
describe("query keys", () => {
|
||||||
|
|||||||
@@ -105,10 +105,13 @@ function showErrors(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient, protocol?: Promise<ServerProtocol>) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, "config"],
|
queryKey: [scope, "config"],
|
||||||
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
queryFn: async () => {
|
||||||
|
if ((await protocol) !== "v1") return {}
|
||||||
|
return retry(() => sdk.global.config.get().then((x) => x.data!))
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
type ProjectApi = {
|
type ProjectApi = {
|
||||||
@@ -149,7 +152,7 @@ export async function bootstrapGlobal(input: {
|
|||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
}) {
|
}) {
|
||||||
const slow = [
|
const slow = [
|
||||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK, input.protocol)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient.fetchQuery(
|
input.queryClient.fetchQuery(
|
||||||
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
|
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
|
||||||
@@ -376,7 +379,10 @@ export async function bootstrapDirectory(input: {
|
|||||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
|
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
|
||||||
.then((data) => input.setStore("agent", data)),
|
.then((data) => input.setStore("agent", data)),
|
||||||
() =>
|
() =>
|
||||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
retry(async () => {
|
||||||
|
if ((await input.protocol) !== "v1") return
|
||||||
|
return input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))
|
||||||
|
}),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ const rootSession = (input: { id: string; parentID?: string; archived?: number }
|
|||||||
},
|
},
|
||||||
}) as Session
|
}) as Session
|
||||||
|
|
||||||
const userMessage = (id: string, sessionID: string) =>
|
const userMessage = (id: string, sessionID: string, created = 1) =>
|
||||||
({
|
({
|
||||||
id,
|
id,
|
||||||
sessionID,
|
sessionID,
|
||||||
role: "user",
|
role: "user",
|
||||||
time: { created: 1 },
|
time: { created },
|
||||||
agent: "assistant",
|
agent: "assistant",
|
||||||
model: { providerID: "openai", modelID: "gpt" },
|
model: { providerID: "openai", modelID: "gpt" },
|
||||||
}) as Message
|
}) as Message
|
||||||
@@ -370,13 +370,13 @@ describe("applyDirectoryEvent", () => {
|
|||||||
const sessionID = "ses_1"
|
const sessionID = "ses_1"
|
||||||
const [store, setStore] = createStore(
|
const [store, setStore] = createStore(
|
||||||
baseState({
|
baseState({
|
||||||
message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_3", sessionID)] },
|
message: { [sessionID]: [userMessage("msg_z", sessionID, 1), userMessage("msg_b", sessionID, 3)] },
|
||||||
part: { msg_2: [textPart("prt_1", sessionID, "msg_2")] },
|
part: { msg_a: [textPart("prt_1", sessionID, "msg_a")] },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
applyDirectoryEvent({
|
applyDirectoryEvent({
|
||||||
event: { type: "message.updated", properties: { info: userMessage("msg_2", sessionID) } },
|
event: { type: "message.updated", properties: { info: userMessage("msg_a", sessionID, 2) } },
|
||||||
store,
|
store,
|
||||||
setStore,
|
setStore,
|
||||||
push() {},
|
push() {},
|
||||||
@@ -384,14 +384,14 @@ describe("applyDirectoryEvent", () => {
|
|||||||
loadLsp() {},
|
loadLsp() {},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2", "msg_3"])
|
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a", "msg_b"])
|
||||||
|
|
||||||
applyDirectoryEvent({
|
applyDirectoryEvent({
|
||||||
event: {
|
event: {
|
||||||
type: "message.updated",
|
type: "message.updated",
|
||||||
properties: {
|
properties: {
|
||||||
info: {
|
info: {
|
||||||
...userMessage("msg_2", sessionID),
|
...userMessage("msg_a", sessionID, 2),
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
} as Message,
|
} as Message,
|
||||||
},
|
},
|
||||||
@@ -403,10 +403,10 @@ describe("applyDirectoryEvent", () => {
|
|||||||
loadLsp() {},
|
loadLsp() {},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.message[sessionID]?.find((x) => x.id === "msg_2")?.role).toBe("assistant")
|
expect(store.message[sessionID]?.find((x) => x.id === "msg_a")?.role).toBe("assistant")
|
||||||
|
|
||||||
applyDirectoryEvent({
|
applyDirectoryEvent({
|
||||||
event: { type: "message.removed", properties: { sessionID, messageID: "msg_2" } },
|
event: { type: "message.removed", properties: { sessionID, messageID: "msg_a" } },
|
||||||
store,
|
store,
|
||||||
setStore,
|
setStore,
|
||||||
push() {},
|
push() {},
|
||||||
@@ -414,8 +414,8 @@ describe("applyDirectoryEvent", () => {
|
|||||||
loadLsp() {},
|
loadLsp() {},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_3"])
|
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_b"])
|
||||||
expect(store.part.msg_2).toBeUndefined()
|
expect(store.part.msg_a).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("upserts and prunes message parts", () => {
|
test("upserts and prunes message parts", () => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { State, VcsCache } from "./types"
|
|||||||
import { trimSessions } from "./session-trim"
|
import { trimSessions } from "./session-trim"
|
||||||
import { dropSessionCaches } from "./session-cache"
|
import { dropSessionCaches } from "./session-cache"
|
||||||
import { diffs as list, message as clean } from "@/utils/diffs"
|
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||||
|
import { messageKey } from "@/utils/session-message"
|
||||||
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const SESSION_CONTENT_EVENTS = new Set([
|
const SESSION_CONTENT_EVENTS = new Set([
|
||||||
@@ -275,7 +276,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
input.setStore("message", info.sessionID, [info])
|
input.setStore("message", info.sessionID, [info])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
const result = Binary.search(messages, info.id, (m) => m.id)
|
const result = Binary.search(messages, messageKey(info), messageKey)
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
input.setStore("message", info.sessionID, result.index, reconcile(info))
|
input.setStore("message", info.sessionID, result.index, reconcile(info))
|
||||||
break
|
break
|
||||||
@@ -295,8 +296,8 @@ export function applyDirectoryEvent(input: {
|
|||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const messages = draft.message[props.sessionID]
|
const messages = draft.message[props.sessionID]
|
||||||
if (messages) {
|
if (messages) {
|
||||||
const result = Binary.search(messages, props.messageID, (m) => m.id)
|
const index = messages.findIndex((message) => message.id === props.messageID)
|
||||||
if (result.found) messages.splice(result.index, 1)
|
if (index >= 0) messages.splice(index, 1)
|
||||||
}
|
}
|
||||||
const parts = draft.part[props.messageID]
|
const parts = draft.part[props.messageID]
|
||||||
if (parts) {
|
if (parts) {
|
||||||
@@ -322,7 +323,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
input.setStore("part", part.messageID, [part])
|
input.setStore("part", part.messageID, [part])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
const result = Binary.search(parts, part.id, (p) => p.id)
|
const result = Binary.search(parts, part.id, (item) => item.id)
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
input.setStore("part", part.messageID, result.index, reconcile(part))
|
input.setStore("part", part.messageID, result.index, reconcile(part))
|
||||||
break
|
break
|
||||||
@@ -345,13 +346,13 @@ export function applyDirectoryEvent(input: {
|
|||||||
)
|
)
|
||||||
const parts = input.store.part[props.messageID]
|
const parts = input.store.part[props.messageID]
|
||||||
if (!parts) break
|
if (!parts) break
|
||||||
const result = Binary.search(parts, props.partID, (p) => p.id)
|
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
input.setStore(
|
input.setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const list = draft.part[props.messageID]
|
const list = draft.part[props.messageID]
|
||||||
if (!list) return
|
if (!list) return
|
||||||
const next = Binary.search(list, props.partID, (p) => p.id)
|
const next = Binary.search(list, props.partID, (part) => part.id)
|
||||||
if (!next.found) return
|
if (!next.found) return
|
||||||
list.splice(next.index, 1)
|
list.splice(next.index, 1)
|
||||||
if (list.length === 0) delete draft.part[props.messageID]
|
if (list.length === 0) delete draft.part[props.messageID]
|
||||||
@@ -364,7 +365,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
|
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
|
||||||
const parts = input.store.part[props.messageID]
|
const parts = input.store.part[props.messageID]
|
||||||
if (!parts) break
|
if (!parts) break
|
||||||
const result = Binary.search(parts, props.partID, (p) => p.id)
|
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||||
if (!result.found) break
|
if (!result.found) break
|
||||||
const field = props.field as keyof (typeof parts)[number]
|
const field = props.field as keyof (typeof parts)[number]
|
||||||
const current = parts[result.index]?.[field]
|
const current = parts[result.index]?.[field]
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ describe("normalizeProviderList", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(result.connected).toEqual(["openai"])
|
expect(result.connected).toEqual(["openai"])
|
||||||
|
expect(result.defaultModel).toEqual({ providerID: "openai", modelID: "gpt-5" })
|
||||||
expect(result.default).toEqual({ openai: "gpt-5" })
|
expect(result.default).toEqual({ openai: "gpt-5" })
|
||||||
expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined()
|
expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined()
|
||||||
expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({
|
expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({
|
||||||
@@ -113,6 +114,10 @@ describe("normalizeProviderList", () => {
|
|||||||
variants: { high: {} },
|
variants: { high: {} },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("preserves an empty current default", () => {
|
||||||
|
expect(normalizeProviderList([] as ProviderListOutput["data"], [], null).defaultModel).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("directoryKey", () => {
|
describe("directoryKey", () => {
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export function normalizeProviderList(
|
|||||||
return {
|
return {
|
||||||
all,
|
all,
|
||||||
connected: providers.map((provider) => provider.id),
|
connected: providers.map((provider) => provider.id),
|
||||||
|
defaultModel: defaultModel ? { providerID: defaultModel.providerID, modelID: defaultModel.id } : null,
|
||||||
default: Object.fromEntries(
|
default: Object.fromEntries(
|
||||||
providers.flatMap((provider) => {
|
providers.flatMap((provider) => {
|
||||||
const model =
|
const model =
|
||||||
|
|||||||
@@ -2,100 +2,46 @@ import * as i18n from "@solid-primitives/i18n"
|
|||||||
import { createEffect, createMemo, createResource } from "solid-js"
|
import { createEffect, createMemo, createResource } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
|
import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { dict as en } from "@/i18n/en"
|
import { dict as en } from "@/i18n/en"
|
||||||
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||||
|
import {
|
||||||
|
createDesktopNativeBundle,
|
||||||
|
detectDesktopNativeLocale,
|
||||||
|
DESKTOP_NATIVE_ENGLISH,
|
||||||
|
DESKTOP_NATIVE_LABELS,
|
||||||
|
DESKTOP_NATIVE_LOCALES,
|
||||||
|
DESKTOP_NATIVE_LOCALE_TAGS,
|
||||||
|
type DesktopNativeBundle,
|
||||||
|
type DesktopNativeLocale,
|
||||||
|
} from "@/i18n/desktop-native"
|
||||||
|
|
||||||
export type Locale =
|
export type Locale = DesktopNativeLocale
|
||||||
| "en"
|
export type Direction = "ltr" | "rtl"
|
||||||
| "zh"
|
|
||||||
| "zht"
|
const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "ur", "pa", "fa", "dv"])
|
||||||
| "ko"
|
|
||||||
| "de"
|
function localeDirection(locale: Locale): Direction {
|
||||||
| "es"
|
return RTL_LOCALES.has(locale) ? "rtl" : "ltr"
|
||||||
| "fr"
|
}
|
||||||
| "da"
|
|
||||||
| "ja"
|
|
||||||
| "pl"
|
|
||||||
| "ru"
|
|
||||||
| "uk"
|
|
||||||
| "ar"
|
|
||||||
| "no"
|
|
||||||
| "br"
|
|
||||||
| "th"
|
|
||||||
| "bs"
|
|
||||||
| "tr"
|
|
||||||
|
|
||||||
type RawDictionary = typeof en & typeof uiEn
|
type RawDictionary = typeof en & typeof uiEn
|
||||||
type Dictionary = i18n.Flatten<RawDictionary>
|
type Dictionary = i18n.Flatten<RawDictionary>
|
||||||
|
type PluralKey =
|
||||||
|
| UiI18nPluralKey
|
||||||
|
| "session.question.pending"
|
||||||
|
| "session.followupDock.summary"
|
||||||
|
| "session.revertDock.summary"
|
||||||
type Source = { dict: Record<string, string> }
|
type Source = { dict: Record<string, string> }
|
||||||
|
|
||||||
function cookie(locale: Locale) {
|
function cookie(locale: Locale) {
|
||||||
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
|
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOCALES: readonly Locale[] = [
|
const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES
|
||||||
"en",
|
|
||||||
"zh",
|
|
||||||
"zht",
|
|
||||||
"ko",
|
|
||||||
"de",
|
|
||||||
"es",
|
|
||||||
"fr",
|
|
||||||
"da",
|
|
||||||
"ja",
|
|
||||||
"pl",
|
|
||||||
"ru",
|
|
||||||
"uk",
|
|
||||||
"bs",
|
|
||||||
"ar",
|
|
||||||
"no",
|
|
||||||
"br",
|
|
||||||
"th",
|
|
||||||
"tr",
|
|
||||||
]
|
|
||||||
|
|
||||||
const INTL: Record<Locale, string> = {
|
const INTL = DESKTOP_NATIVE_LOCALE_TAGS
|
||||||
en: "en",
|
|
||||||
zh: "zh-Hans",
|
|
||||||
zht: "zh-Hant",
|
|
||||||
ko: "ko",
|
|
||||||
de: "de",
|
|
||||||
es: "es",
|
|
||||||
fr: "fr",
|
|
||||||
da: "da",
|
|
||||||
ja: "ja",
|
|
||||||
pl: "pl",
|
|
||||||
ru: "ru",
|
|
||||||
uk: "uk",
|
|
||||||
ar: "ar",
|
|
||||||
no: "nb-NO",
|
|
||||||
br: "pt-BR",
|
|
||||||
th: "th",
|
|
||||||
bs: "bs",
|
|
||||||
tr: "tr",
|
|
||||||
}
|
|
||||||
|
|
||||||
const LABEL_KEY: Record<Locale, keyof Dictionary> = {
|
|
||||||
en: "language.en",
|
|
||||||
zh: "language.zh",
|
|
||||||
zht: "language.zht",
|
|
||||||
ko: "language.ko",
|
|
||||||
de: "language.de",
|
|
||||||
es: "language.es",
|
|
||||||
fr: "language.fr",
|
|
||||||
da: "language.da",
|
|
||||||
ja: "language.ja",
|
|
||||||
pl: "language.pl",
|
|
||||||
ru: "language.ru",
|
|
||||||
uk: "language.uk",
|
|
||||||
ar: "language.ar",
|
|
||||||
no: "language.no",
|
|
||||||
br: "language.br",
|
|
||||||
th: "language.th",
|
|
||||||
bs: "language.bs",
|
|
||||||
tr: "language.tr",
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = i18n.flatten({ ...en, ...uiEn })
|
const base = i18n.flatten({ ...en, ...uiEn })
|
||||||
const dicts = new Map<Locale, Dictionary>([["en", base]])
|
const dicts = new Map<Locale, Dictionary>([["en", base]])
|
||||||
@@ -121,6 +67,50 @@ const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
|
|||||||
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
|
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
|
||||||
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
|
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
|
||||||
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
|
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
|
||||||
|
hi: () => merge(import("@/i18n/hi"), import("@opencode-ai/ui/i18n/hi")),
|
||||||
|
nl: () => merge(import("@/i18n/nl"), import("@opencode-ai/ui/i18n/nl")),
|
||||||
|
id: () => merge(import("@/i18n/id"), import("@opencode-ai/ui/i18n/id")),
|
||||||
|
vi: () => merge(import("@/i18n/vi"), import("@opencode-ai/ui/i18n/vi")),
|
||||||
|
it: () => merge(import("@/i18n/it"), import("@opencode-ai/ui/i18n/it")),
|
||||||
|
ur: () => merge(import("@/i18n/ur"), import("@opencode-ai/ui/i18n/ur")),
|
||||||
|
pa: () => merge(import("@/i18n/pa"), import("@opencode-ai/ui/i18n/pa")),
|
||||||
|
az: () => merge(import("@/i18n/az"), import("@opencode-ai/ui/i18n/az")),
|
||||||
|
fi: () => merge(import("@/i18n/fi"), import("@opencode-ai/ui/i18n/fi")),
|
||||||
|
sv: () => merge(import("@/i18n/sv"), import("@opencode-ai/ui/i18n/sv")),
|
||||||
|
am: () => merge(import("@/i18n/am"), import("@opencode-ai/ui/i18n/am")),
|
||||||
|
bg: () => merge(import("@/i18n/bg"), import("@opencode-ai/ui/i18n/bg")),
|
||||||
|
bn: () => merge(import("@/i18n/bn"), import("@opencode-ai/ui/i18n/bn")),
|
||||||
|
ca: () => merge(import("@/i18n/ca"), import("@opencode-ai/ui/i18n/ca")),
|
||||||
|
cs: () => merge(import("@/i18n/cs"), import("@opencode-ai/ui/i18n/cs")),
|
||||||
|
dv: () => merge(import("@/i18n/dv"), import("@opencode-ai/ui/i18n/dv")),
|
||||||
|
dz: () => merge(import("@/i18n/dz"), import("@opencode-ai/ui/i18n/dz")),
|
||||||
|
el: () => merge(import("@/i18n/el"), import("@opencode-ai/ui/i18n/el")),
|
||||||
|
et: () => merge(import("@/i18n/et"), import("@opencode-ai/ui/i18n/et")),
|
||||||
|
fa: () => merge(import("@/i18n/fa"), import("@opencode-ai/ui/i18n/fa")),
|
||||||
|
fo: () => merge(import("@/i18n/fo"), import("@opencode-ai/ui/i18n/fo")),
|
||||||
|
hr: () => merge(import("@/i18n/hr"), import("@opencode-ai/ui/i18n/hr")),
|
||||||
|
hu: () => merge(import("@/i18n/hu"), import("@opencode-ai/ui/i18n/hu")),
|
||||||
|
hy: () => merge(import("@/i18n/hy"), import("@opencode-ai/ui/i18n/hy")),
|
||||||
|
is: () => merge(import("@/i18n/is"), import("@opencode-ai/ui/i18n/is")),
|
||||||
|
ka: () => merge(import("@/i18n/ka"), import("@opencode-ai/ui/i18n/ka")),
|
||||||
|
km: () => merge(import("@/i18n/km"), import("@opencode-ai/ui/i18n/km")),
|
||||||
|
lo: () => merge(import("@/i18n/lo"), import("@opencode-ai/ui/i18n/lo")),
|
||||||
|
lt: () => merge(import("@/i18n/lt"), import("@opencode-ai/ui/i18n/lt")),
|
||||||
|
lv: () => merge(import("@/i18n/lv"), import("@opencode-ai/ui/i18n/lv")),
|
||||||
|
mk: () => merge(import("@/i18n/mk"), import("@opencode-ai/ui/i18n/mk")),
|
||||||
|
mn: () => merge(import("@/i18n/mn"), import("@opencode-ai/ui/i18n/mn")),
|
||||||
|
ms: () => merge(import("@/i18n/ms"), import("@opencode-ai/ui/i18n/ms")),
|
||||||
|
my: () => merge(import("@/i18n/my"), import("@opencode-ai/ui/i18n/my")),
|
||||||
|
ne: () => merge(import("@/i18n/ne"), import("@opencode-ai/ui/i18n/ne")),
|
||||||
|
ro: () => merge(import("@/i18n/ro"), import("@opencode-ai/ui/i18n/ro")),
|
||||||
|
si: () => merge(import("@/i18n/si"), import("@opencode-ai/ui/i18n/si")),
|
||||||
|
sk: () => merge(import("@/i18n/sk"), import("@opencode-ai/ui/i18n/sk")),
|
||||||
|
sl: () => merge(import("@/i18n/sl"), import("@opencode-ai/ui/i18n/sl")),
|
||||||
|
sq: () => merge(import("@/i18n/sq"), import("@opencode-ai/ui/i18n/sq")),
|
||||||
|
sr: () => merge(import("@/i18n/sr"), import("@opencode-ai/ui/i18n/sr")),
|
||||||
|
tg: () => merge(import("@/i18n/tg"), import("@opencode-ai/ui/i18n/tg")),
|
||||||
|
tk: () => merge(import("@/i18n/tk"), import("@opencode-ai/ui/i18n/tk")),
|
||||||
|
uz: () => merge(import("@/i18n/uz"), import("@opencode-ai/ui/i18n/uz")),
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadDict(locale: Locale) {
|
function loadDict(locale: Locale) {
|
||||||
@@ -138,42 +128,9 @@ export function loadLocaleDict(locale: Locale) {
|
|||||||
return loadDict(locale).then(() => undefined)
|
return loadDict(locale).then(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
|
||||||
{ locale: "en", match: (language) => language.startsWith("en") },
|
|
||||||
{ locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
|
|
||||||
{ locale: "zh", match: (language) => language.startsWith("zh") },
|
|
||||||
{ locale: "ko", match: (language) => language.startsWith("ko") },
|
|
||||||
{ locale: "de", match: (language) => language.startsWith("de") },
|
|
||||||
{ locale: "es", match: (language) => language.startsWith("es") },
|
|
||||||
{ locale: "fr", match: (language) => language.startsWith("fr") },
|
|
||||||
{ locale: "da", match: (language) => language.startsWith("da") },
|
|
||||||
{ locale: "ja", match: (language) => language.startsWith("ja") },
|
|
||||||
{ locale: "pl", match: (language) => language.startsWith("pl") },
|
|
||||||
{ locale: "ru", match: (language) => language.startsWith("ru") },
|
|
||||||
{ locale: "uk", match: (language) => language.startsWith("uk") },
|
|
||||||
{ locale: "ar", match: (language) => language.startsWith("ar") },
|
|
||||||
{
|
|
||||||
locale: "no",
|
|
||||||
match: (language) => language.startsWith("no") || language.startsWith("nb") || language.startsWith("nn"),
|
|
||||||
},
|
|
||||||
{ locale: "br", match: (language) => language.startsWith("pt") },
|
|
||||||
{ locale: "th", match: (language) => language.startsWith("th") },
|
|
||||||
{ locale: "bs", match: (language) => language.startsWith("bs") },
|
|
||||||
{ locale: "tr", match: (language) => language.startsWith("tr") },
|
|
||||||
]
|
|
||||||
|
|
||||||
function detectLocale(): Locale {
|
function detectLocale(): Locale {
|
||||||
if (typeof navigator !== "object") return "en"
|
if (typeof navigator !== "object") return "en"
|
||||||
|
return detectDesktopNativeLocale(navigator.languages?.length ? navigator.languages : [navigator.language])
|
||||||
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
|
||||||
for (const language of languages) {
|
|
||||||
if (!language) continue
|
|
||||||
const normalized = language.toLowerCase()
|
|
||||||
const match = localeMatchers.find((entry) => entry.match(normalized))
|
|
||||||
if (match) return match.locale
|
|
||||||
}
|
|
||||||
|
|
||||||
return "en"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeLocale(value: string): Locale {
|
export function normalizeLocale(value: string): Locale {
|
||||||
@@ -194,12 +151,22 @@ function readStoredLocale() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const warm = readStoredLocale() ?? detectLocale()
|
const warm = readStoredLocale() ?? detectLocale()
|
||||||
if (warm !== "en") void loadDict(warm)
|
const initialLocale =
|
||||||
|
warm === "en"
|
||||||
|
? Promise.resolve(warm)
|
||||||
|
: loadDict(warm).then(
|
||||||
|
() => warm,
|
||||||
|
() => "en" as const,
|
||||||
|
)
|
||||||
|
|
||||||
|
export function loadInitialLocale() {
|
||||||
|
return initialLocale
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
|
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
|
||||||
name: "Language",
|
name: "Language",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: (props: { locale?: Locale }) => {
|
init: (props: { locale?: Locale; onNativeTranslations?: (bundle: DesktopNativeBundle) => void }) => {
|
||||||
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
|
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.global("language", ["language.v1"]),
|
Persist.global("language", ["language.v1"]),
|
||||||
@@ -210,6 +177,13 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
|
|
||||||
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
||||||
const intl = createMemo(() => INTL[locale()])
|
const intl = createMemo(() => INTL[locale()])
|
||||||
|
const [layout, setLayout] = createStore({ direction: undefined as Direction | undefined })
|
||||||
|
const direction = createMemo(() => layout.direction ?? localeDirection(locale()))
|
||||||
|
const layoutLocale = createMemo(() => {
|
||||||
|
if (!layout.direction) return intl()
|
||||||
|
// Kobalte derives menu direction from locale rather than accepting a direction override.
|
||||||
|
return layout.direction === "rtl" ? "ar" : "en"
|
||||||
|
})
|
||||||
|
|
||||||
const [dict] = createResource(locale, loadDict, {
|
const [dict] = createResource(locale, loadDict, {
|
||||||
initialValue: dicts.get(initial) ?? base,
|
initialValue: dicts.get(initial) ?? base,
|
||||||
@@ -220,24 +194,49 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
params?: Record<string, string | number | boolean>,
|
params?: Record<string, string | number | boolean>,
|
||||||
) => string
|
) => string
|
||||||
|
|
||||||
const label = (value: Locale) => t(LABEL_KEY[value])
|
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => {
|
||||||
|
const category = pluralCategory(intl(), count)
|
||||||
|
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
|
||||||
|
const candidate = `${key}.${category}`
|
||||||
|
const fallback = `${key}.other`
|
||||||
|
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count })
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value]
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof document !== "object") return
|
if (typeof document !== "object") return
|
||||||
document.documentElement.lang = locale()
|
const value = locale()
|
||||||
document.cookie = cookie(locale())
|
document.documentElement.lang = intl()
|
||||||
|
document.documentElement.dir = direction()
|
||||||
|
document.cookie = cookie(value)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!props.onNativeTranslations || dict.loading) return
|
||||||
|
const current = dict()
|
||||||
|
if (!current) return
|
||||||
|
props.onNativeTranslations(
|
||||||
|
createDesktopNativeBundle(locale(), (key) => current[key] ?? DESKTOP_NATIVE_ENGLISH[key]),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
locale,
|
locale,
|
||||||
intl,
|
intl,
|
||||||
|
direction,
|
||||||
|
layoutLocale,
|
||||||
locales: LOCALES,
|
locales: LOCALES,
|
||||||
label,
|
label,
|
||||||
t,
|
t,
|
||||||
|
plural,
|
||||||
setLocale(next: Locale) {
|
setLocale(next: Locale) {
|
||||||
setStore("locale", normalizeLocale(next))
|
setStore("locale", normalizeLocale(next))
|
||||||
},
|
},
|
||||||
|
setDirection(next: Direction) {
|
||||||
|
setLayout("direction", next === localeDirection(locale()) ? undefined : next)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createStore } from "solid-js/store"
|
|||||||
import { useModels } from "@/context/models"
|
import { useModels } from "@/context/models"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
|
import { resolveDefaultModel } from "@/hooks/provider-catalog"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { hasCustomAgent, resolveAgent } from "./local-agent"
|
import { hasCustomAgent, resolveAgent } from "./local-agent"
|
||||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||||
@@ -149,10 +150,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const configuredModel = () => {
|
const configuredModel = () => {
|
||||||
const configured = sync().data.config.model
|
const model = resolveDefaultModel(providers.defaultModel(), sync().data.config.model)
|
||||||
if (!configured) return
|
if (!model) return
|
||||||
const [providerID, modelID] = configured.split("/")
|
|
||||||
const model = { providerID, modelID }
|
|
||||||
if (validModel(model)) return model
|
if (validModel(model)) return model
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||||
import { useParams, useSearchParams } from "@solidjs/router"
|
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { ServerSDK } from "./server-sdk"
|
import type { ServerSDK } from "./server-sdk"
|
||||||
import type { ServerSync } from "./server-sync"
|
import type { ServerSync } from "./server-sync"
|
||||||
@@ -118,6 +118,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
const navigate = useNavigate()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -153,6 +154,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
platform,
|
platform,
|
||||||
settings,
|
settings,
|
||||||
language,
|
language,
|
||||||
|
navigate,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
owner ?? undefined,
|
owner ?? undefined,
|
||||||
@@ -217,6 +219,7 @@ function createServerNotificationState(input: {
|
|||||||
platform: ReturnType<typeof usePlatform>
|
platform: ReturnType<typeof usePlatform>
|
||||||
settings: ReturnType<typeof useSettings>
|
settings: ReturnType<typeof useSettings>
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
|
navigate: (href: string) => void
|
||||||
}) {
|
}) {
|
||||||
const serverSDK = () => input.sdk
|
const serverSDK = () => input.sdk
|
||||||
const serverSync = () => input.sync
|
const serverSync = () => input.sync
|
||||||
@@ -353,7 +356,9 @@ function createServerNotificationState(input: {
|
|||||||
|
|
||||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||||
if (settings.notifications.agent()) {
|
if (settings.notifications.agent()) {
|
||||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
|
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
|
||||||
|
input.navigate(href),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -386,7 +391,7 @@ function createServerNotificationState(input: {
|
|||||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||||
if (settings.notifications.errors()) {
|
if (settings.notifications.errors()) {
|
||||||
void platform.notify(language.t("notification.session.error.title"), description, href)
|
void platform.notify(language.t("notification.session.error.title"), description, () => input.navigate(href))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { DesktopMenuAction } from "../desktop-menu"
|
|||||||
import { ServerConnection } from "./server"
|
import { ServerConnection } from "./server"
|
||||||
import type { WslServersPlatform } from "../wsl/types"
|
import type { WslServersPlatform } from "../wsl/types"
|
||||||
import type { UpdaterPlatform } from "../updater"
|
import type { UpdaterPlatform } from "../updater"
|
||||||
|
import type { DraftStore } from "@/utils/draft-store"
|
||||||
|
|
||||||
type PickerPaths = string | string[] | null
|
type PickerPaths = string | string[] | null
|
||||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||||
@@ -31,26 +32,23 @@ type PlatformBase = {
|
|||||||
/** App version */
|
/** App version */
|
||||||
version?: string
|
version?: string
|
||||||
|
|
||||||
/** Open a URL in the default browser */
|
/** Open a web or mail URL in the default system application */
|
||||||
openLink(url: string): void
|
openExternal(url: string): void
|
||||||
|
|
||||||
/** Open a local path in a local app (desktop only) */
|
/** Open a local path in a local app (desktop only) */
|
||||||
openPath?(path: string, app?: string): Promise<void>
|
openPath?(path: string, app?: string): Promise<void>
|
||||||
|
|
||||||
|
/** Open a local file URL in its default app (desktop only) */
|
||||||
|
openLocalFile?(url: string): void
|
||||||
|
|
||||||
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
|
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
|
||||||
revealPath?(path: string): Promise<boolean>
|
revealPath?(path: string): Promise<boolean>
|
||||||
|
|
||||||
/** Restart the app */
|
/** Restart the app */
|
||||||
restart(): Promise<void>
|
restart(): Promise<void>
|
||||||
|
|
||||||
/** Navigate back in history */
|
/** Send a system notification */
|
||||||
back(): void
|
notify(title: string, description?: string, onClick?: () => void): Promise<void>
|
||||||
|
|
||||||
/** Navigate forward in history */
|
|
||||||
forward(): void
|
|
||||||
|
|
||||||
/** Send a system notification (optional deep link) */
|
|
||||||
notify(title: string, description?: string, href?: string): Promise<void>
|
|
||||||
|
|
||||||
/** Open a native attachment picker and read selected files sequentially (desktop only) */
|
/** Open a native attachment picker and read selected files sequentially (desktop only) */
|
||||||
openAttachmentPickerDialog?(
|
openAttachmentPickerDialog?(
|
||||||
@@ -67,6 +65,9 @@ type PlatformBase = {
|
|||||||
/** Storage mechanism, defaults to localStorage */
|
/** Storage mechanism, defaults to localStorage */
|
||||||
storage?: (name?: string) => SyncStorage | AsyncStorage
|
storage?: (name?: string) => SyncStorage | AsyncStorage
|
||||||
|
|
||||||
|
/** Prompt drafts, history, and their blobs. */
|
||||||
|
draftStore?: DraftStore
|
||||||
|
|
||||||
/** Stable platform window identity for window-scoped persistence */
|
/** Stable platform window identity for window-scoped persistence */
|
||||||
windowID?: string
|
windowID?: string
|
||||||
|
|
||||||
@@ -91,9 +92,6 @@ type PlatformBase = {
|
|||||||
/** Set the preferred display backend (desktop only) */
|
/** Set the preferred display backend (desktop only) */
|
||||||
setDisplayBackend?(backend: DisplayBackend): Promise<void>
|
setDisplayBackend?(backend: DisplayBackend): Promise<void>
|
||||||
|
|
||||||
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
|
|
||||||
parseMarkdown?(markdown: string): Promise<string>
|
|
||||||
|
|
||||||
/** Webview zoom level (desktop only) */
|
/** Webview zoom level (desktop only) */
|
||||||
webviewZoom?: Accessor<number>
|
webviewZoom?: Accessor<number>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { createStore, type SetStoreFunction } from "solid-js/store"
|
|||||||
import type { FileSelection } from "@/context/file"
|
import type { FileSelection } from "@/context/file"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import type { BlobReference } from "@/utils/draft-store"
|
||||||
|
import type { Platform } from "@/context/platform"
|
||||||
|
|
||||||
interface PartBase {
|
interface PartBase {
|
||||||
content: string
|
content: string
|
||||||
@@ -37,7 +39,7 @@ export interface ImageAttachmentPart {
|
|||||||
filename: string
|
filename: string
|
||||||
sourcePath?: string
|
sourcePath?: string
|
||||||
mime: string
|
mime: string
|
||||||
dataUrl: string
|
blob: BlobReference
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
||||||
@@ -168,9 +170,9 @@ function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
|
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
|
||||||
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
|
if ("draftID" in scope) return Persist.prompt(Persist.draft(scope.draftID, "prompt"))
|
||||||
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
||||||
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
return Persist.prompt(Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]))
|
||||||
}
|
}
|
||||||
|
|
||||||
function promptStore(initial?: InitialPrompt): PromptStore {
|
function promptStore(initial?: InitialPrompt): PromptStore {
|
||||||
@@ -235,17 +237,22 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<P
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt) {
|
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt, platform?: Platform) {
|
||||||
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)))
|
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)), platform)
|
||||||
return { ready, ...createPromptStateValue(store, setStore) }
|
return { ready, ...createPromptStateValue(store, setStore) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) {
|
export function createPromptSession(
|
||||||
return createPersistedPrompt(promptTarget(serverScope, scope), initial)
|
serverScope: ServerScope,
|
||||||
|
scope: PromptScope,
|
||||||
|
initial?: InitialPrompt,
|
||||||
|
platform?: Platform,
|
||||||
|
) {
|
||||||
|
return createPersistedPrompt(promptTarget(serverScope, scope), initial, platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
|
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
|
||||||
return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial)
|
return createPersistedPrompt(Persist.prompt(Persist.draft(draftID, "prompt")), initial)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptSession = ReturnType<typeof createPromptSession>
|
export type PromptSession = ReturnType<typeof createPromptSession>
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ describe("server session", () => {
|
|||||||
|
|
||||||
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
|
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
|
||||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||||
|
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("extends a current page to include the user for split assistant turns", async () => {
|
test("extends a current page to include the user for split assistant turns", async () => {
|
||||||
@@ -1497,7 +1498,7 @@ describe("server session", () => {
|
|||||||
|
|
||||||
await store.sync("child", { force: true })
|
await store.sync("child", { force: true })
|
||||||
|
|
||||||
expect(store.data.message.child).toEqual([boundary, older])
|
expect(store.data.message.child).toEqual([older, boundary])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves a part update for a message being loaded from history", async () => {
|
test("preserves a part update for a message being loaded from history", async () => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { message as cleanMessage } from "@/utils/diffs"
|
|||||||
import { sessionNotFoundError } from "@/utils/server-errors"
|
import { sessionNotFoundError } from "@/utils/server-errors"
|
||||||
import { rootSession } from "@/utils/session-route"
|
import { rootSession } from "@/utils/session-route"
|
||||||
import { normalizeSessionInfo } from "@/utils/session"
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
import { normalizeSessionMessages } from "@/utils/session-message"
|
import { compareMessages, messageKey, normalizeSessionMessages } from "@/utils/session-message"
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||||
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
|
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
@@ -26,7 +26,6 @@ import type { ServerApi } from "@/utils/server"
|
|||||||
type MessageApi = ServerApi["message"]
|
type MessageApi = ServerApi["message"]
|
||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const initialMessagePageSize = 20
|
const initialMessagePageSize = 20
|
||||||
const historyMessagePageSize = 200
|
const historyMessagePageSize = 200
|
||||||
@@ -64,7 +63,7 @@ type MessagePage = {
|
|||||||
function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] {
|
function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] {
|
||||||
return items
|
return items
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => cmp(a.info.id, b.info.id))
|
.sort((a, b) => compareMessages(a.info, b.info))
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
if (item.info.role === "user") {
|
if (item.info.role === "user") {
|
||||||
return {
|
return {
|
||||||
@@ -111,17 +110,16 @@ function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
|||||||
const part = new Map(page.part.map((item) => [item.id, item.part]))
|
const part = new Map(page.part.map((item) => [item.id, item.part]))
|
||||||
const observed: { messageID: string; parts: Part[] }[] = []
|
const observed: { messageID: string; parts: Part[] }[] = []
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const result = Binary.search(session, item.message.id, (message) => message.id)
|
const result = Binary.search(session, messageKey(item.message), messageKey)
|
||||||
if (!result.found) session.splice(result.index, 0, item.message)
|
const found = result.found
|
||||||
|
if (!found) session.splice(result.index, 0, item.message)
|
||||||
const current = part.get(item.message.id)
|
const current = part.get(item.message.id)
|
||||||
const confirmed = result.found
|
const confirmed = found ? item.parts.filter((part) => current?.some((value) => value.id === part.id)) : []
|
||||||
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found)
|
if (found) observed.push({ messageID: item.message.id, parts: confirmed })
|
||||||
: []
|
|
||||||
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
|
|
||||||
part.set(
|
part.set(
|
||||||
item.message.id,
|
item.message.id,
|
||||||
merge(
|
merge(
|
||||||
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
|
found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
|
||||||
item.parts.filter((part) => !confirmed.includes(part)),
|
item.parts.filter((part) => !confirmed.includes(part)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -158,6 +156,7 @@ function reconcileFetched<T extends { id: string }>(
|
|||||||
retained?: ReadonlySet<string>
|
retained?: ReadonlySet<string>
|
||||||
removed?: ReadonlySet<string>
|
removed?: ReadonlySet<string>
|
||||||
preserveUnfetched?: boolean | ((item: T) => boolean)
|
preserveUnfetched?: boolean | ((item: T) => boolean)
|
||||||
|
compare?: (a: T, b: T) => number
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const result = new Map(fetched.map((item) => [item.id, item]))
|
const result = new Map(fetched.map((item) => [item.id, item]))
|
||||||
@@ -180,7 +179,8 @@ function reconcileFetched<T extends { id: string }>(
|
|||||||
if (!item) result.delete(id)
|
if (!item) result.delete(id)
|
||||||
}
|
}
|
||||||
for (const id of options.removed ?? emptyIDs) result.delete(id)
|
for (const id of options.removed ?? emptyIDs) result.delete(id)
|
||||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
const items = [...result.values()]
|
||||||
|
return options.compare ? items.sort(options.compare) : items
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
|
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
|
||||||
@@ -413,8 +413,7 @@ export function createServerSession(
|
|||||||
if (!load) return
|
if (!load) return
|
||||||
// A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata.
|
// A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata.
|
||||||
const messages = data.message[sessionID]
|
const messages = data.message[sessionID]
|
||||||
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
|
||||||
load.retainedMessages.add(messageID)
|
|
||||||
const parts = load.touchedParts.get(messageID)
|
const parts = load.touchedParts.get(messageID)
|
||||||
if (parts) {
|
if (parts) {
|
||||||
parts.add(partID)
|
parts.add(partID)
|
||||||
@@ -437,16 +436,14 @@ export function createServerSession(
|
|||||||
load.touchedParts.set(messageID, new Set(parts))
|
load.touchedParts.set(messageID, new Set(parts))
|
||||||
load.carriedDeltaParts.set(messageID, new Set(parts))
|
load.carriedDeltaParts.set(messageID, new Set(parts))
|
||||||
const messages = data.message[sessionID]
|
const messages = data.message[sessionID]
|
||||||
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
|
||||||
load.retainedMessages.add(messageID)
|
|
||||||
}
|
}
|
||||||
for (const [messageID, parts] of load.removedParts) {
|
for (const [messageID, parts] of load.removedParts) {
|
||||||
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
||||||
parts.forEach((partID) => touched.add(partID))
|
parts.forEach((partID) => touched.add(partID))
|
||||||
load.touchedParts.set(messageID, touched)
|
load.touchedParts.set(messageID, touched)
|
||||||
const messages = data.message[sessionID]
|
const messages = data.message[sessionID]
|
||||||
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
|
||||||
load.retainedMessages.add(messageID)
|
|
||||||
}
|
}
|
||||||
for (const [messageID, parts] of load.optimisticParts) {
|
for (const [messageID, parts] of load.optimisticParts) {
|
||||||
load.removedMessages.delete(messageID)
|
load.removedMessages.delete(messageID)
|
||||||
@@ -555,7 +552,7 @@ export function createServerSession(
|
|||||||
const source = pages.flatMap((page) => page.data).toReversed()
|
const source = pages.flatMap((page) => page.data).toReversed()
|
||||||
const normalized = normalizeSessionMessages(sessionID, source)
|
const normalized = normalizeSessionMessages(sessionID, source)
|
||||||
return {
|
return {
|
||||||
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
|
session: normalized.messages.sort(compareMessages),
|
||||||
part: [...normalized.parts.entries()]
|
part: [...normalized.parts.entries()]
|
||||||
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
||||||
.sort((a, b) => cmp(a.id, b.id)),
|
.sort((a, b) => cmp(a.id, b.id)),
|
||||||
@@ -572,7 +569,7 @@ export function createServerSession(
|
|||||||
})
|
})
|
||||||
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
||||||
return {
|
return {
|
||||||
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
session: items.map((item) => cleanMessage(item.info)).sort(compareMessages),
|
||||||
part: items.map((item) => ({
|
part: items.map((item) => ({
|
||||||
id: item.info.id,
|
id: item.info.id,
|
||||||
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||||
@@ -696,7 +693,7 @@ export function createServerSession(
|
|||||||
const normalized = normalizeSessionMessages(sessionID, source)
|
const normalized = normalizeSessionMessages(sessionID, source)
|
||||||
return {
|
return {
|
||||||
...page,
|
...page,
|
||||||
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
|
session: normalized.messages.sort(compareMessages),
|
||||||
part: [...normalized.parts.entries()]
|
part: [...normalized.parts.entries()]
|
||||||
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
||||||
.sort((a, b) => cmp(a.id, b.id)),
|
.sort((a, b) => cmp(a.id, b.id)),
|
||||||
@@ -713,6 +710,7 @@ export function createServerSession(
|
|||||||
retained: load?.retainedMessages,
|
retained: load?.retainedMessages,
|
||||||
removed: load?.removedMessages,
|
removed: load?.removedMessages,
|
||||||
preserveUnfetched,
|
preserveUnfetched,
|
||||||
|
compare: compareMessages,
|
||||||
})
|
})
|
||||||
batch(() => {
|
batch(() => {
|
||||||
if (source) setData("session_message", sessionID, reconcile(source))
|
if (source) setData("session_message", sessionID, reconcile(source))
|
||||||
@@ -754,7 +752,7 @@ export function createServerSession(
|
|||||||
try {
|
try {
|
||||||
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
||||||
const first = page.session.reduce<Message | undefined>(
|
const first = page.session.reduce<Message | undefined>(
|
||||||
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
|
(oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest),
|
||||||
undefined,
|
undefined,
|
||||||
)
|
)
|
||||||
if (generations.get(sessionID) !== active) return
|
if (generations.get(sessionID) !== active) return
|
||||||
@@ -804,14 +802,15 @@ export function createServerSession(
|
|||||||
session: merge(
|
session: merge(
|
||||||
page.session,
|
page.session,
|
||||||
parents.map((parent) => parent.message),
|
parents.map((parent) => parent.message),
|
||||||
),
|
).sort(compareMessages),
|
||||||
part: merge(
|
part: merge(
|
||||||
page.part,
|
page.part,
|
||||||
parents.map((parent) => ({ id: parent.message.id, part: parent.parts })),
|
parents.map((parent) => ({ id: parent.message.id, part: parent.parts })),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
const preserveUnfetched =
|
const preserveUnfetched =
|
||||||
mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
|
mode === "prepend" ||
|
||||||
|
(!result.complete && (!first || ((message: Message) => compareMessages(message, first) < 0)))
|
||||||
applyMessagePage(
|
applyMessagePage(
|
||||||
sessionID,
|
sessionID,
|
||||||
result,
|
result,
|
||||||
@@ -928,7 +927,7 @@ export function createServerSession(
|
|||||||
.message({ sessionID, messageID })
|
.message({ sessionID, messageID })
|
||||||
.then((message) => {
|
.then((message) => {
|
||||||
const current = data.session_message[sessionID] ?? []
|
const current = data.session_message[sessionID] ?? []
|
||||||
const messages = [...current.filter((item) => item.id !== message.id), message].sort((a, b) => cmp(a.id, b.id))
|
const messages = [...current.filter((item) => item.id !== message.id), message].sort(compareMessages)
|
||||||
projectV2({ sessionID, messages, touched: [message.id] })
|
projectV2({ sessionID, messages, touched: [message.id] })
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -1051,7 +1050,7 @@ export function createServerSession(
|
|||||||
setData("message", info.sessionID, [info])
|
setData("message", info.sessionID, [info])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result = Binary.search(messages, info.id, (message) => message.id)
|
const result = Binary.search(messages, messageKey(info), messageKey)
|
||||||
if (result.found) setData("message", info.sessionID, result.index, reconcile(info))
|
if (result.found) setData("message", info.sessionID, result.index, reconcile(info))
|
||||||
if (!result.found)
|
if (!result.found)
|
||||||
setData("message", info.sessionID, (value = []) => {
|
setData("message", info.sessionID, (value = []) => {
|
||||||
@@ -1084,8 +1083,8 @@ export function createServerSession(
|
|||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const messages = draft.message[props.sessionID]
|
const messages = draft.message[props.sessionID]
|
||||||
if (messages) {
|
if (messages) {
|
||||||
const result = Binary.search(messages, props.messageID, (message) => message.id)
|
const index = messages.findIndex((message) => message.id === props.messageID)
|
||||||
if (result.found) messages.splice(result.index, 1)
|
if (index >= 0) messages.splice(index, 1)
|
||||||
}
|
}
|
||||||
deleteMessageParts(draft, props.messageID)
|
deleteMessageParts(draft, props.messageID)
|
||||||
}),
|
}),
|
||||||
@@ -1097,7 +1096,7 @@ export function createServerSession(
|
|||||||
if (SKIP_PARTS.has(part.type)) return
|
if (SKIP_PARTS.has(part.type)) return
|
||||||
const messages = data.message[part.sessionID]
|
const messages = data.message[part.sessionID]
|
||||||
const load = messageLoads.get(part.sessionID)
|
const load = messageLoads.get(part.sessionID)
|
||||||
const missing = !messages || !Binary.search(messages, part.messageID, (message) => message.id).found
|
const missing = !messages?.some((message) => message.id === part.messageID)
|
||||||
// Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan.
|
// Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan.
|
||||||
if (
|
if (
|
||||||
missing &&
|
missing &&
|
||||||
@@ -1341,7 +1340,7 @@ export function createServerSession(
|
|||||||
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
||||||
if (!items)
|
if (!items)
|
||||||
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
||||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
|
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
|
||||||
setData(
|
setData(
|
||||||
"part_text_accum_delta",
|
"part_text_accum_delta",
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ function makeQueryOptionsApi(
|
|||||||
protocol: Promise<"v1" | "v2">,
|
protocol: Promise<"v1" | "v2">,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
|
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK(), protocol),
|
||||||
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
||||||
providers: (directory: PathKey | null) =>
|
providers: (directory: PathKey | null) =>
|
||||||
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
|
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
|
||||||
@@ -290,6 +290,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
|
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
|
||||||
|
const refreshProviders = () =>
|
||||||
|
queryClient.refetchQueries({
|
||||||
|
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
|
||||||
|
})
|
||||||
|
|
||||||
let bootedAt = 0
|
let bootedAt = 0
|
||||||
let bootingRoot = false
|
let bootingRoot = false
|
||||||
@@ -537,6 +541,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
homeSessions.apply(event)
|
homeSessions.apply(event)
|
||||||
}
|
}
|
||||||
homeSessions.refresh(event.type)
|
homeSessions.refresh(event.type)
|
||||||
|
if (eventType === "integration.connection.updated") void refreshProviders()
|
||||||
|
|
||||||
if (directory === "global") {
|
if (directory === "global") {
|
||||||
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
|
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
|
||||||
@@ -678,6 +683,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
peek: children.peek,
|
peek: children.peek,
|
||||||
disableMcp: children.disableMcp,
|
disableMcp: children.disableMcp,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
|
refreshProviders,
|
||||||
// bootstrap,
|
// bootstrap,
|
||||||
updateConfig: updateConfigMutation.mutateAsync,
|
updateConfig: updateConfigMutation.mutateAsync,
|
||||||
project: projectApi,
|
project: projectApi,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import {
|
import {
|
||||||
hasExistingWebState,
|
hasExistingWebState,
|
||||||
|
initialAgentVisibility,
|
||||||
isAppUpgrade,
|
isAppUpgrade,
|
||||||
layoutTransitionState,
|
layoutTransitionState,
|
||||||
maximumSunsetTimeout,
|
maximumSunsetTimeout,
|
||||||
@@ -11,6 +12,22 @@ import {
|
|||||||
shouldEnableNewLayout,
|
shouldEnableNewLayout,
|
||||||
} from "./settings"
|
} from "./settings"
|
||||||
|
|
||||||
|
describe("agent visibility", () => {
|
||||||
|
test("shows the picker for existing profiles and hides it for first-time installs", () => {
|
||||||
|
expect(initialAgentVisibility(undefined, true)).toBe(true)
|
||||||
|
expect(initialAgentVisibility(undefined, false)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shows the picker when updating from a recent release", () => {
|
||||||
|
expect(initialAgentVisibility(undefined, false, "1.18.8")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves the preference after initialization", () => {
|
||||||
|
expect(initialAgentVisibility(true, true, "1.18.8")).toBeUndefined()
|
||||||
|
expect(initialAgentVisibility(true, false)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("layout transition", () => {
|
describe("layout transition", () => {
|
||||||
test("blank profiles default to the new layout", () => {
|
test("blank profiles default to the new layout", () => {
|
||||||
expect(newLayoutDesignsDefault).toBe(true)
|
expect(newLayoutDesignsDefault).toBe(true)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
@@ -36,6 +36,7 @@ export interface Settings {
|
|||||||
mobileTitlebarPosition: "top" | "bottom"
|
mobileTitlebarPosition: "top" | "bottom"
|
||||||
newLayoutDesigns?: boolean
|
newLayoutDesigns?: boolean
|
||||||
layoutTransitionEligible?: boolean
|
layoutTransitionEligible?: boolean
|
||||||
|
agentVisibilityInitialized?: boolean
|
||||||
newInterfaceNoticeDismissed?: boolean
|
newInterfaceNoticeDismissed?: boolean
|
||||||
shouldDisplayTabsToast?: boolean
|
shouldDisplayTabsToast?: boolean
|
||||||
}
|
}
|
||||||
@@ -93,6 +94,11 @@ export function hasExistingWebState(settings: Promise<string> | string | null, p
|
|||||||
return settings !== null || previousVersion !== undefined
|
return settings !== null || previousVersion !== undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function initialAgentVisibility(initialized: boolean | undefined, existing: boolean, previousVersion?: string) {
|
||||||
|
if (initialized === true) return
|
||||||
|
return existing || previousVersion !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
|
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
|
||||||
if (!current) return false
|
if (!current) return false
|
||||||
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
|
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
|
||||||
@@ -271,6 +277,14 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
||||||
|
const initializeAgentVisibility = (existing: boolean) => {
|
||||||
|
const initial = initialAgentVisibility(store.general?.agentVisibilityInitialized, existing, launchState.previous)
|
||||||
|
if (initial === undefined) return
|
||||||
|
batch(() => {
|
||||||
|
setStore("general", "showCustomAgents", initial)
|
||||||
|
setStore("general", "agentVisibilityInitialized", true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (sunset && !oldInterfaceRetired()) {
|
if (sunset && !oldInterfaceRetired()) {
|
||||||
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||||
@@ -299,8 +313,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!ready() || !launchState.classified || platform.platform !== "web") return
|
if (!ready() || !launchState.classified || platform.platform !== "web") return
|
||||||
if (layoutTransitionClassified()) return
|
const existing = hasExistingWebState(settingsInit, launchState.previous)
|
||||||
setStore("general", "layoutTransitionEligible", hasExistingWebState(settingsInit, launchState.previous))
|
if (!layoutTransitionClassified()) setStore("general", "layoutTransitionEligible", existing)
|
||||||
|
initializeAgentVisibility(existing)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -426,6 +441,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
if (typeof current === "boolean") return
|
if (typeof current === "boolean") return
|
||||||
setStore("general", "layoutTransitionEligible", eligible)
|
setStore("general", "layoutTransitionEligible", eligible)
|
||||||
},
|
},
|
||||||
|
initializeAgentVisibility,
|
||||||
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
|
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
|
||||||
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
|
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
|
||||||
dismissNewInterfaceNotice() {
|
dismissNewInterfaceNotice() {
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "
|
|||||||
|
|
||||||
type Text = Extract<Part, { type: "text" }>
|
type Text = Extract<Part, { type: "text" }>
|
||||||
|
|
||||||
const userMessage = (id: string, sessionID: string): Message => ({
|
const userMessage = (id: string, sessionID: string, created = 1): Message => ({
|
||||||
id,
|
id,
|
||||||
sessionID,
|
sessionID,
|
||||||
role: "user",
|
role: "user",
|
||||||
time: { created: 1 },
|
time: { created },
|
||||||
agent: "assistant",
|
agent: "assistant",
|
||||||
model: { providerID: "openai", modelID: "gpt" },
|
model: { providerID: "openai", modelID: "gpt" },
|
||||||
})
|
})
|
||||||
@@ -22,21 +22,21 @@ const textPart = (id: string, sessionID: string, messageID: string): Text => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("sync optimistic reducers", () => {
|
describe("sync optimistic reducers", () => {
|
||||||
test("applyOptimisticAdd inserts message in sorted order and stores parts", () => {
|
test("applyOptimisticAdd inserts by creation time", () => {
|
||||||
const sessionID = "ses_1"
|
const sessionID = "ses_1"
|
||||||
const draft = {
|
const draft = {
|
||||||
message: { [sessionID]: [userMessage("msg_2", sessionID)] },
|
message: { [sessionID]: [userMessage("msg_z", sessionID, 1)] },
|
||||||
part: {} as Record<string, Part[] | undefined>,
|
part: {} as Record<string, Part[] | undefined>,
|
||||||
}
|
}
|
||||||
|
|
||||||
applyOptimisticAdd(draft, {
|
applyOptimisticAdd(draft, {
|
||||||
sessionID,
|
sessionID,
|
||||||
message: userMessage("msg_1", sessionID),
|
message: userMessage("msg_a", sessionID, 2),
|
||||||
parts: [textPart("prt_2", sessionID, "msg_1"), textPart("prt_1", sessionID, "msg_1")],
|
parts: [textPart("prt_2", sessionID, "msg_a"), textPart("prt_1", sessionID, "msg_a")],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
|
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
|
||||||
expect(draft.part.msg_1?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
|
expect(draft.part.msg_a?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("applyOptimisticRemove removes message and part entries", () => {
|
test("applyOptimisticRemove removes message and part entries", () => {
|
||||||
@@ -60,19 +60,33 @@ describe("sync optimistic reducers", () => {
|
|||||||
const sessionID = "ses_1"
|
const sessionID = "ses_1"
|
||||||
const page = mergeOptimisticPage(
|
const page = mergeOptimisticPage(
|
||||||
{
|
{
|
||||||
session: [userMessage("msg_1", sessionID)],
|
session: [userMessage("msg_z", sessionID, 1)],
|
||||||
part: [{ id: "msg_1", part: [textPart("prt_1", sessionID, "msg_1")] }],
|
part: [{ id: "msg_z", part: [textPart("prt_1", sessionID, "msg_z")] }],
|
||||||
complete: true,
|
complete: true,
|
||||||
},
|
},
|
||||||
[{ message: userMessage("msg_2", sessionID), parts: [textPart("prt_2", sessionID, "msg_2")] }],
|
[{ message: userMessage("msg_a", sessionID, 2), parts: [textPart("prt_2", sessionID, "msg_a")] }],
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(page.session.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
|
expect(page.session.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
|
||||||
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_2"])
|
expect(page.part.find((x) => x.id === "msg_a")?.part.map((x) => x.id)).toEqual(["prt_2"])
|
||||||
expect(page.confirmed).toEqual([])
|
expect(page.confirmed).toEqual([])
|
||||||
expect(page.complete).toBe(true)
|
expect(page.complete).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("mergeOptimisticPage uses IDs only to break equal-time ties", () => {
|
||||||
|
const sessionID = "ses_1"
|
||||||
|
const page = mergeOptimisticPage(
|
||||||
|
{
|
||||||
|
session: [userMessage("msg_z", sessionID, 1)],
|
||||||
|
part: [],
|
||||||
|
complete: true,
|
||||||
|
},
|
||||||
|
[{ message: userMessage("msg_a", sessionID, 1), parts: [] }],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(page.session.map((message) => message.id)).toEqual(["msg_a", "msg_z"])
|
||||||
|
})
|
||||||
|
|
||||||
test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
|
test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
|
||||||
const sessionID = "ses_1"
|
const sessionID = "ses_1"
|
||||||
const page = mergeOptimisticPage(
|
const page = mergeOptimisticPage(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createMemo } from "solid-js"
|
|||||||
import { useServerSync } from "./server-sync"
|
import { useServerSync } from "./server-sync"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { messageKey } from "@/utils/session-message"
|
||||||
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[])
|
|||||||
const confirmed: string[] = []
|
const confirmed: string[] = []
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const result = Binary.search(session, item.message.id, (message) => message.id)
|
const result = Binary.search(session, messageKey(item.message), messageKey)
|
||||||
const found = result.found
|
const found = result.found
|
||||||
if (!found) session.splice(result.index, 0, item.message)
|
if (!found) session.splice(result.index, 0, item.message)
|
||||||
|
|
||||||
@@ -92,7 +93,7 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[])
|
|||||||
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
|
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
|
||||||
const messages = draft.message[input.sessionID]
|
const messages = draft.message[input.sessionID]
|
||||||
if (messages) {
|
if (messages) {
|
||||||
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
const result = Binary.search(messages, messageKey(input.message), messageKey)
|
||||||
messages.splice(result.index, 0, input.message)
|
messages.splice(result.index, 0, input.message)
|
||||||
} else {
|
} else {
|
||||||
draft.message[input.sessionID] = [input.message]
|
draft.message[input.sessionID] = [input.message]
|
||||||
@@ -103,8 +104,8 @@ export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddI
|
|||||||
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
|
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
|
||||||
const messages = draft.message[input.sessionID]
|
const messages = draft.message[input.sessionID]
|
||||||
if (messages) {
|
if (messages) {
|
||||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
const index = messages.findIndex((message) => message.id === input.messageID)
|
||||||
if (result.found) messages.splice(result.index, 1)
|
if (index >= 0) messages.splice(index, 1)
|
||||||
}
|
}
|
||||||
delete draft.part[input.messageID]
|
delete draft.part[input.messageID]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ServerConnection } from "./server"
|
||||||
|
import type { Tab } from "./tabs"
|
||||||
|
|
||||||
|
export function migrateTabs(value: unknown, fallback: ServerConnection.Key): Tab[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return value.flatMap<Tab>((tab) => {
|
||||||
|
if (!tab || typeof tab !== "object") return []
|
||||||
|
if ("server" in tab && typeof tab.server !== "string") return []
|
||||||
|
const server = ("server" in tab ? tab.server : fallback) as ServerConnection.Key
|
||||||
|
if (tab.type === "session" && typeof tab.sessionId === "string") {
|
||||||
|
return [{ type: tab.type, server, sessionId: tab.sessionId }]
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
tab.type === "draft" &&
|
||||||
|
typeof tab.draftID === "string" &&
|
||||||
|
typeof tab.directory === "string" &&
|
||||||
|
(tab.worktree === undefined || typeof tab.worktree === "string")
|
||||||
|
) {
|
||||||
|
return [{ type: tab.type, server, draftID: tab.draftID, directory: tab.directory, worktree: tab.worktree }]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { createRoot, getOwner, onCleanup } from "solid-js"
|
|||||||
import { createTabMemory } from "./tab-memory"
|
import { createTabMemory } from "./tab-memory"
|
||||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||||
import type { SessionTab, Tab } from "./tabs"
|
import type { SessionTab, Tab } from "./tabs"
|
||||||
|
import { migrateTabs } from "./tab-migration"
|
||||||
import type { ServerConnection } from "./server"
|
import type { ServerConnection } from "./server"
|
||||||
|
|
||||||
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
|
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
|
||||||
@@ -11,6 +12,23 @@ function sessionTab(sessionId: string): SessionTab {
|
|||||||
return { type: "session", server, sessionId }
|
return { type: "session", server, sessionId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("tab migration", () => {
|
||||||
|
test("drops null and malformed persisted tabs", () => {
|
||||||
|
expect(
|
||||||
|
migrateTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"], server),
|
||||||
|
).toEqual([sessionTab("a")])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("adds the fallback server to valid legacy tabs", () => {
|
||||||
|
expect(migrateTabs([{ type: "session", sessionId: "a", dirBase64: "legacy" }], server)).toEqual([sessionTab("a")])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replaces invalid top-level persisted data", () => {
|
||||||
|
expect(migrateTabs(null, server)).toEqual([])
|
||||||
|
expect(migrateTabs({}, server)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("tab memory", () => {
|
describe("tab memory", () => {
|
||||||
test("keeps state until its tab is removed", () => {
|
test("keeps state until its tab is removed", () => {
|
||||||
createRoot((dispose) => {
|
createRoot((dispose) => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { sessionHref } from "@/utils/session-route"
|
|||||||
import { createTabMemory } from "./tab-memory"
|
import { createTabMemory } from "./tab-memory"
|
||||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||||
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
|
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
|
||||||
|
import { migrateTabs } from "./tab-migration"
|
||||||
|
|
||||||
export type SessionTab = {
|
export type SessionTab = {
|
||||||
type: "session"
|
type: "session"
|
||||||
@@ -59,13 +60,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
{
|
{
|
||||||
...Persist.window("tabs"),
|
...Persist.window("tabs"),
|
||||||
migrate: (value: unknown) => {
|
migrate: (value: unknown) => migrateTabs(value, fallback),
|
||||||
if (!Array.isArray(value)) return value
|
|
||||||
return value.map((tab) => {
|
|
||||||
if (!tab || typeof tab !== "object" || "server" in tab) return tab
|
|
||||||
return { ...tab, server: fallback }
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
createStore<Tab[]>([]),
|
createStore<Tab[]>([]),
|
||||||
)
|
)
|
||||||
@@ -106,7 +101,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeDraftPersisted = (draftID: string) => {
|
const removeDraftPersisted = (draftID: string) => {
|
||||||
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
|
for (const key of draftPersistedKeys()) {
|
||||||
|
const target = Persist.draft(draftID, key)
|
||||||
|
removePersisted(key === "prompt" ? Persist.prompt(target) : target, platform)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeInfo = (key: string) => {
|
const removeInfo = (key: string) => {
|
||||||
|
|||||||
@@ -4,10 +4,20 @@ import { DESKTOP_MENU } from "./desktop-menu"
|
|||||||
describe("desktop menu", () => {
|
describe("desktop menu", () => {
|
||||||
test("exports logs through the desktop command registry", () => {
|
test("exports logs through the desktop command registry", () => {
|
||||||
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||||
(item) => item.type === "item" && item.label === "Export Logs...",
|
(item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs",
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(items).toHaveLength(2)
|
expect(items).toHaveLength(2)
|
||||||
expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true)
|
expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("provides translated labels for role-backed entries", () => {
|
||||||
|
const windowMenu = DESKTOP_MENU.find((menu) => menu.role === "windowMenu")
|
||||||
|
const roleItems = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||||
|
(item) => item.type === "item" && item.role && item.labelKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(windowMenu?.labelKey).toBe("desktop.menu.window")
|
||||||
|
expect(roleItems.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user