mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bc53f67de |
@@ -219,6 +219,7 @@ jobs:
|
|||||||
|
|
||||||
build-electron:
|
build-electron:
|
||||||
needs:
|
needs:
|
||||||
|
- build-cli
|
||||||
- version
|
- version
|
||||||
if: github.repository == 'anomalyco/opencode'
|
if: github.repository == 'anomalyco/opencode'
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
@@ -315,7 +316,10 @@ 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
|
||||||
@@ -339,7 +343,8 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
CSC_KEYCHAIN: build.keychain
|
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
|
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
|
||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
||||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
|||||||
@@ -252,6 +252,33 @@ These are not strictly enforced, they are just general guidelines:
|
|||||||
|
|
||||||
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
|
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
|
||||||
|
|
||||||
|
## Trust & Vouch System
|
||||||
|
|
||||||
|
This project uses [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. The vouch list is maintained in [`.github/VOUCHED.td`](.github/VOUCHED.td).
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
- **Vouched users** are explicitly trusted contributors.
|
||||||
|
- **Denounced users** are explicitly blocked. Issues and pull requests from denounced users are automatically closed. If you have been denounced, you can request to be unvouched by reaching out to a maintainer on [Discord](https://opencode.ai/discord)
|
||||||
|
- **Everyone else** can participate normally — you don't need to be vouched to open issues or PRs.
|
||||||
|
|
||||||
|
### For maintainers
|
||||||
|
|
||||||
|
Collaborators with write access can manage the vouch list by commenting on any issue:
|
||||||
|
|
||||||
|
- `vouch` — vouch for the issue author
|
||||||
|
- `vouch @username` — vouch for a specific user
|
||||||
|
- `denounce` — denounce the issue author
|
||||||
|
- `denounce @username` — denounce a specific user
|
||||||
|
- `denounce @username <reason>` — denounce with a reason
|
||||||
|
- `unvouch` / `unvouch @username` — remove someone from the list
|
||||||
|
|
||||||
|
Changes are committed automatically to `.github/VOUCHED.td`.
|
||||||
|
|
||||||
|
### Denouncement policy
|
||||||
|
|
||||||
|
Denouncement is reserved for users who repeatedly submit low-quality AI-generated contributions, spam, or otherwise act in bad faith. It is not used for disagreements or honest mistakes.
|
||||||
|
|
||||||
## Issue Requirements
|
## Issue Requirements
|
||||||
|
|
||||||
All issues **must** use one of our issue templates:
|
All issues **must** use one of our issue templates:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@corvu/drawer": "catalog:",
|
"@corvu/drawer": "catalog:",
|
||||||
"@dnd-kit/abstract": "0.5.0",
|
"@dnd-kit/abstract": "0.5.0",
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lildax": "./bin/lildax.cjs",
|
"lildax": "./bin/lildax.cjs",
|
||||||
},
|
},
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
},
|
},
|
||||||
"packages/codemode": {
|
"packages/codemode": {
|
||||||
"name": "@opencode-ai/codemode",
|
"name": "@opencode-ai/codemode",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"acorn": "8.15.0",
|
"acorn": "8.15.0",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -194,7 +194,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.82",
|
"@ai-sdk/anthropic": "3.0.82",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -243,7 +243,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -267,7 +267,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/support": {
|
"packages/console/support": {
|
||||||
"name": "@opencode-ai/console-support",
|
"name": "@opencode-ai/console-support",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@opencode-ai/console-core": "workspace:*",
|
"@opencode-ai/console-core": "workspace:*",
|
||||||
@@ -287,7 +287,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -339,7 +339,7 @@
|
|||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "6.12.1",
|
"gitlab-ai-provider": "6.11.1",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
@@ -381,16 +381,16 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
"drizzle-orm": "catalog:",
|
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"electron-context-menu": "4.1.2",
|
"electron-context-menu": "4.1.2",
|
||||||
"electron-log": "^5",
|
"electron-log": "^5",
|
||||||
"electron-store": "11.0.2",
|
"electron-store": "11.0.2",
|
||||||
"electron-updater": "6.8.9",
|
"electron-updater": "6.8.9",
|
||||||
"electron-window-state": "^5.0.3",
|
"electron-window-state": "^5.0.3",
|
||||||
|
"marked": "^15",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/artifact": "4.0.0",
|
"@actions/artifact": "4.0.0",
|
||||||
@@ -435,7 +435,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-drizzle-sqlite": {
|
"packages/effect-drizzle-sqlite": {
|
||||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -449,7 +449,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-sqlite-node": {
|
"packages/effect-sqlite-node": {
|
||||||
"name": "@opencode-ai/effect-sqlite-node",
|
"name": "@opencode-ai/effect-sqlite-node",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -461,7 +461,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/standard-validator": "catalog:",
|
"@hono/standard-validator": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -493,7 +493,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -509,7 +509,7 @@
|
|||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "4.0.0-beta.83",
|
"@effect/platform-node": "4.0.0-beta.83",
|
||||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||||
@@ -540,7 +540,7 @@
|
|||||||
},
|
},
|
||||||
"packages/llm": {
|
"packages/llm": {
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
@@ -559,7 +559,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -632,7 +632,7 @@
|
|||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "6.12.1",
|
"gitlab-ai-provider": "6.11.1",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
@@ -690,7 +690,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -766,7 +766,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -781,7 +781,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@opencode-ai/server",
|
"name": "@opencode-ai/server",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/protocol": "workspace:*",
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
@@ -796,7 +796,7 @@
|
|||||||
},
|
},
|
||||||
"packages/session-ui": {
|
"packages/session-ui": {
|
||||||
"name": "@opencode-ai/session-ui",
|
"name": "@opencode-ai/session-ui",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
|
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
|
||||||
@@ -811,6 +811,7 @@
|
|||||||
"@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:",
|
||||||
@@ -840,7 +841,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -853,7 +854,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/app": {
|
"packages/stats/app": {
|
||||||
"name": "@opencode-ai/stats-app",
|
"name": "@opencode-ai/stats-app",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
@@ -887,7 +888,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/core": {
|
"packages/stats/core": {
|
||||||
"name": "@opencode-ai/stats-core",
|
"name": "@opencode-ai/stats-core",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-athena": "3.933.0",
|
"@aws-sdk/client-athena": "3.933.0",
|
||||||
"@planetscale/database": "1.19.0",
|
"@planetscale/database": "1.19.0",
|
||||||
@@ -906,7 +907,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/server": {
|
"packages/stats/server": {
|
||||||
"name": "@opencode-ai/stats-server",
|
"name": "@opencode-ai/stats-server",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-firehose": "3.933.0",
|
"@aws-sdk/client-firehose": "3.933.0",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
@@ -948,7 +949,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tui": {
|
"packages/tui": {
|
||||||
"name": "@opencode-ai/tui",
|
"name": "@opencode-ai/tui",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
@@ -975,7 +976,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
@@ -1001,7 +1002,6 @@
|
|||||||
"remend": "catalog:",
|
"remend": "catalog:",
|
||||||
"shiki": "catalog:",
|
"shiki": "catalog:",
|
||||||
"solid-list": "catalog:",
|
"solid-list": "catalog:",
|
||||||
"solid-sonner": "catalog:",
|
|
||||||
"strip-ansi": "7.1.2",
|
"strip-ansi": "7.1.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1027,7 +1027,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -1082,7 +1082,6 @@
|
|||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||||
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
@@ -1146,7 +1145,6 @@
|
|||||||
"shiki": "4.2.0",
|
"shiki": "4.2.0",
|
||||||
"solid-js": "1.9.10",
|
"solid-js": "1.9.10",
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
"solid-sonner": "0.3.1",
|
|
||||||
"sst": "4.13.1",
|
"sst": "4.13.1",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
@@ -3856,7 +3854,7 @@
|
|||||||
|
|
||||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||||
|
|
||||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="],
|
"gitlab-ai-provider": ["gitlab-ai-provider@6.11.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="],
|
||||||
|
|
||||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||||
|
|
||||||
@@ -5128,8 +5126,6 @@
|
|||||||
|
|
||||||
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
|
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
|
||||||
|
|
||||||
"solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="],
|
|
||||||
|
|
||||||
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
|
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
|
||||||
|
|
||||||
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
|
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
|
||||||
@@ -6044,6 +6040,8 @@
|
|||||||
|
|
||||||
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
|
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
|
||||||
|
|
||||||
|
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||||
|
|
||||||
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||||
|
|
||||||
"@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
|
"@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
|
||||||
|
|||||||
+2
-6
@@ -288,12 +288,8 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
server: {
|
server: {
|
||||||
placement: { region: "aws:us-east-2" },
|
placement: { region: "aws:us-east-2" },
|
||||||
transform: {
|
transform: {
|
||||||
worker: (args) => {
|
worker: {
|
||||||
args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [
|
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
|
||||||
...(flags ?? []),
|
|
||||||
"global_fetch_strictly_public",
|
|
||||||
])
|
|
||||||
args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }]
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-a7NyYa9vRUEqDfZNDPXXmFO58RDEgioyuGSl5CPBvxo=",
|
"x86_64-linux": "sha256-2suqkFAUeT2YgppKqP14s0C+GE3TR3uNKOOD8vWEiAg=",
|
||||||
"aarch64-linux": "sha256-l4OJtSEllHvRhktjcaJYwkXBSaJvsIoyoLusbZfYMcM=",
|
"aarch64-linux": "sha256-cJ/XYTodYmgs9ZFQhHQbfZPqxjIMmEvz062OsX6/6p4=",
|
||||||
"aarch64-darwin": "sha256-IIl0BQGs1/HLFh0auiQjiwfSQ2nfHcK2G2BAphYW59c=",
|
"aarch64-darwin": "sha256-tKyqHeI6r1aHEk4vBS39+eA6wh9KcIwsa2ctcktKkEc=",
|
||||||
"x86_64-darwin": "sha256-vVeuPyd4ZIRYrHouphTuEb4rkRZLKKTAHe840jNh9rU="
|
"x86_64-darwin": "sha256-1u7+dNjRcqKSvUTCEarVJ9fMrRUyg9y+4seZAm6Fmus="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,6 @@
|
|||||||
"@sentry/solid": "10.36.0",
|
"@sentry/solid": "10.36.0",
|
||||||
"@sentry/vite-plugin": "4.6.0",
|
"@sentry/vite-plugin": "4.6.0",
|
||||||
"solid-js": "1.9.10",
|
"solid-js": "1.9.10",
|
||||||
"solid-sonner": "0.3.1",
|
|
||||||
"vite-plugin-solid": "2.11.10",
|
"vite-plugin-solid": "2.11.10",
|
||||||
"@lydell/node-pty": "1.2.0-beta.12"
|
"@lydell/node-pty": "1.2.0-beta.12"
|
||||||
}
|
}
|
||||||
@@ -144,7 +143,6 @@
|
|||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
|
||||||
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
|
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
|
||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { expect, test } from "@playwright/test"
|
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
|
||||||
|
|
||||||
const directory = "C:/OpenCode/OpenFileExpand"
|
|
||||||
const projectID = "proj_open_file_expand"
|
|
||||||
const sessionID = "ses_open_file_expand"
|
|
||||||
const title = "Open file expand"
|
|
||||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
|
||||||
|
|
||||||
test.use({ viewport: { width: 1440, height: 900 } })
|
|
||||||
|
|
||||||
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
|
|
||||||
await mockOpenCodeServer(page, {
|
|
||||||
directory,
|
|
||||||
project: {
|
|
||||||
id: projectID,
|
|
||||||
worktree: directory,
|
|
||||||
vcs: "git",
|
|
||||||
name: "open-file-expand",
|
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
|
||||||
sandboxes: [],
|
|
||||||
},
|
|
||||||
provider: {
|
|
||||||
all: [
|
|
||||||
{
|
|
||||||
id: "opencode",
|
|
||||||
name: "OpenCode",
|
|
||||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
connected: ["opencode"],
|
|
||||||
default: { providerID: "opencode", modelID: "test" },
|
|
||||||
},
|
|
||||||
sessions: [
|
|
||||||
{
|
|
||||||
id: sessionID,
|
|
||||||
slug: sessionID,
|
|
||||||
projectID,
|
|
||||||
directory,
|
|
||||||
title,
|
|
||||||
version: "dev",
|
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
vcsDiff: [],
|
|
||||||
fileList: (path) => {
|
|
||||||
if (path === "frontend\\" || path === "frontend") {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: "app.ts",
|
|
||||||
path: "frontend\\app.ts",
|
|
||||||
absolute: `${directory}/frontend/app.ts`,
|
|
||||||
type: "file" as const,
|
|
||||||
ignored: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
if (path) return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: "frontend",
|
|
||||||
path: "frontend\\",
|
|
||||||
absolute: `${directory}/frontend`,
|
|
||||||
type: "directory" as const,
|
|
||||||
ignored: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "README.md",
|
|
||||||
path: "README.md",
|
|
||||||
absolute: `${directory}/README.md`,
|
|
||||||
type: "file" as const,
|
|
||||||
ignored: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
|
||||||
pageMessages: () => ({ items: [] }),
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.addInitScript(
|
|
||||||
({ directory, server, sessionID }) => {
|
|
||||||
localStorage.setItem(
|
|
||||||
"settings.v3",
|
|
||||||
JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }),
|
|
||||||
)
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.global.dat:server",
|
|
||||||
JSON.stringify({
|
|
||||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
|
||||||
lastProject: { local: directory },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.global.dat:layout",
|
|
||||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
|
||||||
)
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.global.dat:review-panel-v2",
|
|
||||||
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
|
||||||
)
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.window.browser.dat:tabs",
|
|
||||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ directory, server, sessionID },
|
|
||||||
)
|
|
||||||
|
|
||||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
|
||||||
await expectSessionTitle(page, title)
|
|
||||||
|
|
||||||
const panel = page.locator("#review-panel")
|
|
||||||
await panel.getByRole("button", { name: "Open file" }).click()
|
|
||||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
|
||||||
|
|
||||||
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
|
|
||||||
await expect(sidebar).toBeVisible()
|
|
||||||
|
|
||||||
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
|
|
||||||
await expect(frontendRow).toBeVisible()
|
|
||||||
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
|
|
||||||
await frontendRow.click()
|
|
||||||
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
|
|
||||||
|
|
||||||
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
|
|
||||||
await expect(appRow).toBeVisible()
|
|
||||||
await appRow.click()
|
|
||||||
await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "")
|
|
||||||
await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible()
|
|
||||||
})
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
|
||||||
import type { Page } from "@playwright/test"
|
|
||||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
|
||||||
import { expectAppVisible } from "../utils/waits"
|
|
||||||
|
|
||||||
const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
|
|
||||||
const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)
|
|
||||||
|
|
||||||
// The sixth project sits outside the five-item recent cap, so it is only reachable if the
|
|
||||||
// dialog hands every recent project to the list filter instead of a pre-truncated slice.
|
|
||||||
const OUTSIDE_CAP = "foxtrot-docs"
|
|
||||||
|
|
||||||
// Dialog rows carry data-directory-path; the sidebar project list does not, so this
|
|
||||||
// scopes assertions to the picker instead of matching the sidebar entry of the same name.
|
|
||||||
const rows = (page: Page) => page.locator("[data-directory-path]")
|
|
||||||
const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)
|
|
||||||
|
|
||||||
async function openProjectDialog(page: Page) {
|
|
||||||
await mockOpenCodeServer(page, {
|
|
||||||
sessions: fixture.sessions,
|
|
||||||
provider: fixture.provider,
|
|
||||||
directory: fixture.directory,
|
|
||||||
project: fixture.project,
|
|
||||||
pageMessages,
|
|
||||||
fileList: () => [],
|
|
||||||
findFiles: () => [],
|
|
||||||
})
|
|
||||||
await page.addInitScript((dirs) => {
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.global.dat:server",
|
|
||||||
JSON.stringify({
|
|
||||||
projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
|
|
||||||
lastProject: {},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}, worktrees)
|
|
||||||
await page.goto("/")
|
|
||||||
const add = page.getByRole("button", { name: "Add project" }).first()
|
|
||||||
await expectAppVisible(add)
|
|
||||||
await add.click()
|
|
||||||
await expect(rows(page)).toHaveCount(5)
|
|
||||||
return page.getByRole("textbox").last()
|
|
||||||
}
|
|
||||||
|
|
||||||
test("searches every recent project, not just the five most recent", async ({ page }) => {
|
|
||||||
const search = await openProjectDialog(page)
|
|
||||||
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
|
|
||||||
|
|
||||||
await search.fill("foxtrot")
|
|
||||||
|
|
||||||
await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("still caps the idle recent list at five projects", async ({ page }) => {
|
|
||||||
await openProjectDialog(page)
|
|
||||||
|
|
||||||
await expect(row(page, NAMES[4])).toHaveCount(1)
|
|
||||||
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
|
|
||||||
})
|
|
||||||
@@ -5,8 +5,6 @@ import { currentSession } from "../utils/mock-server"
|
|||||||
const server = "http://127.0.0.1:4096"
|
const server = "http://127.0.0.1:4096"
|
||||||
const sessionA = session("ses_tab_a", "Tab A session")
|
const sessionA = session("ses_tab_a", "Tab A session")
|
||||||
const sessionB = session("ses_tab_b", "Tab B session")
|
const sessionB = session("ses_tab_b", "Tab B session")
|
||||||
const sessionC = session("ses_tab_c", "Tab C session")
|
|
||||||
const unresolvedSessionID = "ses_tab_unresolved"
|
|
||||||
|
|
||||||
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
||||||
await mockServer(page)
|
await mockServer(page)
|
||||||
@@ -42,34 +40,6 @@ test("pressing mouse down on a tab navigates before mouse up", async ({ page })
|
|||||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keyboard navigation follows the visible tab order", async ({ page }) => {
|
|
||||||
await mockServer(page)
|
|
||||||
await page.addInitScript(
|
|
||||||
({ server, sessionA, unresolved, sessionC }) => {
|
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.window.browser.dat:tabs",
|
|
||||||
JSON.stringify([
|
|
||||||
{ type: "session", server, sessionId: sessionA },
|
|
||||||
{ type: "session", server, sessionId: unresolved },
|
|
||||||
{ type: "session", server, sessionId: sessionC },
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id },
|
|
||||||
)
|
|
||||||
|
|
||||||
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
|
||||||
const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}`
|
|
||||||
await page.goto(hrefA)
|
|
||||||
await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2)
|
|
||||||
await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible()
|
|
||||||
|
|
||||||
await page.keyboard.press("Control+Alt+ArrowRight")
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
|
||||||
})
|
|
||||||
|
|
||||||
function session(id: string, title: string) {
|
function session(id: string, title: string) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -83,12 +53,10 @@ function session(id: string, title: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function mockServer(page: Page) {
|
async function mockServer(page: Page) {
|
||||||
const sessions = [sessionA, sessionB, sessionC]
|
const sessions = [sessionA, sessionB]
|
||||||
await page.route("**/*", async (route) => {
|
await page.route("**/*", async (route) => {
|
||||||
const url = new URL(route.request().url())
|
const url = new URL(route.request().url())
|
||||||
if (url.origin !== server) return route.fallback()
|
if (url.origin !== server) return route.fallback()
|
||||||
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
|
||||||
return new Promise(() => {})
|
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||||
return sse(route)
|
return sse(route)
|
||||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.12",
|
"version": "1.18.6",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"test": "bun run test:unit && bun run test:browser",
|
"test": "bun run test:unit && bun run test:browser",
|
||||||
"test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src",
|
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
|
||||||
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
|
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
|
||||||
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
|
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:local": "playwright test",
|
"test:e2e:local": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function Cell(props: {
|
|||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"flex min-w-0 items-center": true,
|
"flex min-w-0 items-center": true,
|
||||||
"min-h-[20px] w-fit justify-start px-1.5 py-0.5 text-left": !!props.inline,
|
"min-h-[20px] w-fit flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline,
|
||||||
"justify-center text-center": !props.inline,
|
"justify-center text-center": !props.inline,
|
||||||
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
|
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
|
||||||
"col-span-2": !!props.wide && !props.inline,
|
"col-span-2": !!props.wide && !props.inline,
|
||||||
@@ -74,29 +74,21 @@ function Cell(props: {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"flex min-w-0": true,
|
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
|
||||||
"-translate-y-px items-baseline gap-1.5": !!props.inline,
|
|
||||||
"flex-col items-center": !props.inline,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
{props.label}
|
||||||
classList={{
|
</div>
|
||||||
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
|
<div
|
||||||
}}
|
classList={{
|
||||||
>
|
"uppercase leading-none font-bold tabular-nums": true,
|
||||||
{props.label}
|
"text-[11px]": !!props.inline,
|
||||||
</div>
|
"text-[13px] sm:text-[14px]": !props.inline,
|
||||||
<div
|
"text-text-on-critical-base": !!props.bad,
|
||||||
classList={{
|
"opacity-70": !!props.dim,
|
||||||
"uppercase leading-none font-bold tabular-nums": true,
|
}}
|
||||||
"text-[11px]": !!props.inline,
|
>
|
||||||
"text-[13px] sm:text-[14px]": !props.inline,
|
{props.value}
|
||||||
"text-text-on-critical-base": !!props.bad,
|
|
||||||
"opacity-70": !!props.dim,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.value}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -124,25 +116,15 @@ function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => vo
|
|||||||
aria-pressed={props.active}
|
aria-pressed={props.active}
|
||||||
classList={{
|
classList={{
|
||||||
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
|
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
|
||||||
"min-h-[20px] w-fit justify-start rounded px-1.5 py-0.5 text-left": !!props.inline,
|
"min-h-[20px] w-fit flex-row justify-start gap-1.5 rounded px-1.5 py-0.5 text-left": !!props.inline,
|
||||||
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
|
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
|
||||||
"bg-surface-raised-base text-text-strong": props.active,
|
"bg-surface-raised-base text-text-strong": props.active,
|
||||||
}}
|
}}
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
>
|
>
|
||||||
<span
|
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
|
||||||
classList={{
|
<span classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}>
|
||||||
flex: true,
|
{props.active ? "ON" : "OFF"}
|
||||||
"-translate-y-px items-baseline gap-1.5": !!props.inline,
|
|
||||||
"flex-col items-center": !props.inline,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
|
|
||||||
<span
|
|
||||||
classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}
|
|
||||||
>
|
|
||||||
{props.active ? "ON" : "OFF"}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
import { useQueryClient } from "@tanstack/solid-query"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { ExternalLink } from "@/components/external-link"
|
import { Link } from "@/components/link"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -37,6 +38,7 @@ import { useSettings } from "@/context/settings"
|
|||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
|
||||||
const CUSTOM_ID = "_custom"
|
const CUSTOM_ID = "_custom"
|
||||||
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||||
@@ -384,6 +386,7 @@ function ProviderConnection(props: {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
@@ -704,8 +707,9 @@ function ProviderConnection(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function complete() {
|
async function complete() {
|
||||||
await serverSync()
|
const value = directory()
|
||||||
.refreshProviders()
|
await queryClient
|
||||||
|
.refetchQueries(serverSync().queryOptions.providers(value ? pathKey(value) : null))
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
showToast({
|
showToast({
|
||||||
@@ -836,12 +840,12 @@ function ProviderConnection(props: {
|
|||||||
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
|
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
|
||||||
<div>
|
<div>
|
||||||
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
||||||
<ExternalLink
|
<Link
|
||||||
href="https://opencode.ai/zen"
|
href="https://opencode.ai/zen"
|
||||||
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
||||||
>
|
>
|
||||||
{language.t("provider.connect.opencodeZen.visit.link")}
|
{language.t("provider.connect.opencodeZen.visit.link")}
|
||||||
</ExternalLink>
|
</Link>
|
||||||
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -886,9 +890,9 @@ function ProviderConnection(props: {
|
|||||||
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
|
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
||||||
<ExternalLink href="https://opencode.ai/zen" tabIndex={-1}>
|
<Link href="https://opencode.ai/zen" tabIndex={-1}>
|
||||||
{language.t("provider.connect.opencodeZen.visit.link")}
|
{language.t("provider.connect.opencodeZen.visit.link")}
|
||||||
</ExternalLink>
|
</Link>
|
||||||
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -967,9 +971,9 @@ function ProviderConnection(props: {
|
|||||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||||
<div>
|
<div>
|
||||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||||
<ExternalLink href={store.authorization!.url} class="text-v2-text-text-base">
|
<Link href={store.authorization!.url} class="text-v2-text-text-base">
|
||||||
{language.t("provider.connect.oauth.code.visit.link")}
|
{language.t("provider.connect.oauth.code.visit.link")}
|
||||||
</ExternalLink>
|
</Link>
|
||||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
||||||
@@ -1006,9 +1010,7 @@ function ProviderConnection(props: {
|
|||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||||
<ExternalLink href={store.authorization!.url}>
|
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
|
||||||
{language.t("provider.connect.oauth.code.visit.link")}
|
|
||||||
</ExternalLink>
|
|
||||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||||
@@ -1079,9 +1081,7 @@ function ProviderConnection(props: {
|
|||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
||||||
<ExternalLink href={store.authorization!.url}>
|
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
|
||||||
{language.t("provider.connect.oauth.auto.visit.link")}
|
|
||||||
</ExternalLink>
|
|
||||||
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
||||||
</div>
|
</div>
|
||||||
<TextField
|
<TextField
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
|||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { batch, For } from "solid-js"
|
import { batch, For } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { ExternalLink } from "@/components/external-link"
|
import { Link } from "@/components/link"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -185,9 +185,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
|||||||
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
||||||
<p class="text-14-regular text-text-base">
|
<p class="text-14-regular text-text-base">
|
||||||
{language.t("provider.custom.description.prefix")}
|
{language.t("provider.custom.description.prefix")}
|
||||||
<ExternalLink href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
||||||
{language.t("provider.custom.description.link")}
|
{language.t("provider.custom.description.link")}
|
||||||
</ExternalLink>
|
</Link>
|
||||||
{language.t("provider.custom.description.suffix")}
|
{language.t("provider.custom.description.suffix")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ interface DialogSelectDirectoryProps {
|
|||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
}
|
}
|
||||||
|
|
||||||
const RECENT_PROJECT_LIMIT = 5
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
absolute: string
|
absolute: string
|
||||||
search: string
|
search: string
|
||||||
@@ -59,9 +57,9 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
const [filter, setFilter] = createSignal("")
|
const [filter, setFilter] = createSignal("")
|
||||||
let list: ListRef | undefined
|
let list: ListRef | undefined
|
||||||
|
|
||||||
const missingHome = createMemo(() => !sync.data.path.home)
|
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingHome() ? true : undefined),
|
() => (missingBase() ? true : undefined),
|
||||||
async (): Promise<Path | undefined> => {
|
async (): Promise<Path | undefined> => {
|
||||||
if ((await sdk.protocol) !== "v1") return
|
if ((await sdk.protocol) !== "v1") return
|
||||||
return sdk.client.path
|
return sdk.client.path
|
||||||
@@ -104,6 +102,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
return projects
|
return projects
|
||||||
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
|
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
|
||||||
.sort((a, b) => b.at - a.at || a.index - b.index)
|
.sort((a, b) => b.at - a.at || a.index - b.index)
|
||||||
|
.slice(0, 5)
|
||||||
.map(({ project }) => {
|
.map(({ project }) => {
|
||||||
const row = toRow(project.worktree, home(), "recent")
|
const row = toRow(project.worktree, home(), "recent")
|
||||||
const name = project.name || getFilename(project.worktree)
|
const name = project.name || getFilename(project.worktree)
|
||||||
@@ -117,10 +116,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
const items = async (value: string) => {
|
const items = async (value: string) => {
|
||||||
const results = await directories(value)
|
const results = await directories(value)
|
||||||
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
|
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
|
||||||
// Cap the idle list only. Once a query narrows the results, every project stays searchable.
|
return uniqueRows([...recentProjects(), ...directoryRows])
|
||||||
const recent = recentProjects()
|
|
||||||
const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
|
|
||||||
return uniqueRows([...visible, ...directoryRows])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolve(absolute: string) {
|
function resolve(absolute: string) {
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||||
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
|
import {
|
||||||
|
Component,
|
||||||
|
ComponentProps,
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
For,
|
||||||
|
JSX,
|
||||||
|
onCleanup,
|
||||||
|
Show,
|
||||||
|
ValidComponent,
|
||||||
|
} from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -19,7 +29,6 @@ import { ModelTooltip } from "./model-tooltip"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||||
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
|
|
||||||
import { createEventListener } from "@solid-primitives/event-listener"
|
import { createEventListener } from "@solid-primitives/event-listener"
|
||||||
import { matchesModelSearch } from "./dialog-select-model-search"
|
import { matchesModelSearch } from "./dialog-select-model-search"
|
||||||
|
|
||||||
@@ -113,13 +122,14 @@ const ModelList: Component<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
||||||
type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
|
|
||||||
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
||||||
|
|
||||||
export function ModelSelectorPopover(props: {
|
export function ModelSelectorPopover(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
model?: ModelState
|
model?: ModelState
|
||||||
trigger: ModelSelectorTrigger
|
children?: JSX.Element
|
||||||
|
triggerAs?: ValidComponent
|
||||||
|
triggerProps?: ModelSelectorTriggerProps
|
||||||
onClose?: (cause: "escape" | "select") => void
|
onClose?: (cause: "escape" | "select") => void
|
||||||
}) {
|
}) {
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
@@ -164,7 +174,9 @@ export function ModelSelectorPopover(props: {
|
|||||||
placement="top-start"
|
placement="top-start"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
>
|
>
|
||||||
<Kobalte.Trigger as={props.trigger} />
|
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||||
|
{props.children}
|
||||||
|
</Kobalte.Trigger>
|
||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
<Kobalte.Content
|
<Kobalte.Content
|
||||||
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||||
@@ -225,101 +237,66 @@ export function ModelSelectorPopover(props: {
|
|||||||
export function ModelSelectorPopoverV2(props: {
|
export function ModelSelectorPopoverV2(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
model?: ModelState
|
model?: ModelState
|
||||||
trigger: ModelSelectorTrigger
|
children?: JSX.Element
|
||||||
|
triggerAs?: ValidComponent
|
||||||
|
triggerProps?: ModelSelectorTriggerProps
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const model = props.model ?? useLocal().model
|
||||||
|
const language = useLanguage()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const controller = createModelSelectorController({
|
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||||
model: props.model,
|
let searchRef: HTMLInputElement | undefined
|
||||||
provider: () => props.provider,
|
let contentRef: HTMLDivElement | undefined
|
||||||
onSelect: () => props.onClose?.(),
|
let restoreTrigger = true
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModelSelectorPopoverV2View
|
|
||||||
trigger={props.trigger}
|
|
||||||
models={controller.models}
|
|
||||||
groups={controller.groups}
|
|
||||||
current={controller.current}
|
|
||||||
select={controller.select}
|
|
||||||
onManage={() => {
|
|
||||||
void import("./dialog-manage-models").then((module) => {
|
|
||||||
void dialog.show(() => <module.DialogManageModelsV2 />)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
onClose={() => props.onClose?.()}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function createModelSelectorController(input: {
|
|
||||||
provider: () => string | undefined
|
|
||||||
model?: ModelState
|
|
||||||
onSelect: () => void
|
|
||||||
}) {
|
|
||||||
const model = input.model ?? useLocal().model
|
|
||||||
const allModels = createMemo(() =>
|
const allModels = createMemo(() =>
|
||||||
model
|
model
|
||||||
.list()
|
.list()
|
||||||
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
||||||
.filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
|
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
||||||
)
|
)
|
||||||
|
const models = createMemo(() => {
|
||||||
|
const search = store.search.trim()
|
||||||
|
const filtered = search
|
||||||
|
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
||||||
|
: allModels()
|
||||||
|
|
||||||
return {
|
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
models: (search: string) => {
|
})
|
||||||
const query = search.trim()
|
const groups = createMemo(() => {
|
||||||
const filtered = query
|
const byProvider = new Map<string, ModelItem[]>()
|
||||||
? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name]))
|
for (const item of models()) {
|
||||||
: allModels()
|
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
||||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
}
|
||||||
},
|
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
||||||
groups: (models: ModelItem[]) => {
|
})
|
||||||
const byProvider = new Map<string, ModelItem[]>()
|
|
||||||
for (const item of models) {
|
|
||||||
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
|
||||||
}
|
|
||||||
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
|
||||||
},
|
|
||||||
current: () => {
|
|
||||||
const value = model.current()
|
|
||||||
return value ? modelKey(value) : undefined
|
|
||||||
},
|
|
||||||
select: (item: ModelItem) => {
|
|
||||||
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
|
||||||
input.onSelect()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelSelectorPopoverV2View(props: {
|
|
||||||
trigger: ModelSelectorTrigger
|
|
||||||
models: (search: string) => ModelItem[]
|
|
||||||
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
|
||||||
current: () => string | undefined
|
|
||||||
select: (item: ModelItem) => void
|
|
||||||
onManage: () => void
|
|
||||||
onClose: () => void
|
|
||||||
}) {
|
|
||||||
const language = useLanguage()
|
|
||||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
|
||||||
let searchRef: HTMLInputElement | undefined
|
|
||||||
let contentRef: HTMLDivElement | undefined
|
|
||||||
const dismiss = createMenuDismissController(() => contentRef)
|
|
||||||
|
|
||||||
const models = createMemo(() => props.models(store.search))
|
|
||||||
const groups = createMemo(() => props.groups(models()))
|
|
||||||
const keys = () => [...models().map(modelKey), manageKey]
|
const keys = () => [...models().map(modelKey), manageKey]
|
||||||
|
const current = () => {
|
||||||
|
const value = model.current()
|
||||||
|
return value ? `${value.provider.id}:${value.id}` : undefined
|
||||||
|
}
|
||||||
const initialActive = () => {
|
const initialActive = () => {
|
||||||
const selected = props.current()
|
const selected = current()
|
||||||
const options = keys()
|
const options = keys()
|
||||||
if (selected && options.includes(selected)) return selected
|
if (selected && options.includes(selected)) return selected
|
||||||
return options[0] ?? ""
|
return options[0] ?? ""
|
||||||
}
|
}
|
||||||
const activeItem = () =>
|
const activeItem = () =>
|
||||||
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
||||||
|
const afterClose = (callback: () => void) => {
|
||||||
|
const complete = () => {
|
||||||
|
if (contentRef?.isConnected) {
|
||||||
|
requestAnimationFrame(complete)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(callback))
|
||||||
|
}
|
||||||
|
requestAnimationFrame(complete)
|
||||||
|
}
|
||||||
const setOpen = (open: boolean) => {
|
const setOpen = (open: boolean) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
dismiss.allowTriggerRestore()
|
restoreTrigger = true
|
||||||
setStore({ open: true, active: initialActive() })
|
setStore({ open: true, active: initialActive() })
|
||||||
setTimeout(() =>
|
setTimeout(() =>
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -331,15 +308,23 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
}
|
}
|
||||||
setStore({ open: false, search: "", active: "" })
|
setStore({ open: false, search: "", active: "" })
|
||||||
}
|
}
|
||||||
|
const select = (item: ModelItem) => {
|
||||||
|
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||||
|
props.onClose?.()
|
||||||
|
}
|
||||||
const selectModel = (item: ModelItem) => {
|
const selectModel = (item: ModelItem) => {
|
||||||
dismiss.preventTriggerRestore()
|
restoreTrigger = false
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
dismiss.afterClose(() => props.select(item))
|
afterClose(() => select(item))
|
||||||
}
|
}
|
||||||
const manage = () => {
|
const manage = () => {
|
||||||
dismiss.preventTriggerRestore()
|
restoreTrigger = false
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
dismiss.afterClose(props.onManage)
|
afterClose(() => {
|
||||||
|
void import("./dialog-manage-models").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogManageModelsV2 />)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const selectActive = () => {
|
const selectActive = () => {
|
||||||
const item = models().find((item) => modelKey(item) === store.active)
|
const item = models().find((item) => modelKey(item) === store.active)
|
||||||
@@ -358,7 +343,10 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||||
}
|
}
|
||||||
const setSearch = (value: string) => {
|
const setSearch = (value: string) => {
|
||||||
const first = props.models(value)[0]
|
const search = value.trim()
|
||||||
|
const first = [...allModels()]
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
||||||
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,14 +362,18 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
||||||
<MenuV2.Trigger as={props.trigger} />
|
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||||
|
{props.children}
|
||||||
|
</MenuV2.Trigger>
|
||||||
<MenuV2.Portal>
|
<MenuV2.Portal>
|
||||||
<MenuV2.Content
|
<MenuV2.Content
|
||||||
ref={(element: HTMLDivElement) => (contentRef = element)}
|
ref={(el: HTMLDivElement) => (contentRef = el)}
|
||||||
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||||
onPointerDownOutside={dismiss.preventTriggerRestore}
|
onPointerDownOutside={() => (restoreTrigger = false)}
|
||||||
onFocusOutside={dismiss.preventTriggerRestore}
|
onFocusOutside={() => (restoreTrigger = false)}
|
||||||
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (!restoreTrigger) event.preventDefault()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col p-0.5">
|
<div class="flex flex-col p-0.5">
|
||||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||||
@@ -401,9 +393,9 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
dismiss.preventTriggerRestore()
|
restoreTrigger = false
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
dismiss.afterClose(props.onClose)
|
afterClose(() => props.onClose?.())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.altKey || event.metaKey) return
|
if (event.altKey || event.metaKey) return
|
||||||
@@ -453,7 +445,7 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
<MenuV2.GroupLabel class="gap-2 px-3">
|
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||||
</MenuV2.GroupLabel>
|
</MenuV2.GroupLabel>
|
||||||
<MenuV2.RadioGroup value={props.current()}>
|
<MenuV2.RadioGroup value={current()}>
|
||||||
<For each={group.items}>
|
<For each={group.items}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<TooltipV2
|
<TooltipV2
|
||||||
@@ -473,7 +465,7 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
<MenuV2.RadioItem
|
<MenuV2.RadioItem
|
||||||
value={modelKey(item)}
|
value={modelKey(item)}
|
||||||
data-option-key={modelKey(item)}
|
data-option-key={modelKey(item)}
|
||||||
data-selected-model={props.current() === modelKey(item) ? true : undefined}
|
data-selected-model={current() === modelKey(item) ? true : undefined}
|
||||||
class="scroll-my-6 w-full"
|
class="scroll-my-6 w-full"
|
||||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
|
||||||
const runAction = () => {
|
const runAction = () => {
|
||||||
if (props.link) platform.openExternal(props.link)
|
if (props.link) platform.openLink(props.link)
|
||||||
props.onClose?.()
|
props.onClose?.()
|
||||||
dialog.close()
|
dialog.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { ComponentProps, splitProps } from "solid-js"
|
|
||||||
|
|
||||||
export interface ExternalLinkProps extends Omit<ComponentProps<"a">, "href"> {
|
|
||||||
href: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExternalLink(props: ExternalLinkProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={local.href}
|
|
||||||
class={`text-text-strong underline ${local.class ?? ""}`}
|
|
||||||
target={local.target ?? "_blank"}
|
|
||||||
rel={local.rel ?? "noopener noreferrer"}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ComponentProps, splitProps } from "solid-js"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
|
||||||
|
export interface LinkProps extends Omit<ComponentProps<"a">, "href"> {
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Link(props: LinkProps) {
|
||||||
|
const platform = usePlatform()
|
||||||
|
const [local, rest] = splitProps(props, ["href", "children", "class"])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={local.href}
|
||||||
|
class={`text-text-strong underline ${local.class ?? ""}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!local.href) return
|
||||||
|
event.preventDefault()
|
||||||
|
platform.openLink(local.href)
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{local.children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
|||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
import type { Prompt, ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createEffect, createMemo, on, Show } from "solid-js"
|
import { createEffect, createMemo, on, Show } from "solid-js"
|
||||||
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||||
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||||
@@ -37,9 +37,11 @@ export type PromptInputV2ComposerProps = {
|
|||||||
class?: string
|
class?: string
|
||||||
controller: PromptInputV2ComposerController
|
controller: PromptInputV2ComposerController
|
||||||
borderUnderlay?: boolean
|
borderUnderlay?: boolean
|
||||||
|
edit?: PromptInputProps["edit"]
|
||||||
|
onEditLoaded?: PromptInputProps["onEditLoaded"]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
|
||||||
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
|
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
|
||||||
readonly model: PromptInputProps["controls"]["model"]
|
readonly model: PromptInputProps["controls"]["model"]
|
||||||
}
|
}
|
||||||
@@ -49,13 +51,15 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
|||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
|
useCommands(props)
|
||||||
|
useEditHandler(props)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<PromptInputV2
|
<PromptInputV2
|
||||||
controller={props.controller}
|
controller={props.controller}
|
||||||
borderUnderlay={props.borderUnderlay}
|
borderUnderlay={props.borderUnderlay}
|
||||||
class={props.class}
|
class={props.class}
|
||||||
variantControlVisible={!props.controller.model.loading}
|
|
||||||
attachKeybind={command.keybindParts("file.attach")}
|
attachKeybind={command.keybindParts("file.attach")}
|
||||||
attachShortcut={command.keybind("file.attach")}
|
attachShortcut={command.keybind("file.attach")}
|
||||||
modelControl={
|
modelControl={
|
||||||
@@ -78,6 +82,70 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const useEditHandler = (props: PromptInputV2ComposerProps) => {
|
||||||
|
const prompt = usePrompt()
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => props.edit?.id,
|
||||||
|
(id) => {
|
||||||
|
const edit = props.edit
|
||||||
|
if (!id || !edit) return
|
||||||
|
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
|
||||||
|
edit.context.forEach((item) =>
|
||||||
|
prompt.context.add({
|
||||||
|
type: item.type,
|
||||||
|
path: item.path,
|
||||||
|
selection: item.selection,
|
||||||
|
comment: item.comment,
|
||||||
|
commentID: item.commentID,
|
||||||
|
commentOrigin: item.commentOrigin,
|
||||||
|
preview: item.preview,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
props.controller.dispatch({ type: "mode.normal" })
|
||||||
|
props.controller.resetHistory()
|
||||||
|
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||||
|
props.controller.restoreFocus()
|
||||||
|
props.onEditLoaded?.()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCommands = (props: PromptInputV2ComposerProps) => {
|
||||||
|
const command = useCommand()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
command.register("prompt-input", () => [
|
||||||
|
{
|
||||||
|
id: "file.attach",
|
||||||
|
title: language.t("prompt.action.attachFile"),
|
||||||
|
category: language.t("command.category.file"),
|
||||||
|
keybind: "mod+u",
|
||||||
|
disabled: props.controller.state.mode !== "normal",
|
||||||
|
onSelect: () => props.controller.attach(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.shell",
|
||||||
|
title: language.t("command.prompt.mode.shell"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: "mod+shift+x",
|
||||||
|
disabled: props.controller.state.mode === "shell",
|
||||||
|
onSelect: () => props.controller.dispatch({ type: "mode.shell" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.normal",
|
||||||
|
title: language.t("command.prompt.mode.normal"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: "mod+shift+e",
|
||||||
|
disabled: props.controller.state.mode === "normal",
|
||||||
|
onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
@@ -344,7 +412,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
if (item?.commentID) comments.remove(item.path, item.commentID)
|
if (item?.commentID) comments.remove(item.path, item.commentID)
|
||||||
},
|
},
|
||||||
openAttachment: (attachment) =>
|
openAttachment: (attachment) =>
|
||||||
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
|
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
|
||||||
openContext(key) {
|
openContext(key) {
|
||||||
const item = controller.contextItem(key)
|
const item = controller.contextItem(key)
|
||||||
if (item) openComment(item, props, sync, layout, files, comments)
|
if (item) openComment(item, props, sync, layout, files, comments)
|
||||||
@@ -368,7 +436,6 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
title: language.t("prompt.toast.pasteUnsupported.title"),
|
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||||
description: language.t("prompt.toast.pasteUnsupported.description"),
|
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||||
}),
|
}),
|
||||||
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
|
|
||||||
onError: (error) =>
|
onError: (error) =>
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
@@ -377,7 +444,6 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
}),
|
}),
|
||||||
readClipboardImage: platform.readClipboardImage,
|
readClipboardImage: platform.readClipboardImage,
|
||||||
getPathForFile: platform.getPathForFile,
|
getPathForFile: platform.getPathForFile,
|
||||||
store: platform.draftStore?.putBlob,
|
|
||||||
},
|
},
|
||||||
view: {
|
view: {
|
||||||
placeholder: designPlaceholder,
|
placeholder: designPlaceholder,
|
||||||
@@ -406,62 +472,6 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
Object.defineProperty(controller, "model", { get: () => props.controls.model })
|
Object.defineProperty(controller, "model", { get: () => props.controls.model })
|
||||||
|
|
||||||
command.register("prompt-input", () => [
|
|
||||||
{
|
|
||||||
id: "file.attach",
|
|
||||||
title: language.t("prompt.action.attachFile"),
|
|
||||||
category: language.t("command.category.file"),
|
|
||||||
keybind: "mod+u",
|
|
||||||
disabled: controller.state.mode !== "normal",
|
|
||||||
onSelect: () => controller.attach(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "prompt.mode.shell",
|
|
||||||
title: language.t("command.prompt.mode.shell"),
|
|
||||||
category: language.t("command.category.session"),
|
|
||||||
keybind: "mod+shift+x",
|
|
||||||
disabled: controller.state.mode === "shell",
|
|
||||||
onSelect: () => controller.dispatch({ type: "mode.shell" }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "prompt.mode.normal",
|
|
||||||
title: language.t("command.prompt.mode.normal"),
|
|
||||||
category: language.t("command.category.session"),
|
|
||||||
keybind: "mod+shift+e",
|
|
||||||
disabled: controller.state.mode === "normal",
|
|
||||||
onSelect: () => controller.dispatch({ type: "mode.normal" }),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => props.edit?.id,
|
|
||||||
(id) => {
|
|
||||||
const edit = props.edit
|
|
||||||
if (!id || !edit) return
|
|
||||||
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
|
|
||||||
edit.context.forEach((item) =>
|
|
||||||
prompt.context.add({
|
|
||||||
type: item.type,
|
|
||||||
path: item.path,
|
|
||||||
selection: item.selection,
|
|
||||||
comment: item.comment,
|
|
||||||
commentID: item.commentID,
|
|
||||||
commentOrigin: item.commentOrigin,
|
|
||||||
preview: item.preview,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
controller.dispatch({ type: "mode.normal" })
|
|
||||||
controller.resetHistory()
|
|
||||||
prompt.set(edit.prompt, promptLength(edit.prompt))
|
|
||||||
controller.restoreFocus()
|
|
||||||
props.onEditLoaded?.()
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return controller as PromptInputV2ComposerController
|
return controller as PromptInputV2ComposerController
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,22 +535,20 @@ function PromptInputV2ModelControl(props: {
|
|||||||
>
|
>
|
||||||
<ModelSelectorPopoverV2
|
<ModelSelectorPopoverV2
|
||||||
model={props.model}
|
model={props.model}
|
||||||
trigger={(triggerProps) => (
|
triggerAs={ButtonV2}
|
||||||
<ButtonV2
|
triggerProps={{
|
||||||
{...triggerProps}
|
variant: "ghost-muted",
|
||||||
variant="ghost-muted"
|
size: "normal",
|
||||||
size="normal"
|
style: { height: "28px" },
|
||||||
style={{ height: "28px" }}
|
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
|
||||||
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
classList: { "animate-in fade-in": shouldAnimate() },
|
||||||
classList={{ "animate-in fade-in": shouldAnimate() }}
|
"data-action": "prompt-model",
|
||||||
data-action="prompt-model"
|
"data-control-type": "popover",
|
||||||
data-control-type="popover"
|
}}
|
||||||
>
|
|
||||||
{content()}
|
|
||||||
</ButtonV2>
|
|
||||||
)}
|
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
/>
|
>
|
||||||
|
{content()}
|
||||||
|
</ModelSelectorPopoverV2>
|
||||||
</Show>
|
</Show>
|
||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1489,7 +1489,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<PromptImageAttachments
|
<PromptImageAttachments
|
||||||
attachments={imageAttachments()}
|
attachments={imageAttachments()}
|
||||||
onOpen={(attachment) =>
|
onOpen={(attachment) =>
|
||||||
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />)
|
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
||||||
}
|
}
|
||||||
onRemove={removeAttachment}
|
onRemove={removeAttachment}
|
||||||
removeLabel={language.t("prompt.attachment.remove")}
|
removeLabel={language.t("prompt.attachment.remove")}
|
||||||
@@ -1723,31 +1723,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<ModelSelectorPopover
|
<ModelSelectorPopover
|
||||||
model={props.controls.model.selection}
|
model={props.controls.model.selection}
|
||||||
trigger={(triggerProps) => (
|
triggerAs={Button}
|
||||||
<Button
|
triggerProps={{
|
||||||
{...triggerProps}
|
variant: "ghost",
|
||||||
variant="ghost"
|
size: "normal",
|
||||||
size="normal"
|
style: control(),
|
||||||
style={control()}
|
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
"data-action": "prompt-model",
|
||||||
data-action="prompt-model"
|
}}
|
||||||
>
|
|
||||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
|
||||||
<ProviderIcon
|
|
||||||
id={props.controls.model.selection.current()?.provider?.id ?? ""}
|
|
||||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
|
||||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<span class="truncate">
|
|
||||||
{props.controls.model.selection.current()?.name ??
|
|
||||||
language.t("dialog.model.select.title")}
|
|
||||||
</span>
|
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
onClose={restoreFocus}
|
onClose={restoreFocus}
|
||||||
/>
|
>
|
||||||
|
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||||
|
<ProviderIcon
|
||||||
|
id={props.controls.model.selection.current()?.provider?.id ?? ""}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<span class="truncate">
|
||||||
|
{props.controls.model.selection.current()?.name ??
|
||||||
|
language.t("dialog.model.select.title")}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
|
</ModelSelectorPopover>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,13 +3,28 @@ import { makeEventListener } from "@solid-primitives/event-listener"
|
|||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
|
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { uuid } from "@/utils/uuid"
|
import { uuid } from "@/utils/uuid"
|
||||||
import { getCursorPosition } from "./editor-dom"
|
import { getCursorPosition } from "./editor-dom"
|
||||||
import { createBlobReference, type DraftStore } from "@/utils/draft-store"
|
|
||||||
import { attachmentMime } from "./files"
|
import { attachmentMime } from "./files"
|
||||||
import { normalizePaste, pasteMode } from "./paste"
|
import { normalizePaste, pasteMode } from "./paste"
|
||||||
|
|
||||||
|
function dataUrl(file: File, mime: string) {
|
||||||
|
return new Promise<string>((resolve) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.addEventListener("error", () => resolve(""))
|
||||||
|
reader.addEventListener("load", () => {
|
||||||
|
const value = typeof reader.result === "string" ? reader.result : ""
|
||||||
|
const idx = value.indexOf(",")
|
||||||
|
if (idx === -1) {
|
||||||
|
resolve(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
|
||||||
|
})
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
|
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
|
||||||
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
|
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
|
||||||
|
|
||||||
@@ -21,7 +36,6 @@ type PromptAttachmentsCoreInput = {
|
|||||||
warn?: () => void
|
warn?: () => void
|
||||||
readClipboardImage?: () => Promise<File | null>
|
readClipboardImage?: () => Promise<File | null>
|
||||||
getPathForFile?: (file: File) => string
|
getPathForFile?: (file: File) => string
|
||||||
draftStore?: DraftStore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptAttachmentsInput = {
|
export type PromptAttachmentsInput = {
|
||||||
@@ -51,13 +65,16 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = await dataUrl(file, mime)
|
||||||
|
if (!url) return false
|
||||||
|
|
||||||
const attachment: ImageAttachmentPart = {
|
const attachment: ImageAttachmentPart = {
|
||||||
type: "image",
|
type: "image",
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
sourcePath: input.getPathForFile?.(file) || undefined,
|
sourcePath: input.getPathForFile?.(file) || undefined,
|
||||||
mime,
|
mime,
|
||||||
blob: input.draftStore ? await input.draftStore.putBlob(file) : await createBlobReference(file),
|
dataUrl: url,
|
||||||
}
|
}
|
||||||
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
|
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
|
||||||
return true
|
return true
|
||||||
@@ -149,10 +166,8 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
|||||||
|
|
||||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
|
||||||
const attachments = createPromptAttachmentsCore({
|
const attachments = createPromptAttachmentsCore({
|
||||||
...input,
|
...input,
|
||||||
draftStore: platform.draftStore,
|
|
||||||
capture: input.prompt.capture,
|
capture: input.prompt.capture,
|
||||||
warn: () => {
|
warn: () => {
|
||||||
showToast({
|
showToast({
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type ContextFile = {
|
|||||||
type BuildRequestPartsInput = {
|
type BuildRequestPartsInput = {
|
||||||
prompt: Prompt
|
prompt: Prompt
|
||||||
context: ContextFile[]
|
context: ContextFile[]
|
||||||
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
|
images: ImageAttachmentPart[]
|
||||||
text: string
|
text: string
|
||||||
messageID: string
|
messageID: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type { Prompt } from "@/context/prompt"
|
import type { Prompt } from "@/context/prompt"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import {
|
import { prependHistoryEntry, type PromptHistoryComment, type PromptHistoryStoredEntry } from "./history"
|
||||||
clonePromptHistoryComments,
|
|
||||||
clonePromptParts,
|
|
||||||
prependHistoryEntry,
|
|
||||||
type PromptHistoryComment,
|
|
||||||
type PromptHistoryStoredEntry,
|
|
||||||
} from "./history"
|
|
||||||
|
|
||||||
export type PromptInputHistory = {
|
export type PromptInputHistory = {
|
||||||
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||||
@@ -41,23 +35,13 @@ export function createPromptInputHistory(): PromptInputHistory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createPersistedPromptInputHistory() {
|
export function createPersistedPromptInputHistory() {
|
||||||
const [normal, setNormal, normalInit] = persisted(
|
const [normal, setNormal] = persisted(
|
||||||
Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])),
|
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||||
createStore<PromptHistoryState>({ entries: [] }),
|
createStore<PromptHistoryState>({ entries: [] }),
|
||||||
)
|
)
|
||||||
const [shell, setShell, shellInit] = persisted(
|
const [shell, setShell] = persisted(
|
||||||
Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])),
|
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
||||||
createStore<PromptHistoryState>({ entries: [] }),
|
createStore<PromptHistoryState>({ entries: [] }),
|
||||||
)
|
)
|
||||||
const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||||
return {
|
|
||||||
...history,
|
|
||||||
add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
|
|
||||||
const ready = mode === "shell" ? shellInit : normalInit
|
|
||||||
if (!(ready instanceof Promise)) return history.add(prompt, mode, comments)
|
|
||||||
const saved = clonePromptParts(prompt)
|
|
||||||
const metadata = clonePromptHistoryComments(comments)
|
|
||||||
void ready.then(() => history.add(saved, mode, metadata))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ describe("prompt-input history", () => {
|
|||||||
end: 12,
|
end: 12,
|
||||||
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
|
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
|
||||||
},
|
},
|
||||||
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
|
{ type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" },
|
||||||
]
|
]
|
||||||
const copy = clonePromptParts(original)
|
const copy = clonePromptParts(original)
|
||||||
expect(copy).not.toBe(original)
|
expect(copy).not.toBe(original)
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={attachment.blob.url}
|
src={attachment.dataUrl}
|
||||||
alt={attachment.filename}
|
alt={attachment.filename}
|
||||||
class={props.newLayoutDesigns ? imageClassV2 : imageClass}
|
class={props.newLayoutDesigns ? imageClassV2 : imageClass}
|
||||||
onClick={() => props.onOpen(attachment)}
|
onClick={() => props.onOpen(attachment)}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { ScopedKey } from "@/utils/server-scope"
|
|||||||
import { createPromptSubmissionState } from "./submission-state"
|
import { createPromptSubmissionState } from "./submission-state"
|
||||||
import { normalizeSessionInfo } from "@/utils/session"
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { blobDataUrl } from "@/utils/draft-store"
|
|
||||||
|
|
||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
@@ -96,12 +95,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
providerID: input.draft.model.providerID,
|
providerID: input.draft.model.providerID,
|
||||||
variant: input.draft.variant,
|
variant: input.draft.variant,
|
||||||
},
|
},
|
||||||
files: await Promise.all(
|
files: images.map((attachment) => ({
|
||||||
images.map(async (attachment) => ({
|
uri: attachment.dataUrl,
|
||||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
name: attachment.filename,
|
||||||
name: attachment.filename,
|
})),
|
||||||
})),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -111,16 +108,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messageID = input.messageID ?? Identifier.ascending("message")
|
const messageID = input.messageID ?? Identifier.ascending("message")
|
||||||
const encodedImages = await Promise.all(
|
|
||||||
images.map(async (attachment) => ({
|
|
||||||
...attachment,
|
|
||||||
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
const { requestParts, optimisticParts } = buildRequestParts({
|
const { requestParts, optimisticParts } = buildRequestParts({
|
||||||
prompt: input.draft.prompt,
|
prompt: input.draft.prompt,
|
||||||
context: input.draft.context,
|
context: input.draft.context,
|
||||||
images: encodedImages,
|
images,
|
||||||
text,
|
text,
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
messageID,
|
messageID,
|
||||||
@@ -525,12 +516,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
arguments: args.join(" "),
|
arguments: args.join(" "),
|
||||||
agent,
|
agent,
|
||||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||||
files: await Promise.all(
|
files: images.map((attachment) => ({
|
||||||
images.map(async (attachment) => ({
|
uri: attachment.dataUrl,
|
||||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
name: attachment.filename,
|
||||||
name: attachment.filename,
|
})),
|
||||||
})),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
serverSync().session.set("session_status", session.id, { type: "idle" })
|
serverSync().session.set("session_status", session.id, { type: "idle" })
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||||
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
|
|
||||||
|
|
||||||
export type PromptProject = {
|
export type PromptProject = {
|
||||||
name?: string
|
name?: string
|
||||||
@@ -198,8 +197,8 @@ export function PromptProjectSelector(props: {
|
|||||||
}) {
|
}) {
|
||||||
const [triggerReady, setTriggerReady] = createSignal(false)
|
const [triggerReady, setTriggerReady] = createSignal(false)
|
||||||
let contentRef: HTMLDivElement | undefined
|
let contentRef: HTMLDivElement | undefined
|
||||||
const dismiss = createMenuDismissController(() => contentRef)
|
|
||||||
let triggerFrame: number | undefined
|
let triggerFrame: number | undefined
|
||||||
|
let restoreTrigger = true
|
||||||
|
|
||||||
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
|
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
|
||||||
const setTriggerRef = (element: HTMLButtonElement) => {
|
const setTriggerRef = (element: HTMLButtonElement) => {
|
||||||
@@ -222,15 +221,25 @@ export function PromptProjectSelector(props: {
|
|||||||
props.controller.active()
|
props.controller.active()
|
||||||
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
||||||
: undefined
|
: undefined
|
||||||
|
const afterClose = (callback: () => void) => {
|
||||||
|
const complete = () => {
|
||||||
|
if (contentRef?.isConnected) {
|
||||||
|
requestAnimationFrame(complete)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(callback))
|
||||||
|
}
|
||||||
|
requestAnimationFrame(complete)
|
||||||
|
}
|
||||||
const selectProject = (project: PromptProject) => {
|
const selectProject = (project: PromptProject) => {
|
||||||
dismiss.preventTriggerRestore()
|
restoreTrigger = false
|
||||||
props.controller.setOpen(false)
|
props.controller.setOpen(false)
|
||||||
dismiss.afterClose(() => props.controller.select(project))
|
afterClose(() => props.controller.select(project))
|
||||||
}
|
}
|
||||||
const selectAction = (server?: string) => {
|
const selectAction = (server?: string) => {
|
||||||
dismiss.preventTriggerRestore()
|
restoreTrigger = false
|
||||||
props.controller.setOpen(false)
|
props.controller.setOpen(false)
|
||||||
dismiss.afterClose(() => props.controller.add(server))
|
afterClose(() => props.controller.add(server))
|
||||||
}
|
}
|
||||||
const selectActive = () => {
|
const selectActive = () => {
|
||||||
const project = props.controller.activeProject()
|
const project = props.controller.activeProject()
|
||||||
@@ -258,7 +267,7 @@ export function PromptProjectSelector(props: {
|
|||||||
)
|
)
|
||||||
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
|
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
|
||||||
.findLast((element) => element.offsetParent !== null)
|
.findLast((element) => element.offsetParent !== null)
|
||||||
dismiss.preventTriggerRestore()
|
restoreTrigger = false
|
||||||
target?.focus()
|
target?.focus()
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (props.controller.open()) props.controller.setOpen(false)
|
if (props.controller.open()) props.controller.setOpen(false)
|
||||||
@@ -282,10 +291,7 @@ export function PromptProjectSelector(props: {
|
|||||||
placement={props.placement ?? "bottom"}
|
placement={props.placement ?? "bottom"}
|
||||||
gutter={4}
|
gutter={4}
|
||||||
modal={false}
|
modal={false}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => props.controller.setOpen(open)}
|
||||||
if (open) dismiss.allowTriggerRestore()
|
|
||||||
props.controller.setOpen(open)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
|
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
@@ -294,9 +300,11 @@ export function PromptProjectSelector(props: {
|
|||||||
id="prompt-project-menu"
|
id="prompt-project-menu"
|
||||||
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
|
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
onPointerDownOutside={dismiss.preventTriggerRestore}
|
onPointerDownOutside={() => (restoreTrigger = false)}
|
||||||
onFocusOutside={dismiss.preventTriggerRestore}
|
onFocusOutside={() => (restoreTrigger = false)}
|
||||||
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (!restoreTrigger) event.preventDefault()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col p-0.5">
|
<div class="flex flex-col p-0.5">
|
||||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export { SortableTab, FileVisual } from "./session-sortable-tab"
|
|||||||
export { SortableTabV2 } from "./session-sortable-tab-v2"
|
export { SortableTabV2 } from "./session-sortable-tab-v2"
|
||||||
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
|
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
|
||||||
export { NewSessionView } from "./session-new-view"
|
export { NewSessionView } from "./session-new-view"
|
||||||
|
export { NewSessionDesignView } from "./session-new-design-view"
|
||||||
|
|||||||
@@ -289,9 +289,9 @@ export function SessionHeader() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Show when={search() && centerMount()} keyed>
|
<Show when={search() && centerMount()}>
|
||||||
{(mount) => (
|
{(mount) => (
|
||||||
<Portal mount={mount}>
|
<Portal mount={mount()}>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -308,10 +308,10 @@ export function SessionHeader() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={hotkey()} keyed>
|
<Show when={hotkey()}>
|
||||||
{(keybind) => (
|
{(keybind) => (
|
||||||
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
|
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
|
||||||
{keybind}
|
{keybind()}
|
||||||
</Keybind>
|
</Keybind>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -319,9 +319,9 @@ export function SessionHeader() {
|
|||||||
</Portal>
|
</Portal>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={rightMount()} keyed>
|
<Show when={rightMount()}>
|
||||||
{(mount) => (
|
{(mount) => (
|
||||||
<Portal mount={mount}>
|
<Portal mount={mount()}>
|
||||||
<Show
|
<Show
|
||||||
when={isV2}
|
when={isV2}
|
||||||
fallback={
|
fallback={
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { JSX } from "solid-js"
|
||||||
|
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
|
||||||
|
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||||
|
|
||||||
|
export function NewSessionDesignView(props: { children: JSX.Element }) {
|
||||||
|
return (
|
||||||
|
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
|
||||||
|
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||||
|
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||||
|
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
|
||||||
|
<div class="mt-8">{props.children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
} from "@/context/settings"
|
} from "@/context/settings"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||||
import { ExternalLink } from "./external-link"
|
import { Link } from "./link"
|
||||||
import { SettingsList } from "./settings-list"
|
import { SettingsList } from "./settings-list"
|
||||||
|
|
||||||
let demoSoundState = {
|
let demoSoundState = {
|
||||||
@@ -482,7 +482,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
{language.t("settings.general.row.theme.description")}{" "}
|
{language.t("settings.general.row.theme.description")}{" "}
|
||||||
<ExternalLink href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</ExternalLink>
|
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ type KeybindMeta = {
|
|||||||
|
|
||||||
type KeybindMap = Record<string, string | undefined>
|
type KeybindMap = Record<string, string | undefined>
|
||||||
type CommandContext = ReturnType<typeof useCommand>
|
type CommandContext = ReturnType<typeof useCommand>
|
||||||
type LanguageContext = ReturnType<typeof useLanguage>
|
|
||||||
type SettingsContext = ReturnType<typeof useSettings>
|
|
||||||
|
|
||||||
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
|
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
|
||||||
|
|
||||||
@@ -124,7 +122,7 @@ function keybinds(value: unknown): KeybindMap {
|
|||||||
return value as KeybindMap
|
return value as KeybindMap
|
||||||
}
|
}
|
||||||
|
|
||||||
function listFor(command: Pick<CommandContext, "catalog" | "options">, map: KeybindMap, palette: string) {
|
function listFor(command: CommandContext, map: KeybindMap, palette: string) {
|
||||||
const out = new Map<string, KeybindMeta>()
|
const out = new Map<string, KeybindMeta>()
|
||||||
out.set(PALETTE_ID, { title: palette, group: "General" })
|
out.set(PALETTE_ID, { title: palette, group: "General" })
|
||||||
|
|
||||||
@@ -264,274 +262,7 @@ function useKeyCapture(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createKeybindSettingsController(
|
|
||||||
input: {
|
|
||||||
command: Pick<CommandContext, "catalog" | "options" | "keybinds">
|
|
||||||
settings: {
|
|
||||||
current: { keybinds: unknown }
|
|
||||||
keybinds: Pick<SettingsContext["keybinds"], "get" | "set" | "resetAll">
|
|
||||||
}
|
|
||||||
target?: Document
|
|
||||||
notify?: (toast: { title: string; description: string }) => void
|
|
||||||
},
|
|
||||||
language: Pick<LanguageContext, "locale" | "t"> = useLanguage(),
|
|
||||||
) {
|
|
||||||
const [store, setStore] = createStore({ active: null as string | null })
|
|
||||||
const overrides = createMemo(() => keybinds(input.settings.current.keybinds))
|
|
||||||
const list = createMemo(() => {
|
|
||||||
language.locale()
|
|
||||||
return listFor(input.command, overrides(), language.t("command.palette"))
|
|
||||||
})
|
|
||||||
const grouped = createMemo(() => groupedFor(list()))
|
|
||||||
const title = (id: string) => list().get(id)?.title ?? ""
|
|
||||||
const effective = (id: string) => {
|
|
||||||
if (id === PALETTE_ID) return input.settings.keybinds.get(id) ?? DEFAULT_PALETTE_KEYBIND
|
|
||||||
|
|
||||||
const custom = input.settings.keybinds.get(id)
|
|
||||||
if (typeof custom === "string") return custom
|
|
||||||
|
|
||||||
const live = input.command.options.find((item) => item.id === id)
|
|
||||||
if (live?.keybind) return live.keybind
|
|
||||||
return input.command.catalog.find((item) => item.id === id)?.keybind
|
|
||||||
}
|
|
||||||
const used = createMemo(() => {
|
|
||||||
const value = new Map<string, { id: string; title: string }[]>()
|
|
||||||
|
|
||||||
for (const id of list().keys()) {
|
|
||||||
for (const signature of signatures(effective(id))) {
|
|
||||||
const items = value.get(signature)
|
|
||||||
if (items) {
|
|
||||||
items.push({ id, title: title(id) })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
value.set(signature, [{ id, title: title(id) }])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
})
|
|
||||||
const stop = () => {
|
|
||||||
if (!store.active) return
|
|
||||||
setStore("active", null)
|
|
||||||
input.command.keybinds(true)
|
|
||||||
}
|
|
||||||
const toggle = (id: string) => {
|
|
||||||
if (store.active === id) {
|
|
||||||
stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (store.active) stop()
|
|
||||||
setStore("active", id)
|
|
||||||
input.command.keybinds(false)
|
|
||||||
}
|
|
||||||
const notify = input.notify ?? ((toast: { title: string; description: string }) => showToast(toast))
|
|
||||||
|
|
||||||
const handle = (event: KeyboardEvent) => {
|
|
||||||
const id = store.active
|
|
||||||
if (!id) return
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
event.stopImmediatePropagation()
|
|
||||||
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const clear =
|
|
||||||
(event.key === "Backspace" || event.key === "Delete") &&
|
|
||||||
!event.ctrlKey &&
|
|
||||||
!event.metaKey &&
|
|
||||||
!event.altKey &&
|
|
||||||
!event.shiftKey
|
|
||||||
if (clear) {
|
|
||||||
input.settings.keybinds.set(id, "none")
|
|
||||||
stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = recordKeybind(event)
|
|
||||||
if (!next) return
|
|
||||||
|
|
||||||
const conflicts = new Map<string, string>()
|
|
||||||
for (const signature of signatures(next)) {
|
|
||||||
for (const item of used().get(signature) ?? []) {
|
|
||||||
if (item.id === id) continue
|
|
||||||
conflicts.set(item.id, item.title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (conflicts.size > 0) {
|
|
||||||
notify({
|
|
||||||
title: language.t("settings.shortcuts.conflict.title"),
|
|
||||||
description: language.t("settings.shortcuts.conflict.description", {
|
|
||||||
keybind: formatKeybind(next, language.t),
|
|
||||||
titles: [...conflicts.values()].join(", "),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
input.settings.keybinds.set(id, next)
|
|
||||||
stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = input.target ?? (typeof document === "object" ? document : undefined)
|
|
||||||
if (target) makeEventListener(target, "keydown", handle, { capture: true })
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (store.active) input.command.keybinds(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
catalog: {
|
|
||||||
groups: GROUPS,
|
|
||||||
filtered: (query: string) =>
|
|
||||||
filteredFor(query, list(), grouped(), (id) => formatKeybind(effective(id) ?? "", language.t)),
|
|
||||||
title,
|
|
||||||
keybind: (id: string) => formatKeybind(effective(id) ?? "", language.t),
|
|
||||||
},
|
|
||||||
capture: {
|
|
||||||
active: () => store.active,
|
|
||||||
toggle,
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
hasOverrides: () => Object.values(overrides()).some((value) => typeof value === "string"),
|
|
||||||
reset: () => {
|
|
||||||
stop()
|
|
||||||
input.settings.keybinds.resetAll()
|
|
||||||
notify({
|
|
||||||
title: language.t("settings.shortcuts.reset.toast.title"),
|
|
||||||
description: language.t("settings.shortcuts.reset.toast.description"),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingsKeybindsV2() {
|
|
||||||
const command = useCommand()
|
|
||||||
const settings = useSettings()
|
|
||||||
const controller = createKeybindSettingsController({
|
|
||||||
command,
|
|
||||||
settings,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsKeybindsV2View
|
|
||||||
groups={controller.catalog.groups}
|
|
||||||
filtered={controller.catalog.filtered}
|
|
||||||
title={controller.catalog.title}
|
|
||||||
keybind={controller.catalog.keybind}
|
|
||||||
active={controller.capture.active}
|
|
||||||
onCapture={controller.capture.toggle}
|
|
||||||
hasOverrides={controller.settings.hasOverrides}
|
|
||||||
onReset={controller.settings.reset}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingsKeybindsV2View(props: {
|
|
||||||
groups: KeybindGroup[]
|
|
||||||
filtered: (query: string) => Map<KeybindGroup, string[]>
|
|
||||||
title: (id: string) => string
|
|
||||||
keybind: (id: string) => string
|
|
||||||
active: () => string | null
|
|
||||||
onCapture: (id: string) => void
|
|
||||||
hasOverrides: () => boolean
|
|
||||||
onReset: () => void
|
|
||||||
}) {
|
|
||||||
const language = useLanguage()
|
|
||||||
const [store, setStore] = createStore({ filter: "" })
|
|
||||||
const filtered = createMemo(() => props.filtered(store.filter))
|
|
||||||
const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
|
||||||
<div class="settings-v2-tab-header-row">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
|
||||||
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
|
|
||||||
{language.t("settings.shortcuts.reset.button")}
|
|
||||||
</ButtonV2>
|
|
||||||
</div>
|
|
||||||
<div class="settings-v2-tab-search">
|
|
||||||
<TextInputV2
|
|
||||||
type="search"
|
|
||||||
appearance="base"
|
|
||||||
value={store.filter}
|
|
||||||
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
|
||||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
aria-label={language.t("settings.shortcuts.search.placeholder")}
|
|
||||||
/>
|
|
||||||
<Show when={store.filter}>
|
|
||||||
<IconButtonV2
|
|
||||||
type="button"
|
|
||||||
variant="ghost-muted"
|
|
||||||
size="small"
|
|
||||||
class="settings-v2-tab-search-clear"
|
|
||||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
|
||||||
onClick={() => setStore("filter", "")}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="settings-v2-tab-body">
|
|
||||||
<div class="settings-v2-shortcuts flex flex-col gap-8">
|
|
||||||
<For each={props.groups}>
|
|
||||||
{(group) => (
|
|
||||||
<Show when={(filtered().get(group) ?? []).length > 0}>
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<h3 class="settings-v2-section-title">{language.t(groupKey[group])}</h3>
|
|
||||||
<SettingsListV2>
|
|
||||||
<For each={filtered().get(group) ?? []}>
|
|
||||||
{(id) => (
|
|
||||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
|
||||||
<span>{props.title(id)}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-keybind-id={id}
|
|
||||||
classList={{
|
|
||||||
"settings-v2-keybind-button": true,
|
|
||||||
"settings-v2-keybind-button--active": props.active() === id,
|
|
||||||
}}
|
|
||||||
onClick={() => props.onCapture(id)}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={props.active() === id}
|
|
||||||
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
|
||||||
>
|
|
||||||
{language.t("settings.shortcuts.pressKeys")}
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<Show when={store.filter && !hasResults()}>
|
|
||||||
<div class="settings-v2-shortcuts-status">
|
|
||||||
<span>{language.t("settings.shortcuts.search.empty")}</span>
|
|
||||||
<span class="settings-v2-shortcuts-status-filter">"{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()
|
||||||
@@ -745,37 +476,78 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
<Show
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
when={props.v2}
|
||||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
fallback={
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||||
{language.t("settings.shortcuts.reset.button")}
|
<div class="flex items-center justify-between gap-4">
|
||||||
</Button>
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
||||||
</div>
|
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
||||||
|
{language.t("settings.shortcuts.reset.button")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
||||||
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
||||||
<TextField
|
<TextField
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
type="text"
|
type="text"
|
||||||
|
value={store.filter}
|
||||||
|
onChange={(v) => setStore("filter", v)}
|
||||||
|
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Show when={store.filter}>
|
||||||
|
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{groups}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||||
|
<ButtonV2 variant="ghost" onClick={resetAll} disabled={!hasOverrides()}>
|
||||||
|
{language.t("settings.shortcuts.reset.button")}
|
||||||
|
</ButtonV2>
|
||||||
|
</div>
|
||||||
|
<div class="settings-v2-tab-search">
|
||||||
|
<TextInputV2
|
||||||
|
type="search"
|
||||||
|
appearance="base"
|
||||||
value={store.filter}
|
value={store.filter}
|
||||||
onChange={(v) => setStore("filter", v)}
|
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
||||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
class="flex-1"
|
aria-label={language.t("settings.shortcuts.search.placeholder")}
|
||||||
/>
|
/>
|
||||||
<Show when={store.filter}>
|
<Show when={store.filter}>
|
||||||
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
class="settings-v2-tab-search-clear"
|
||||||
|
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||||
|
onClick={() => setStore("filter", "")}
|
||||||
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="settings-v2-tab-body">{groups}</div>
|
||||||
{groups}
|
</>
|
||||||
</div>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import { onCleanup } from "solid-js"
|
|
||||||
|
|
||||||
export type ShellOption = {
|
|
||||||
path: string
|
|
||||||
name: string
|
|
||||||
acceptable: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ShellSelectOption = {
|
|
||||||
id: string
|
|
||||||
value: string
|
|
||||||
name: string
|
|
||||||
terminalOnly: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createShellOptions(input: { shells: ShellOption[]; current: string | undefined }) {
|
|
||||||
const counts = input.shells.reduce((result, shell) => {
|
|
||||||
result.set(shell.name, (result.get(shell.name) ?? 0) + 1)
|
|
||||||
return result
|
|
||||||
}, new Map<string, number>())
|
|
||||||
const options: ShellSelectOption[] = [
|
|
||||||
{ id: "auto", value: "", name: "", terminalOnly: false },
|
|
||||||
...input.shells.map((shell) => {
|
|
||||||
const ambiguous = (counts.get(shell.name) ?? 0) > 1
|
|
||||||
const name = ambiguous ? shell.path : shell.name
|
|
||||||
return {
|
|
||||||
id: shell.path,
|
|
||||||
value: ambiguous ? shell.path : shell.name,
|
|
||||||
name,
|
|
||||||
terminalOnly: !shell.acceptable,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
if (input.current && !options.some((option) => option.value === input.current)) {
|
|
||||||
options.push({ id: input.current, value: input.current, name: input.current, terminalOnly: false })
|
|
||||||
}
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSoundPreviewController(player: (id: string | undefined) => Promise<(() => void) | undefined>) {
|
|
||||||
let cleanup: (() => void) | undefined
|
|
||||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
|
||||||
let run = 0
|
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
run += 1
|
|
||||||
cleanup?.()
|
|
||||||
clearTimeout(timeout)
|
|
||||||
cleanup = undefined
|
|
||||||
timeout = undefined
|
|
||||||
}
|
|
||||||
const play = (id: string | undefined) => {
|
|
||||||
stop()
|
|
||||||
if (!id) return
|
|
||||||
const current = ++run
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
timeout = undefined
|
|
||||||
void player(id).then((next) => {
|
|
||||||
if (run === current) {
|
|
||||||
cleanup = next
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next?.()
|
|
||||||
})
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(stop)
|
|
||||||
return { play, stop }
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { describe, expect, test, vi } from "bun:test"
|
|
||||||
import { createRoot } from "solid-js"
|
|
||||||
import { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
|
|
||||||
|
|
||||||
describe("settings v2 controllers", () => {
|
|
||||||
test("normalizes shell names and preserves an unavailable configured shell", () => {
|
|
||||||
expect(
|
|
||||||
createShellOptions({
|
|
||||||
shells: [
|
|
||||||
{ path: "/bin/bash", name: "bash", acceptable: true },
|
|
||||||
{ path: "/opt/bash", name: "bash", acceptable: false },
|
|
||||||
{ path: "/bin/zsh", name: "zsh", acceptable: true },
|
|
||||||
],
|
|
||||||
current: "fish",
|
|
||||||
}),
|
|
||||||
).toEqual([
|
|
||||||
{ id: "auto", value: "", name: "", terminalOnly: false },
|
|
||||||
{ id: "/bin/bash", value: "/bin/bash", name: "/bin/bash", terminalOnly: false },
|
|
||||||
{ id: "/opt/bash", value: "/opt/bash", name: "/opt/bash", terminalOnly: true },
|
|
||||||
{ id: "/bin/zsh", value: "zsh", name: "zsh", terminalOnly: false },
|
|
||||||
{ id: "fish", value: "fish", name: "fish", terminalOnly: false },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("debounces previews and stops owned audio on disposal", async () => {
|
|
||||||
vi.useFakeTimers()
|
|
||||||
try {
|
|
||||||
const played: string[] = []
|
|
||||||
const stopped: string[] = []
|
|
||||||
const owned = createRoot((dispose) => ({
|
|
||||||
dispose,
|
|
||||||
preview: createSoundPreviewController(async (id) => {
|
|
||||||
played.push(id ?? "")
|
|
||||||
return () => stopped.push(id ?? "")
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
owned.preview.play("first")
|
|
||||||
vi.advanceTimersByTime(99)
|
|
||||||
expect(played).toEqual([])
|
|
||||||
|
|
||||||
owned.preview.play("second")
|
|
||||||
vi.advanceTimersByTime(100)
|
|
||||||
await Promise.resolve()
|
|
||||||
expect(played).toEqual(["second"])
|
|
||||||
|
|
||||||
owned.dispose()
|
|
||||||
expect(stopped).toEqual(["second"])
|
|
||||||
} finally {
|
|
||||||
vi.useRealTimers()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
|
|
||||||
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
|
|
||||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
|
||||||
import { usePermission } from "@/context/permission"
|
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import {
|
|
||||||
monoDefault,
|
|
||||||
monoFontFamily,
|
|
||||||
monoInput,
|
|
||||||
sansDefault,
|
|
||||||
sansFontFamily,
|
|
||||||
sansInput,
|
|
||||||
terminalDefault,
|
|
||||||
terminalFontFamily,
|
|
||||||
terminalInput,
|
|
||||||
useSettings,
|
|
||||||
} from "@/context/settings"
|
|
||||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
|
||||||
import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior"
|
|
||||||
|
|
||||||
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
|
|
||||||
export type { ShellOption, ShellSelectOption } from "./general-controller-behavior"
|
|
||||||
|
|
||||||
export function createPermissionScopeController(sessionID: Accessor<string | undefined>) {
|
|
||||||
const permission = usePermission()
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const directory = createMemo(() => {
|
|
||||||
const id = sessionID()
|
|
||||||
if (!id) return undefined
|
|
||||||
return serverSync().session.lineage.peek(id)?.session.directory
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
accepting: createMemo(() => {
|
|
||||||
const id = sessionID()
|
|
||||||
const dir = directory()
|
|
||||||
if (!id || !dir) return false
|
|
||||||
return permission.isAutoAccepting(id, dir)
|
|
||||||
}),
|
|
||||||
enabled: createMemo(() => !!directory()),
|
|
||||||
set: (checked: boolean) => {
|
|
||||||
const id = sessionID()
|
|
||||||
const dir = directory()
|
|
||||||
if (!id || !dir) return
|
|
||||||
if (checked) return permission.enableAutoAccept(id, dir)
|
|
||||||
permission.disableAutoAccept(id, dir)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createShellSettingsController() {
|
|
||||||
const serverSdk = useServerSDK()
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const [shells] = createResource(
|
|
||||||
async () => {
|
|
||||||
const sdk = serverSdk()
|
|
||||||
if ((await sdk.protocol) === "v1") return (await sdk.client.pty.shells()).data ?? []
|
|
||||||
return [] as ShellOption[]
|
|
||||||
},
|
|
||||||
{ initialValue: [] as ShellOption[] },
|
|
||||||
)
|
|
||||||
const current = createMemo(() => serverSync().data.config.shell ?? "")
|
|
||||||
|
|
||||||
return {
|
|
||||||
shells: () => shells.latest,
|
|
||||||
current,
|
|
||||||
select: (value: string) => {
|
|
||||||
if (value === current()) return
|
|
||||||
void serverSync().updateConfig({ shell: value })
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAppearanceSettingsController() {
|
|
||||||
const settings = useSettings()
|
|
||||||
const theme = useTheme()
|
|
||||||
const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
|
||||||
|
|
||||||
onMount(() => void theme.loadThemes())
|
|
||||||
|
|
||||||
return {
|
|
||||||
scheme: {
|
|
||||||
current: theme.colorScheme,
|
|
||||||
select: (value: ColorScheme) => theme.setColorScheme(value),
|
|
||||||
},
|
|
||||||
theme: {
|
|
||||||
options: themes,
|
|
||||||
current: createMemo(() => themes().find((option) => option.id === theme.themeId())),
|
|
||||||
select: (option: { id: string } | null) => option && theme.setTheme(option.id),
|
|
||||||
},
|
|
||||||
fonts: {
|
|
||||||
ui: createMemo(() => ({
|
|
||||||
value: sansInput(settings.appearance.uiFont()),
|
|
||||||
family: sansFontFamily(settings.appearance.uiFont()),
|
|
||||||
placeholder: sansDefault,
|
|
||||||
})),
|
|
||||||
code: createMemo(() => ({
|
|
||||||
value: monoInput(settings.appearance.font()),
|
|
||||||
family: monoFontFamily(settings.appearance.font()),
|
|
||||||
placeholder: monoDefault,
|
|
||||||
})),
|
|
||||||
terminal: createMemo(() => ({
|
|
||||||
value: terminalInput(settings.appearance.terminalFont()),
|
|
||||||
family: terminalFontFamily(settings.appearance.terminalFont()),
|
|
||||||
placeholder: terminalDefault,
|
|
||||||
})),
|
|
||||||
setUI: (value: string) => settings.appearance.setUIFont(value),
|
|
||||||
setCode: (value: string) => settings.appearance.setFont(value),
|
|
||||||
setTerminal: (value: string) => settings.appearance.setTerminalFont(value),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
|
||||||
export const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
|
||||||
export type SoundSelectOption = (typeof soundOptions)[number]
|
|
||||||
|
|
||||||
export function createSoundSettingsController() {
|
|
||||||
const settings = useSettings()
|
|
||||||
const preview = createSoundPreviewController(playSoundById)
|
|
||||||
const channel = (
|
|
||||||
enabled: Accessor<boolean>,
|
|
||||||
current: Accessor<string>,
|
|
||||||
setEnabled: (value: boolean) => void,
|
|
||||||
set: (id: string) => void,
|
|
||||||
) => ({
|
|
||||||
current: createMemo(() =>
|
|
||||||
enabled() ? (soundOptions.find((option) => option.id === current()) ?? noneSound) : noneSound,
|
|
||||||
),
|
|
||||||
highlight: (option: SoundSelectOption | undefined) => {
|
|
||||||
if (!option) return
|
|
||||||
preview.play(option.id === "none" ? undefined : option.id)
|
|
||||||
},
|
|
||||||
select: (option: SoundSelectOption | null) => {
|
|
||||||
if (!option) return
|
|
||||||
if (option.id === "none") {
|
|
||||||
setEnabled(false)
|
|
||||||
preview.stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setEnabled(true)
|
|
||||||
set(option.id)
|
|
||||||
preview.play(option.id)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
agent: channel(
|
|
||||||
settings.sounds.agentEnabled,
|
|
||||||
settings.sounds.agent,
|
|
||||||
(value) => settings.sounds.setAgentEnabled(value),
|
|
||||||
(id) => settings.sounds.setAgent(id),
|
|
||||||
),
|
|
||||||
permissions: channel(
|
|
||||||
settings.sounds.permissionsEnabled,
|
|
||||||
settings.sounds.permissions,
|
|
||||||
(value) => settings.sounds.setPermissionsEnabled(value),
|
|
||||||
(id) => settings.sounds.setPermissions(id),
|
|
||||||
),
|
|
||||||
errors: channel(
|
|
||||||
settings.sounds.errorsEnabled,
|
|
||||||
settings.sounds.errors,
|
|
||||||
(value) => settings.sounds.setErrorsEnabled(value),
|
|
||||||
(id) => settings.sounds.setErrors(id),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PermissionScopeController = ReturnType<typeof createPermissionScopeController>
|
|
||||||
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
|
|
||||||
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
|
|
||||||
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
|
|
||||||
@@ -1,297 +1,182 @@
|
|||||||
import { Component, Show, createMemo, createResource } from "solid-js"
|
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useUpdaterAction } from "../updater-action"
|
import { useUpdaterAction } from "../updater-action"
|
||||||
import { useSettings } from "@/context/settings"
|
import {
|
||||||
import { ExternalLink } from "../external-link"
|
monoDefault,
|
||||||
|
monoFontFamily,
|
||||||
|
monoInput,
|
||||||
|
sansDefault,
|
||||||
|
sansFontFamily,
|
||||||
|
sansInput,
|
||||||
|
terminalDefault,
|
||||||
|
terminalFontFamily,
|
||||||
|
terminalInput,
|
||||||
|
useSettings,
|
||||||
|
} from "@/context/settings"
|
||||||
|
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||||
|
import { Link } from "../link"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||||
import {
|
|
||||||
createAppearanceSettingsController,
|
|
||||||
createPermissionScopeController,
|
|
||||||
createShellOptions,
|
|
||||||
createShellSettingsController,
|
|
||||||
createSoundSettingsController,
|
|
||||||
soundOptions,
|
|
||||||
type AppearanceSettingsController,
|
|
||||||
type PermissionScopeController,
|
|
||||||
type ShellSettingsController,
|
|
||||||
type SoundSettingsController,
|
|
||||||
} from "./general-controllers"
|
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
let demoSoundState = {
|
||||||
const fontSettings = {
|
cleanup: undefined as (() => void) | undefined,
|
||||||
ui: {
|
timeout: undefined as NodeJS.Timeout | undefined,
|
||||||
action: "settings-ui-font",
|
run: 0,
|
||||||
title: "settings.general.row.uiFont.title",
|
|
||||||
description: "settings.general.row.uiFont.description",
|
|
||||||
font: "ui",
|
|
||||||
input: "setUI",
|
|
||||||
},
|
|
||||||
code: {
|
|
||||||
action: "settings-code-font",
|
|
||||||
title: "settings.general.row.font.title",
|
|
||||||
description: "settings.general.row.font.description",
|
|
||||||
font: "code",
|
|
||||||
input: "setCode",
|
|
||||||
},
|
|
||||||
terminal: {
|
|
||||||
action: "settings-terminal-font",
|
|
||||||
title: "settings.general.row.terminalFont.title",
|
|
||||||
description: "settings.general.row.terminalFont.description",
|
|
||||||
font: "terminal",
|
|
||||||
input: "setTerminal",
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
const soundSettings = {
|
|
||||||
agent: {
|
|
||||||
action: "settings-sounds-agent",
|
|
||||||
title: "settings.general.sounds.agent.title",
|
|
||||||
description: "settings.general.sounds.agent.description",
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
action: "settings-sounds-permissions",
|
|
||||||
title: "settings.general.sounds.permissions.title",
|
|
||||||
description: "settings.general.sounds.permissions.description",
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
action: "settings-sounds-errors",
|
|
||||||
title: "settings.general.sounds.errors.title",
|
|
||||||
description: "settings.general.sounds.errors.description",
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
|
|
||||||
const language = useLanguage()
|
|
||||||
return (
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("command.permissions.autoaccept.enable")}
|
|
||||||
description={language.t("toast.permissions.autoaccept.on.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-auto-accept-permissions">
|
|
||||||
<Switch
|
|
||||||
checked={props.controller.accepting()}
|
|
||||||
disabled={!props.controller.enabled()}
|
|
||||||
onChange={props.controller.set}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
type ThemeOption = {
|
||||||
const language = useLanguage()
|
id: string
|
||||||
const options = createMemo(() =>
|
name: string
|
||||||
createShellOptions({
|
|
||||||
shells: props.controller.shells(),
|
|
||||||
current: props.controller.current(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.shell.title")}
|
|
||||||
description={language.t("settings.general.row.shell.description")}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-shell"
|
|
||||||
options={options()}
|
|
||||||
current={options().find((option) => option.value === props.controller.current()) ?? options()[0]}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
value={(option) => option.id}
|
|
||||||
label={(option) => {
|
|
||||||
if (option.id === "auto") return language.t("settings.general.row.shell.autoDefault")
|
|
||||||
if (!option.terminalOnly) return option.name
|
|
||||||
return `${option.name} (${language.t("settings.general.row.shell.terminalOnly")})`
|
|
||||||
}}
|
|
||||||
onSelect={(option) => option && props.controller.select(option.value)}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
|
type ShellOption = {
|
||||||
const language = useLanguage()
|
path: string
|
||||||
return (
|
name: string
|
||||||
<div class="settings-v2-section">
|
acceptable: boolean
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
|
|
||||||
<SettingsListV2>
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.colorScheme.title")}
|
|
||||||
description={language.t("settings.general.row.colorScheme.description")}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-color-scheme"
|
|
||||||
options={schemeOptions}
|
|
||||||
current={schemeOptions.find((option) => option === props.controller.scheme.current())}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
label={(option) => {
|
|
||||||
if (option === "system") return language.t("theme.scheme.system")
|
|
||||||
if (option === "light") return language.t("theme.scheme.light")
|
|
||||||
return language.t("theme.scheme.dark")
|
|
||||||
}}
|
|
||||||
onSelect={(option) => option && props.controller.scheme.select(option)}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.theme.title")}
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
{language.t("settings.general.row.theme.description")}{" "}
|
|
||||||
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
|
||||||
{language.t("common.learnMore")}
|
|
||||||
</ExternalLink>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-theme"
|
|
||||||
options={props.controller.theme.options()}
|
|
||||||
current={props.controller.theme.current()}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
value={(option) => option.id}
|
|
||||||
label={(option) => option.name}
|
|
||||||
onSelect={props.controller.theme.select}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<FontSetting kind="ui" fonts={props.controller.fonts} />
|
|
||||||
<FontSetting kind="code" fonts={props.controller.fonts} />
|
|
||||||
<FontSetting kind="terminal" fonts={props.controller.fonts} />
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FontSetting: Component<{
|
type ShellSelectOption = {
|
||||||
kind: "ui" | "code" | "terminal"
|
id: string
|
||||||
fonts: AppearanceSettingsController["fonts"]
|
value: string
|
||||||
}> = (props) => {
|
label: string
|
||||||
const language = useLanguage()
|
|
||||||
const config = () => fontSettings[props.kind]
|
|
||||||
return (
|
|
||||||
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
|
||||||
<div class="w-full sm:w-[220px]">
|
|
||||||
<TextInputV2
|
|
||||||
data-action={config().action}
|
|
||||||
type="text"
|
|
||||||
appearance="base"
|
|
||||||
value={props.fonts[config().font]().value}
|
|
||||||
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
|
|
||||||
placeholder={props.fonts[config().font]().placeholder}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
aria-label={language.t(config().title)}
|
|
||||||
style={{ "font-family": props.fonts[config().font]().family }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => {
|
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||||
const language = useLanguage()
|
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||||
return (
|
const stopDemoSound = () => {
|
||||||
<div class="settings-v2-section">
|
demoSoundState.run += 1
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
|
if (demoSoundState.cleanup) {
|
||||||
<SettingsListV2>
|
demoSoundState.cleanup()
|
||||||
<SoundSetting kind="agent" channel={props.controller.agent} />
|
}
|
||||||
<SoundSetting kind="permissions" channel={props.controller.permissions} />
|
clearTimeout(demoSoundState.timeout)
|
||||||
<SoundSetting kind="errors" channel={props.controller.errors} />
|
demoSoundState.cleanup = undefined
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SoundSetting: Component<{
|
const playDemoSound = (id: string | undefined) => {
|
||||||
kind: "agent" | "permissions" | "errors"
|
stopDemoSound()
|
||||||
channel: SoundSettingsController["agent"]
|
if (!id) return
|
||||||
}> = (props) => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const config = () => soundSettings[props.kind]
|
|
||||||
return (
|
|
||||||
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action={config().action}
|
|
||||||
options={soundOptions}
|
|
||||||
current={props.channel.current()}
|
|
||||||
value={(option) => option.id}
|
|
||||||
label={(option) => language.t(option.label)}
|
|
||||||
onHighlight={props.channel.highlight}
|
|
||||||
onSelect={props.channel.select}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const LanguageSetting = () => {
|
const run = ++demoSoundState.run
|
||||||
const language = useLanguage()
|
demoSoundState.timeout = setTimeout(() => {
|
||||||
const options = createMemo(() =>
|
void playSoundById(id).then((cleanup) => {
|
||||||
language.locales.map((locale) => ({
|
if (demoSoundState.run !== run) {
|
||||||
value: locale,
|
cleanup?.()
|
||||||
label: language.label(locale),
|
return
|
||||||
})),
|
}
|
||||||
)
|
demoSoundState.cleanup = cleanup
|
||||||
return (
|
})
|
||||||
<SettingsRowV2
|
}, 100)
|
||||||
title={language.t("settings.general.row.language.title")}
|
|
||||||
description={language.t("settings.general.row.language.description")}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-language"
|
|
||||||
options={options()}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
current={options().find((option) => option.value === language.locale())}
|
|
||||||
value={(option) => option.value}
|
|
||||||
label={(option) => option.label}
|
|
||||||
onSelect={(option) => option && language.setLocale(option.value)}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsGeneralV2: Component<{
|
export const SettingsGeneralV2: Component<{
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const permission = usePermission()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const serverSdk = useServerSDK()
|
||||||
const mobile = createMediaQuery("(max-width: 767px)")
|
const mobile = createMediaQuery("(max-width: 767px)")
|
||||||
|
|
||||||
const updater = useUpdaterAction()
|
const updater = useUpdaterAction()
|
||||||
const permissionScope = createPermissionScopeController(() => props.sessionID)
|
|
||||||
const shell = createShellSettingsController()
|
const dir = createMemo(() => {
|
||||||
const appearance = createAppearanceSettingsController()
|
if (!props.sessionID) return undefined
|
||||||
const sounds = createSoundSettingsController()
|
return serverSync().session.lineage.peek(props.sessionID)?.session.directory
|
||||||
|
})
|
||||||
|
const accepting = createMemo(() => {
|
||||||
|
const value = dir()
|
||||||
|
if (!value || !props.sessionID) return false
|
||||||
|
return permission.isAutoAccepting(props.sessionID, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleAccept = (checked: boolean) => {
|
||||||
|
const value = dir()
|
||||||
|
if (!value || !props.sessionID) return
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
permission.enableAutoAccept(props.sessionID, value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
permission.disableAutoAccept(props.sessionID, value)
|
||||||
|
}
|
||||||
const desktop = createMemo(() => platform.platform === "desktop")
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
|
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
|
|
||||||
|
const [shells] = createResource(
|
||||||
|
async () => {
|
||||||
|
const sdk = serverSdk()
|
||||||
|
if ((await sdk.protocol) === "v1") {
|
||||||
|
return (await sdk.client.pty.shells()).data ?? []
|
||||||
|
}
|
||||||
|
// return (await sdk.api.pty.shells()).data
|
||||||
|
return [] as ShellOption[]
|
||||||
|
},
|
||||||
|
{ initialValue: [] as ShellOption[] },
|
||||||
|
)
|
||||||
|
|
||||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||||
() => desktop() && "getPinchZoomEnabled" in platform,
|
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
|
||||||
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
||||||
{ initialValue: false },
|
{ initialValue: false },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void theme.loadThemes()
|
||||||
|
})
|
||||||
|
|
||||||
|
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
||||||
|
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
|
||||||
|
|
||||||
|
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
||||||
|
const list = shells.latest
|
||||||
|
const current = serverSync().data.config.shell
|
||||||
|
|
||||||
|
const nameCounts = new Map<string, number>()
|
||||||
|
for (const s of list) {
|
||||||
|
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
autoOption,
|
||||||
|
...list.map((s) => {
|
||||||
|
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
|
||||||
|
const text = ambiguousName ? s.path : s.name
|
||||||
|
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
|
||||||
|
return {
|
||||||
|
id: s.path,
|
||||||
|
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
|
||||||
|
value: ambiguousName ? s.path : s.name,
|
||||||
|
label,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (current && !options.some((o) => o.value === current)) {
|
||||||
|
options.push({ id: current, value: current, label: current })
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
})
|
||||||
|
|
||||||
const onPinchZoomChange = (checked: boolean) => {
|
const onPinchZoomChange = (checked: boolean) => {
|
||||||
setPinchZoom(checked)
|
setPinchZoom(checked)
|
||||||
const update = platform.setPinchZoomEnabled?.(checked)
|
const update = platform.setPinchZoomEnabled?.(checked)
|
||||||
@@ -299,6 +184,52 @@ export const SettingsGeneralV2: Component<{
|
|||||||
void update.catch(() => setPinchZoom(!checked))
|
void update.catch(() => setPinchZoom(!checked))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||||
|
{ value: "system", label: language.t("theme.scheme.system") },
|
||||||
|
{ value: "light", label: language.t("theme.scheme.light") },
|
||||||
|
{ value: "dark", label: language.t("theme.scheme.dark") },
|
||||||
|
])
|
||||||
|
|
||||||
|
const languageOptions = createMemo(() =>
|
||||||
|
language.locales.map((locale) => ({
|
||||||
|
value: locale,
|
||||||
|
label: language.label(locale),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||||
|
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||||
|
const mono = () => monoInput(settings.appearance.font())
|
||||||
|
const sans = () => sansInput(settings.appearance.uiFont())
|
||||||
|
const terminal = () => terminalInput(settings.appearance.terminalFont())
|
||||||
|
|
||||||
|
const soundSelectProps = (
|
||||||
|
enabled: () => boolean,
|
||||||
|
current: () => string,
|
||||||
|
setEnabled: (value: boolean) => void,
|
||||||
|
set: (id: string) => void,
|
||||||
|
) => ({
|
||||||
|
options: soundOptions,
|
||||||
|
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
|
||||||
|
value: (o: (typeof soundOptions)[number]) => o.id,
|
||||||
|
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
||||||
|
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
||||||
|
if (!option) return
|
||||||
|
playDemoSound(option.id === "none" ? undefined : option.id)
|
||||||
|
},
|
||||||
|
onSelect: (option: (typeof soundOptions)[number] | null) => {
|
||||||
|
if (!option) return
|
||||||
|
if (option.id === "none") {
|
||||||
|
setEnabled(false)
|
||||||
|
stopDemoSound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEnabled(true)
|
||||||
|
set(option.id)
|
||||||
|
playDemoSound(option.id)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const InterfaceSection = () => (
|
const InterfaceSection = () => (
|
||||||
<LayoutTransitionToggle
|
<LayoutTransitionToggle
|
||||||
title={language.t("settings.general.row.newInterface.title")}
|
title={language.t("settings.general.row.newInterface.title")}
|
||||||
@@ -320,18 +251,59 @@ export const SettingsGeneralV2: Component<{
|
|||||||
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||||
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||||
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||||
onDismiss={() => settings.general.dismissNewInterfaceNotice()}
|
onDismiss={settings.general.dismissNewInterfaceNotice}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
const GeneralSection = () => (
|
const GeneralSection = () => (
|
||||||
<div class="settings-v2-section">
|
<div class="settings-v2-section">
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<LanguageSetting />
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.language.title")}
|
||||||
|
description={language.t("settings.general.row.language.description")}
|
||||||
|
>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action="settings-language"
|
||||||
|
options={languageOptions()}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
current={languageOptions().find((o) => o.value === language.locale())}
|
||||||
|
value={(o) => o.value}
|
||||||
|
label={(o) => o.label}
|
||||||
|
onSelect={(option) => option && language.setLocale(option.value)}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
<PermissionScopeSetting controller={permissionScope} />
|
<SettingsRowV2
|
||||||
|
title={language.t("command.permissions.autoaccept.enable")}
|
||||||
|
description={language.t("toast.permissions.autoaccept.on.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-auto-accept-permissions">
|
||||||
|
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
<ShellSetting controller={shell} />
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.shell.title")}
|
||||||
|
description={language.t("settings.general.row.shell.description")}
|
||||||
|
>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action="settings-shell"
|
||||||
|
options={shellOptions()}
|
||||||
|
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
value={(o) => o.id}
|
||||||
|
label={(o) => o.label}
|
||||||
|
onSelect={(option) => {
|
||||||
|
if (!option) return
|
||||||
|
if (option.value === currentShell()) return
|
||||||
|
serverSync().updateConfig({ shell: option.value })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
<SettingsRowV2
|
<SettingsRowV2
|
||||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||||
@@ -442,6 +414,124 @@ export const SettingsGeneralV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const AppearanceSection = () => (
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
|
||||||
|
|
||||||
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.colorScheme.title")}
|
||||||
|
description={language.t("settings.general.row.colorScheme.description")}
|
||||||
|
>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action="settings-color-scheme"
|
||||||
|
options={colorSchemeOptions()}
|
||||||
|
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
value={(o) => o.value}
|
||||||
|
label={(o) => o.label}
|
||||||
|
onSelect={(option) => option && theme.setColorScheme(option.value)}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.theme.title")}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{language.t("settings.general.row.theme.description")}{" "}
|
||||||
|
<Link class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
||||||
|
{language.t("common.learnMore")}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action="settings-theme"
|
||||||
|
options={themeOptions()}
|
||||||
|
current={themeOptions().find((o) => o.id === theme.themeId())}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
value={(o) => o.id}
|
||||||
|
label={(o) => o.name}
|
||||||
|
onSelect={(option) => {
|
||||||
|
if (!option) return
|
||||||
|
theme.setTheme(option.id)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.uiFont.title")}
|
||||||
|
description={language.t("settings.general.row.uiFont.description")}
|
||||||
|
>
|
||||||
|
<div class="w-full sm:w-[220px]">
|
||||||
|
<TextInputV2
|
||||||
|
data-action="settings-ui-font"
|
||||||
|
type="text"
|
||||||
|
appearance="base"
|
||||||
|
value={sans()}
|
||||||
|
onInput={(event) => settings.appearance.setUIFont(event.currentTarget.value)}
|
||||||
|
placeholder={sansDefault}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
aria-label={language.t("settings.general.row.uiFont.title")}
|
||||||
|
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.font.title")}
|
||||||
|
description={language.t("settings.general.row.font.description")}
|
||||||
|
>
|
||||||
|
<div class="w-full sm:w-[220px]">
|
||||||
|
<TextInputV2
|
||||||
|
data-action="settings-code-font"
|
||||||
|
type="text"
|
||||||
|
appearance="base"
|
||||||
|
value={mono()}
|
||||||
|
onInput={(event) => settings.appearance.setFont(event.currentTarget.value)}
|
||||||
|
placeholder={monoDefault}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
aria-label={language.t("settings.general.row.font.title")}
|
||||||
|
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.terminalFont.title")}
|
||||||
|
description={language.t("settings.general.row.terminalFont.description")}
|
||||||
|
>
|
||||||
|
<div class="w-full sm:w-[220px]">
|
||||||
|
<TextInputV2
|
||||||
|
data-action="settings-terminal-font"
|
||||||
|
type="text"
|
||||||
|
appearance="base"
|
||||||
|
value={terminal()}
|
||||||
|
onInput={(event) => settings.appearance.setTerminalFont(event.currentTarget.value)}
|
||||||
|
placeholder={terminalDefault}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
aria-label={language.t("settings.general.row.terminalFont.title")}
|
||||||
|
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const NotificationsSection = () => (
|
const NotificationsSection = () => (
|
||||||
<div class="settings-v2-section">
|
<div class="settings-v2-section">
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
|
||||||
@@ -486,6 +576,68 @@ export const SettingsGeneralV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const SoundsSection = () => (
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
|
||||||
|
|
||||||
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.sounds.agent.title")}
|
||||||
|
description={language.t("settings.general.sounds.agent.description")}
|
||||||
|
>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action="settings-sounds-agent"
|
||||||
|
{...soundSelectProps(
|
||||||
|
() => settings.sounds.agentEnabled(),
|
||||||
|
() => settings.sounds.agent(),
|
||||||
|
(value) => settings.sounds.setAgentEnabled(value),
|
||||||
|
(id) => settings.sounds.setAgent(id),
|
||||||
|
)}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.sounds.permissions.title")}
|
||||||
|
description={language.t("settings.general.sounds.permissions.description")}
|
||||||
|
>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action="settings-sounds-permissions"
|
||||||
|
{...soundSelectProps(
|
||||||
|
() => settings.sounds.permissionsEnabled(),
|
||||||
|
() => settings.sounds.permissions(),
|
||||||
|
(value) => settings.sounds.setPermissionsEnabled(value),
|
||||||
|
(id) => settings.sounds.setPermissions(id),
|
||||||
|
)}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.sounds.errors.title")}
|
||||||
|
description={language.t("settings.general.sounds.errors.description")}
|
||||||
|
>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action="settings-sounds-errors"
|
||||||
|
{...soundSelectProps(
|
||||||
|
() => settings.sounds.errorsEnabled(),
|
||||||
|
() => settings.sounds.errors(),
|
||||||
|
(value) => settings.sounds.setErrorsEnabled(value),
|
||||||
|
(id) => settings.sounds.setErrors(id),
|
||||||
|
)}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const UpdatesSection = () => (
|
const UpdatesSection = () => (
|
||||||
<div class="settings-v2-section">
|
<div class="settings-v2-section">
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
|
||||||
@@ -507,7 +659,7 @@ export const SettingsGeneralV2: Component<{
|
|||||||
title={language.t("settings.updates.row.check.title")}
|
title={language.t("settings.updates.row.check.title")}
|
||||||
description={language.t("settings.updates.row.check.description")}
|
description={language.t("settings.updates.row.check.description")}
|
||||||
>
|
>
|
||||||
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={() => updater.run()}>
|
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={updater.run}>
|
||||||
{language.t(updater.action().label)}
|
{language.t(updater.action().label)}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
@@ -552,11 +704,11 @@ export const SettingsGeneralV2: Component<{
|
|||||||
|
|
||||||
<GeneralSection />
|
<GeneralSection />
|
||||||
|
|
||||||
<AppearanceSection controller={appearance} />
|
<AppearanceSection />
|
||||||
|
|
||||||
<NotificationsSection />
|
<NotificationsSection />
|
||||||
|
|
||||||
<SoundsSection controller={sounds} />
|
<SoundsSection />
|
||||||
|
|
||||||
<Show when={desktop()}>
|
<Show when={desktop()}>
|
||||||
<UpdatesSection />
|
<UpdatesSection />
|
||||||
|
|||||||
@@ -5,12 +5,9 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { type Component, For, Show } from "solid-js"
|
import { type Component, For, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useModels } from "@/context/models"
|
import { useModels } from "@/context/models"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
@@ -22,11 +19,6 @@ const PROVIDER_ICON_SIZE = 16
|
|||||||
export const SettingsModelsV2: Component = () => {
|
export const SettingsModelsV2: Component = () => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
const serverSdk = useServerSDK()
|
|
||||||
const [store, setStore] = persisted(
|
|
||||||
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
|
|
||||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
|
||||||
)
|
|
||||||
|
|
||||||
const list = useFilteredList<ModelItem>({
|
const list = useFilteredList<ModelItem>({
|
||||||
items: (_filter) => models.list(),
|
items: (_filter) => models.list(),
|
||||||
@@ -102,82 +94,41 @@ export const SettingsModelsV2: Component = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={list.grouped.latest}>
|
<For each={list.grouped.latest}>
|
||||||
{(group) => {
|
{(group) => (
|
||||||
const searching = () => list.filter().length > 0
|
<div class="settings-v2-section" data-component="settings-models-provider">
|
||||||
const expanded = () => searching() || !store.collapsed[group.category]
|
<div class="settings-v2-models-group-header">
|
||||||
|
<ProviderIcon
|
||||||
return (
|
id={group.category}
|
||||||
<div
|
width={PROVIDER_ICON_SIZE}
|
||||||
class="settings-v2-section"
|
height={PROVIDER_ICON_SIZE}
|
||||||
data-component="settings-models-provider"
|
class="settings-v2-models-provider-icon shrink-0"
|
||||||
data-expanded={expanded() ? "" : undefined}
|
/>
|
||||||
>
|
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
|
||||||
<h3 class="settings-v2-models-group-header">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="settings-v2-models-group-trigger"
|
|
||||||
aria-expanded={expanded()}
|
|
||||||
disabled={searching()}
|
|
||||||
onClick={() => setStore("collapsed", group.category, expanded())}
|
|
||||||
>
|
|
||||||
<span class="settings-v2-models-group-chevron">
|
|
||||||
<Show
|
|
||||||
when={expanded()}
|
|
||||||
fallback={
|
|
||||||
<svg width="5" height="6" viewBox="0 0 5 6" fill="none" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M0.75194 5.31663C0.41861 5.51103 0 5.27063 0 4.88473V0.500754C0 0.114854 0.41861 -0.125577 0.75194 0.0688635L4.5096 2.26084C4.8404 2.45378 4.8404 2.93168 4.5096 3.12462L0.75194 5.31663Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M5.37624 6.75194C5.18184 6.41861 5.42224 6 5.80814 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43203 10.5096C8.23909 10.8404 7.76119 10.8404 7.56825 10.5096L5.37624 6.75194Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
<span class="settings-v2-models-group-label">
|
|
||||||
<ProviderIcon
|
|
||||||
id={group.category}
|
|
||||||
width={PROVIDER_ICON_SIZE}
|
|
||||||
height={PROVIDER_ICON_SIZE}
|
|
||||||
class="settings-v2-models-provider-icon shrink-0"
|
|
||||||
/>
|
|
||||||
<span class="settings-v2-section-title">{group.items[0].provider.name}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</h3>
|
|
||||||
<Show when={expanded()}>
|
|
||||||
<SettingsListV2>
|
|
||||||
<For each={group.items}>
|
|
||||||
{(item) => {
|
|
||||||
const key = { providerID: item.provider.id, modelID: item.id }
|
|
||||||
return (
|
|
||||||
<SettingsRowV2 title={item.name} description="">
|
|
||||||
<div>
|
|
||||||
<Switch
|
|
||||||
checked={models.visible(key)}
|
|
||||||
onChange={(checked) => {
|
|
||||||
models.setVisibility(key, checked)
|
|
||||||
}}
|
|
||||||
hideLabel
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</SettingsListV2>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
<SettingsListV2>
|
||||||
}}
|
<For each={group.items}>
|
||||||
|
{(item) => {
|
||||||
|
const key = { providerID: item.provider.id, modelID: item.id }
|
||||||
|
return (
|
||||||
|
<SettingsRowV2 title={item.name} description="">
|
||||||
|
<div>
|
||||||
|
<Switch
|
||||||
|
checked={models.visible(key)}
|
||||||
|
onChange={(checked) => {
|
||||||
|
models.setVisibility(key, checked)
|
||||||
|
}}
|
||||||
|
hideLabel
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -373,64 +373,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-models {
|
.settings-v2-models {
|
||||||
gap: 12px;
|
gap: 24px;
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-models .settings-v2-section {
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-models .settings-v2-section[data-expanded] {
|
|
||||||
padding-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-models-group-header {
|
.settings-v2-models-group-header {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-models-group-trigger {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 4px 8px 4px 4px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--v2-text-text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-models-group-trigger:focus-visible {
|
|
||||||
outline: 2px solid var(--v2-border-border-focus);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
.settings-v2-models-group-trigger:not(:disabled):hover {
|
|
||||||
background-color: var(--v2-background-bg-layer-02);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-models-group-trigger:disabled {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-models-group-chevron {
|
|
||||||
display: flex;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--v2-icon-icon-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-models-group-label {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-models .settings-v2-section-title {
|
.settings-v2-models .settings-v2-section-title {
|
||||||
@@ -444,10 +394,6 @@
|
|||||||
color: var(--v2-icon-icon-base);
|
color: var(--v2-icon-icon-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-models [data-component="settings-v2-list"] {
|
|
||||||
padding-inline: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
|
.settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,11 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import {
|
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
|
||||||
hasNonBlockingServiceIssue,
|
|
||||||
hasServiceNeedingAttention,
|
|
||||||
serverStatusDotClass,
|
|
||||||
} from "./status-popover-indicator"
|
|
||||||
|
|
||||||
describe("serverStatusDotClass", () => {
|
describe("serverStatusDotClass", () => {
|
||||||
test("uses the success token while the server and services are healthy", () => {
|
test("uses the success token while the server and services are healthy", () => {
|
||||||
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
|
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses the session attention token when a service needs attention", () => {
|
|
||||||
expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe(
|
|
||||||
"bg-v2-background-bg-accent",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses the warning token for non-blocking issues while the server is online", () => {
|
test("uses the warning token for non-blocking issues while the server is online", () => {
|
||||||
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
|
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
|
||||||
})
|
})
|
||||||
@@ -44,15 +34,3 @@ describe("hasNonBlockingServiceIssue", () => {
|
|||||||
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
|
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("hasServiceNeedingAttention", () => {
|
|
||||||
test("detects MCP states that need user attention", () => {
|
|
||||||
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
|
|
||||||
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("ignores states that do not need user attention", () => {
|
|
||||||
expect(hasServiceNeedingAttention({ mcp: ["failed"] })).toBe(false)
|
|
||||||
expect(hasServiceNeedingAttention({ mcp: ["connected", "pending", "disabled"] })).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import type { LspStatus } from "@opencode-ai/sdk/v2/client"
|
import type { LspStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { McpServer } from "@opencode-ai/client/promise"
|
import type { McpServer } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
|
|
||||||
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasNonBlockingServiceIssue(input: {
|
export function hasNonBlockingServiceIssue(input: {
|
||||||
mcp: Array<McpServer["status"]["status"]>
|
mcp: Array<McpServer["status"]["status"]>
|
||||||
lsp: Array<LspStatus["status"]>
|
lsp: Array<LspStatus["status"]>
|
||||||
@@ -15,15 +11,9 @@ export function hasNonBlockingServiceIssue(input: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serverStatusDotClass(input: {
|
export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
|
||||||
ready: boolean
|
|
||||||
serverHealth: boolean | undefined
|
|
||||||
attention?: boolean
|
|
||||||
issue: boolean
|
|
||||||
}) {
|
|
||||||
if (input.serverHealth === false) return "bg-icon-critical-base"
|
if (input.serverHealth === false) return "bg-icon-critical-base"
|
||||||
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
|
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
|
||||||
if (input.attention) return "bg-v2-background-bg-accent"
|
|
||||||
if (input.issue) return "bg-icon-warning-base"
|
if (input.issue) return "bg-icon-warning-base"
|
||||||
if (input.serverHealth === true) return "bg-icon-success-base"
|
if (input.serverHealth === true) return "bg-icon-success-base"
|
||||||
return "bg-border-weak-base"
|
return "bg-border-weak-base"
|
||||||
|
|||||||
@@ -9,11 +9,7 @@ import { ServerConnection, useServer } from "@/context/server"
|
|||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import {
|
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
|
||||||
hasNonBlockingServiceIssue,
|
|
||||||
hasServiceNeedingAttention,
|
|
||||||
serverStatusDotClass,
|
|
||||||
} from "./status-popover-indicator"
|
|
||||||
|
|
||||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||||
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
|
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
|
||||||
@@ -26,11 +22,6 @@ export function StatusPopover() {
|
|||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||||
const attention = createMemo(() =>
|
|
||||||
hasServiceNeedingAttention({
|
|
||||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const issue = createMemo(() =>
|
const issue = createMemo(() =>
|
||||||
hasNonBlockingServiceIssue({
|
hasNonBlockingServiceIssue({
|
||||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
@@ -58,7 +49,6 @@ export function StatusPopover() {
|
|||||||
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
|
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
|
||||||
ready: ready(),
|
ready: ready(),
|
||||||
serverHealth: serverHealth(),
|
serverHealth: serverHealth(),
|
||||||
attention: attention(),
|
|
||||||
issue: issue(),
|
issue: issue(),
|
||||||
})}`}
|
})}`}
|
||||||
/>
|
/>
|
||||||
@@ -95,11 +85,6 @@ function DirectoryStatusPopover() {
|
|||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
|
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
|
||||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||||
const attention = createMemo(() =>
|
|
||||||
hasServiceNeedingAttention({
|
|
||||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const issue = createMemo(() =>
|
const issue = createMemo(() =>
|
||||||
hasNonBlockingServiceIssue({
|
hasNonBlockingServiceIssue({
|
||||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
@@ -110,7 +95,6 @@ function DirectoryStatusPopover() {
|
|||||||
shown: shown(),
|
shown: shown(),
|
||||||
ready: ready(),
|
ready: ready(),
|
||||||
serverHealth: serverHealth(),
|
serverHealth: serverHealth(),
|
||||||
attention: attention(),
|
|
||||||
issue: issue(),
|
issue: issue(),
|
||||||
label: language.t("status.popover.trigger"),
|
label: language.t("status.popover.trigger"),
|
||||||
onOpenChange: setShown,
|
onOpenChange: setShown,
|
||||||
@@ -134,7 +118,6 @@ function ServerStatusPopover() {
|
|||||||
shown: shown(),
|
shown: shown(),
|
||||||
ready: serverHealth() !== undefined,
|
ready: serverHealth() !== undefined,
|
||||||
serverHealth: serverHealth(),
|
serverHealth: serverHealth(),
|
||||||
attention: false,
|
|
||||||
issue: false,
|
issue: false,
|
||||||
label: language.t("status.popover.trigger"),
|
label: language.t("status.popover.trigger"),
|
||||||
onOpenChange: setShown,
|
onOpenChange: setShown,
|
||||||
@@ -152,7 +135,6 @@ type StatusPopoverState = {
|
|||||||
shown: boolean
|
shown: boolean
|
||||||
ready: boolean
|
ready: boolean
|
||||||
serverHealth: boolean | undefined
|
serverHealth: boolean | undefined
|
||||||
attention: boolean
|
|
||||||
issue: boolean
|
issue: boolean
|
||||||
label: string
|
label: string
|
||||||
onOpenChange: (value: boolean) => void
|
onOpenChange: (value: boolean) => void
|
||||||
|
|||||||
@@ -382,11 +382,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopImmediatePropagation()
|
event.stopImmediatePropagation()
|
||||||
if (URL.canParse(text) && new URL(text).protocol === "file:" && platform.openLocalFile) {
|
platform.openLink(text)
|
||||||
platform.openLocalFile(text)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
platform.openExternal(text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTM
|
|||||||
if (typeof ref === "function") ref(element)
|
if (typeof ref === "function") ref(element)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, pending: boolean) {
|
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
|
||||||
return !dragging && !editing && !pending
|
return !dragging && !editing && !committing
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,86 +9,53 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab] {
|
|
||||||
--tab-base: var(--v2-background-bg-deep);
|
|
||||||
--tab-overlay: transparent;
|
|
||||||
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not(
|
|
||||||
[data-dragging="true"]
|
|
||||||
):not([data-editing="true"]) {
|
|
||||||
--tab-base: var(--v2-background-bg-layer-02);
|
|
||||||
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-titlebar-tab]:is([data-state="pressed"], [data-dragging="true"], [data-editing="true"]) {
|
|
||||||
--tab-base: var(--v2-background-bg-layer-02);
|
|
||||||
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
|
|
||||||
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
|
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab-list] {
|
[data-titlebar-tab-list] {
|
||||||
gap: 6px;
|
gap: 13.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab-slot] {
|
[data-titlebar-tab-slot] {
|
||||||
--tab-separator: var(--v2-background-bg-layer-03);
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-color-scheme="dark"] [data-titlebar-tab-slot] {
|
|
||||||
--tab-separator: var(--v2-background-bg-layer-02);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before {
|
[data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
left: -3.75px;
|
left: -6.75px;
|
||||||
width: 1.5px;
|
width: 1.5px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
background: var(--tab-separator);
|
background: var(--v2-background-bg-layer-02);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before {
|
[data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before,
|
[data-titlebar-tab] [data-slot="tab-close"]::before {
|
||||||
[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + [data-titlebar-tab-slot]::before {
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 28px;
|
||||||
|
width: 16px;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(to right, transparent, var(--tab-bg));
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
|
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before {
|
||||||
--tab-title-fade-offset: 4px;
|
display: block;
|
||||||
-webkit-mask-image: linear-gradient(
|
right: 0;
|
||||||
to right,
|
|
||||||
black 0,
|
|
||||||
black calc(100% - var(--tab-title-fade-offset) - 16px),
|
|
||||||
transparent calc(100% - var(--tab-title-fade-offset)),
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
mask-image: linear-gradient(
|
|
||||||
to right,
|
|
||||||
black 0,
|
|
||||||
black calc(100% - var(--tab-title-fade-offset) - 16px),
|
|
||||||
transparent calc(100% - var(--tab-title-fade-offset)),
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
|
[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before,
|
||||||
[data-slot="tab-link"] {
|
[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"])
|
||||||
--tab-title-fade-offset: 24px;
|
[data-slot="tab-close"]::before {
|
||||||
|
right: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
[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"])
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
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"
|
||||||
|
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"
|
||||||
@@ -22,7 +23,8 @@ export function TabNavItem(props: {
|
|||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
session: () => Session | undefined
|
session: () => Session | undefined
|
||||||
fallbackTitle?: string
|
fallbackTitle?: string
|
||||||
onRename: (title: string) => Promise<void>
|
onTitleChange?: (title: string) => void
|
||||||
|
onTitleChangeFailed?: (title: string) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onNavigate: () => void
|
onNavigate: () => void
|
||||||
active?: boolean
|
active?: boolean
|
||||||
@@ -32,12 +34,13 @@ export function TabNavItem(props: {
|
|||||||
pressed?: boolean
|
pressed?: boolean
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
const [editing, setEditing] = createSignal(false)
|
const [editing, setEditing] = createSignal(false)
|
||||||
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
|
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
|
||||||
let tabRoot!: HTMLDivElement
|
let tabRoot!: HTMLDivElement
|
||||||
let titleEl!: HTMLSpanElement
|
let titleEl!: HTMLSpanElement
|
||||||
|
let committing = false
|
||||||
let measureFrame: number | undefined
|
let measureFrame: number | undefined
|
||||||
const rename = createMutation(() => ({ mutationFn: props.onRename }))
|
|
||||||
|
|
||||||
const closeTab = (event: MouseEvent) => {
|
const closeTab = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -113,20 +116,40 @@ export function TabNavItem(props: {
|
|||||||
selection?.addRange(range)
|
selection?.addRange(range)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rename = async (title: string) => {
|
||||||
|
const ctx = serverCtx()
|
||||||
|
const session = props.session()
|
||||||
|
if (!ctx || !session) return
|
||||||
|
await ctx.sdk.api.session.rename({ sessionID: session.id, title })
|
||||||
|
}
|
||||||
|
|
||||||
const closeRename = async (save: boolean) => {
|
const closeRename = async (save: boolean) => {
|
||||||
if (rename.isPending || !editing()) return
|
if (committing || !editing()) return
|
||||||
|
committing = true
|
||||||
|
|
||||||
const original = props.session()?.title ?? ""
|
const original = props.session()?.title ?? ""
|
||||||
const next = (titleEl.textContent ?? "").trim()
|
const next = (titleEl.textContent ?? "").trim()
|
||||||
|
|
||||||
titleEl.scrollLeft = 0
|
titleEl.scrollLeft = 0
|
||||||
|
if (save && next && next !== original) props.onTitleChange?.(next)
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
|
|
||||||
if (!save || !next || next === original) {
|
if (!save || !next || next === original) {
|
||||||
|
committing = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await rename.mutateAsync(next)
|
try {
|
||||||
|
await rename(next)
|
||||||
|
} catch (err) {
|
||||||
|
props.onTitleChangeFailed?.(original)
|
||||||
|
showToast({
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
committing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -140,7 +163,7 @@ export function TabNavItem(props: {
|
|||||||
const openRename = (event: MouseEvent) => {
|
const openRename = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
|
if (!canOpenTabRename(props.dragging, editing(), committing)) return
|
||||||
const session = props.session()
|
const session = props.session()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
titleEl.textContent = session.title
|
titleEl.textContent = session.title
|
||||||
@@ -180,11 +203,11 @@ export function TabNavItem(props: {
|
|||||||
data-slot="titlebar-tab-item"
|
data-slot="titlebar-tab-item"
|
||||||
data-title-overflow={titleOverflowing()}
|
data-title-overflow={titleOverflowing()}
|
||||||
data-editing={editing()}
|
data-editing={editing()}
|
||||||
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]"
|
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||||
classList={{ invisible: props.hidden }}
|
classList={{ invisible: props.hidden }}
|
||||||
data-active={props.active}
|
data-active={props.active}
|
||||||
data-dragging={props.dragging}
|
data-dragging={props.dragging}
|
||||||
data-state={props.active || props.pressed ? "pressed" : undefined}
|
data-pressed={props.pressed}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -195,89 +218,90 @@ export function TabNavItem(props: {
|
|||||||
closeTab(event)
|
closeTab(event)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<a
|
<Show when={title() !== undefined}>
|
||||||
data-slot="tab-link"
|
<a
|
||||||
data-titlebar-tab-link
|
data-slot="tab-link"
|
||||||
href={props.href}
|
data-titlebar-tab-link
|
||||||
draggable={false}
|
href={props.href}
|
||||||
onDragStart={(event) => {
|
draggable={false}
|
||||||
event.preventDefault()
|
onDragStart={(event) => {
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
|
||||||
if (event.button !== 0) return
|
|
||||||
if (editing()) return
|
|
||||||
if (props.suppressNavigation?.()) return
|
|
||||||
props.onNavigate()
|
|
||||||
}}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
|
||||||
if (event.detail > 0) return
|
|
||||||
if (editing()) return
|
|
||||||
if (props.suppressNavigation?.()) return
|
|
||||||
props.onNavigate()
|
|
||||||
}}
|
|
||||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
|
||||||
>
|
|
||||||
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
|
|
||||||
<Show
|
|
||||||
when={props.session()}
|
|
||||||
keyed
|
|
||||||
fallback={
|
|
||||||
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(session) => (
|
|
||||||
<SessionTabAvatar
|
|
||||||
project={project()}
|
|
||||||
directory={session.directory}
|
|
||||||
sessionId={session.id}
|
|
||||||
server={props.server}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
ref={(el) => {
|
|
||||||
titleEl = el
|
|
||||||
titleEl.textContent = title() ?? ""
|
|
||||||
}}
|
|
||||||
data-slot="tab-title"
|
|
||||||
data-titlebar-tab-title
|
|
||||||
class="min-w-0 flex-1 outline-none leading-4"
|
|
||||||
classList={{
|
|
||||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
|
||||||
"select-text": editing(),
|
|
||||||
}}
|
|
||||||
contenteditable={editing() ? true : undefined}
|
|
||||||
onDblClick={openRename}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault()
|
|
||||||
void closeRename(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.key !== "Escape") return
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
titleEl.textContent = props.session()?.title ?? ""
|
|
||||||
void closeRename(false)
|
|
||||||
}}
|
|
||||||
onBlur={() => void closeRename(true)}
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
if (!editing()) return
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||||
|
if (event.button !== 0) return
|
||||||
|
if (editing()) return
|
||||||
|
if (props.suppressNavigation?.()) return
|
||||||
|
props.onNavigate()
|
||||||
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (!editing()) return
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||||
|
if (event.detail > 0) return
|
||||||
|
if (editing()) return
|
||||||
|
if (props.suppressNavigation?.()) return
|
||||||
|
props.onNavigate()
|
||||||
}}
|
}}
|
||||||
/>
|
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||||
</a>
|
>
|
||||||
|
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
|
||||||
|
<Show
|
||||||
|
when={props.session()}
|
||||||
|
fallback={
|
||||||
|
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(session) => (
|
||||||
|
<SessionTabAvatar
|
||||||
|
project={project()}
|
||||||
|
directory={session().directory}
|
||||||
|
sessionId={session().id}
|
||||||
|
server={props.server}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
ref={(el) => {
|
||||||
|
titleEl = el
|
||||||
|
titleEl.textContent = title() ?? ""
|
||||||
|
}}
|
||||||
|
data-slot="tab-title"
|
||||||
|
data-titlebar-tab-title
|
||||||
|
class="min-w-0 flex-1 outline-none leading-4"
|
||||||
|
classList={{
|
||||||
|
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||||
|
"select-text": editing(),
|
||||||
|
}}
|
||||||
|
contenteditable={editing() ? true : undefined}
|
||||||
|
onDblClick={openRename}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
void closeRename(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key !== "Escape") return
|
||||||
|
event.preventDefault()
|
||||||
|
titleEl.textContent = props.session()?.title ?? ""
|
||||||
|
void closeRename(false)
|
||||||
|
}}
|
||||||
|
onBlur={() => void closeRename(true)}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (!editing()) return
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!editing()) return
|
||||||
|
event.preventDefault()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div data-slot="tab-close">
|
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||||
<IconButtonV2
|
<IconButtonV2
|
||||||
size="small"
|
size="small"
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
@@ -335,8 +359,8 @@ export function DraftTabItem(props: {
|
|||||||
data-slot="titlebar-tab-item"
|
data-slot="titlebar-tab-item"
|
||||||
data-active={props.active}
|
data-active={props.active}
|
||||||
data-dragging={props.dragging}
|
data-dragging={props.dragging}
|
||||||
data-state={props.active || props.pressed ? "pressed" : undefined}
|
data-pressed={props.pressed}
|
||||||
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap"
|
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||||
classList={{ invisible: props.hidden }}
|
classList={{ invisible: props.hidden }}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
||||||
@@ -382,7 +406,7 @@ export function DraftTabItem(props: {
|
|||||||
{props.title}
|
{props.title}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<div data-slot="tab-close">
|
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||||
<IconButtonV2
|
<IconButtonV2
|
||||||
size="small"
|
size="small"
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
|
|
||||||
|
|
||||||
describe("adjacentTabKey", () => {
|
|
||||||
test("follows the visible left-to-right order", () => {
|
|
||||||
expect(adjacentTabKey(["c", "a", "b"], "c", 1)).toBe("a")
|
|
||||||
expect(adjacentTabKey(["c", "a", "b"], "a", -1)).toBe("c")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("skips tabs omitted from the visible order", () => {
|
|
||||||
expect(adjacentTabKey(["a", "c"], "a", 1)).toBe("c")
|
|
||||||
expect(adjacentTabKey(["a", "c"], "c", 1)).toBe("a")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("merges reordered visible tabs around hidden tabs", () => {
|
|
||||||
expect(mergeVisibleTabOrder(["a", "hidden", "b", "c"], ["a", "b", "c"], ["c", "a", "b"])).toEqual([
|
|
||||||
"c",
|
|
||||||
"hidden",
|
|
||||||
"a",
|
|
||||||
"b",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
export function adjacentTabKey(order: string[], current: string | undefined, offset: -1 | 1) {
|
|
||||||
if (!current || order.length === 0) return
|
|
||||||
const index = order.indexOf(current)
|
|
||||||
if (index === -1) return
|
|
||||||
return order[(index + offset + order.length) % order.length]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeVisibleTabOrder(all: string[], current: string[], next: string[]) {
|
|
||||||
const visible = new Set(current)
|
|
||||||
const reordered = next.values()
|
|
||||||
return all.map((key) => (visible.has(key) ? (reordered.next().value ?? key) : key))
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount, Show } from "solid-js"
|
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||||
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
||||||
@@ -16,10 +15,7 @@ import { useCommand } from "@/context/command"
|
|||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { createTabPromptState } from "@/context/prompt"
|
import { createTabPromptState } from "@/context/prompt"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { showToast } from "@/utils/toast"
|
|
||||||
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
||||||
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
|
|
||||||
import type { Session } from "@opencode-ai/sdk/v2"
|
|
||||||
|
|
||||||
function SessionTabSlot(props: {
|
function SessionTabSlot(props: {
|
||||||
tab: SessionTab
|
tab: SessionTab
|
||||||
@@ -27,12 +23,12 @@ function SessionTabSlot(props: {
|
|||||||
index: () => number
|
index: () => number
|
||||||
active: () => boolean
|
active: () => boolean
|
||||||
forceTruncate: boolean
|
forceTruncate: boolean
|
||||||
session: () => Session | undefined
|
serverCtx: () => ServerCtx | undefined
|
||||||
fallbackTitle?: string
|
|
||||||
onRename: (title: string) => Promise<void>
|
|
||||||
onNavigate: (element: HTMLDivElement) => void
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const tabs = useTabs()
|
||||||
|
const language = useLanguage()
|
||||||
const sortable = useSortable({
|
const sortable = useSortable({
|
||||||
get id() {
|
get id() {
|
||||||
return props.id
|
return props.id
|
||||||
@@ -42,47 +38,6 @@ function SessionTabSlot(props: {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
let ref!: HTMLDivElement
|
let ref!: HTMLDivElement
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={sortable.ref}
|
|
||||||
data-titlebar-tab-slot
|
|
||||||
data-tab-key={props.id}
|
|
||||||
data-active={props.active()}
|
|
||||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
|
||||||
>
|
|
||||||
<TabNavItem
|
|
||||||
ref={(el) => {
|
|
||||||
ref = el
|
|
||||||
}}
|
|
||||||
href={tabHref(props.tab)}
|
|
||||||
server={props.tab.server}
|
|
||||||
session={props.session}
|
|
||||||
fallbackTitle={props.fallbackTitle}
|
|
||||||
onRename={props.onRename}
|
|
||||||
onNavigate={() => props.onNavigate(ref)}
|
|
||||||
onClose={props.onClose}
|
|
||||||
active={props.active()}
|
|
||||||
forceTruncate={props.forceTruncate}
|
|
||||||
dragging={sortable.isDragSource()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionTabEntry(props: {
|
|
||||||
tab: SessionTab
|
|
||||||
id: string
|
|
||||||
index: () => number
|
|
||||||
active: () => boolean
|
|
||||||
forceTruncate: boolean
|
|
||||||
serverCtx: () => ServerCtx | undefined
|
|
||||||
onVisibleChange: (visible: boolean) => void
|
|
||||||
onNavigate: (element: HTMLDivElement) => void
|
|
||||||
onClose: () => void
|
|
||||||
}) {
|
|
||||||
const tabs = useTabs()
|
|
||||||
const language = useLanguage()
|
|
||||||
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
||||||
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
||||||
const persisted = createMemo(() => tabs.info[props.id])
|
const persisted = createMemo(() => tabs.info[props.id])
|
||||||
@@ -95,30 +50,8 @@ function SessionTabEntry(props: {
|
|||||||
)
|
)
|
||||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||||
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
|
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
|
||||||
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
|
|
||||||
let prefetched = false
|
let prefetched = false
|
||||||
|
|
||||||
const rename = async (title: string) => {
|
|
||||||
const value = session()
|
|
||||||
const ctx = props.serverCtx()
|
|
||||||
if (!value || !ctx) return
|
|
||||||
|
|
||||||
ctx.sync.session.remember({ ...value, title })
|
|
||||||
try {
|
|
||||||
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
|
||||||
} catch (err) {
|
|
||||||
const current = session()
|
|
||||||
const currentCtx = props.serverCtx()
|
|
||||||
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
|
|
||||||
showToast({
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => props.onVisibleChange(visible()))
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const ctx = props.serverCtx()
|
const ctx = props.serverCtx()
|
||||||
const value = session()
|
const value = session()
|
||||||
@@ -150,20 +83,39 @@ function SessionTabEntry(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={visible()}>
|
<div
|
||||||
<SessionTabSlot
|
ref={sortable.ref}
|
||||||
tab={props.tab}
|
data-titlebar-tab-slot
|
||||||
id={props.id}
|
data-tab-key={props.id}
|
||||||
index={props.index}
|
data-active={props.active()}
|
||||||
active={props.active}
|
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||||
forceTruncate={props.forceTruncate}
|
classList={{ hidden: !session() && !missingSession() && !persisted()?.title }}
|
||||||
|
>
|
||||||
|
<TabNavItem
|
||||||
|
ref={(el) => {
|
||||||
|
ref = el
|
||||||
|
}}
|
||||||
|
href={tabHref(props.tab)}
|
||||||
|
server={props.tab.server}
|
||||||
session={session}
|
session={session}
|
||||||
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
|
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
|
||||||
onRename={rename}
|
onTitleChange={(title) => {
|
||||||
onNavigate={props.onNavigate}
|
const value = session()
|
||||||
|
const ctx = props.serverCtx()
|
||||||
|
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||||
|
}}
|
||||||
|
onTitleChangeFailed={(title) => {
|
||||||
|
const value = session()
|
||||||
|
const ctx = props.serverCtx()
|
||||||
|
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||||
|
}}
|
||||||
|
onNavigate={() => props.onNavigate(ref)}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
|
active={props.active()}
|
||||||
|
forceTruncate={props.forceTruncate}
|
||||||
|
dragging={sortable.isDragSource()}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,39 +172,11 @@ export function TitlebarTabStrip(props: {
|
|||||||
}) {
|
}) {
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const command = useCommand()
|
|
||||||
let scrollRef!: HTMLDivElement
|
let scrollRef!: HTMLDivElement
|
||||||
let listRef!: HTMLDivElement
|
let listRef!: HTMLDivElement
|
||||||
let resizeFrame: number | undefined
|
let resizeFrame: number | undefined
|
||||||
const [visibility, setVisibility] = createStore<Record<string, boolean>>({})
|
|
||||||
const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)]))
|
|
||||||
const visibleTabIds = () => visibleTabs().map(tabKey)
|
|
||||||
|
|
||||||
command.register("titlebar-tab-cycle", () => [
|
const tabIds = () => props.tabs.map(tabKey)
|
||||||
{
|
|
||||||
id: `tab.prev`,
|
|
||||||
category: "tab",
|
|
||||||
title: "",
|
|
||||||
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
|
|
||||||
hidden: true,
|
|
||||||
onSelect: () => selectAdjacentTab(-1),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: `tab.next`,
|
|
||||||
category: "tab",
|
|
||||||
title: "",
|
|
||||||
keybind: `mod+option+ArrowRight,ctrl+tab`,
|
|
||||||
hidden: true,
|
|
||||||
onSelect: () => selectAdjacentTab(1),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
function selectAdjacentTab(offset: -1 | 1) {
|
|
||||||
const current = props.currentTab()
|
|
||||||
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
|
|
||||||
const next = props.tabs.find((tab) => tabKey(tab) === key)
|
|
||||||
if (next) props.onNavigate(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshOverflow() {
|
function refreshOverflow() {
|
||||||
if (!scrollRef) return
|
if (!scrollRef) return
|
||||||
@@ -280,7 +204,7 @@ export function TitlebarTabStrip(props: {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
props.tabs.length
|
props.tabs.length
|
||||||
visibleTabIds()
|
tabIds()
|
||||||
refreshOverflow()
|
refreshOverflow()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -316,29 +240,22 @@ export function TitlebarTabStrip(props: {
|
|||||||
props.onNavigate(tab, tabEl ?? undefined)
|
props.onNavigate(tab, tabEl ?? undefined)
|
||||||
}}
|
}}
|
||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
const current = visibleTabIds()
|
const current = tabIds()
|
||||||
const source = event.operation.source
|
const source = event.operation.source
|
||||||
if (event.canceled || !isSortable(source)) return
|
if (event.canceled || !isSortable(source)) return
|
||||||
|
|
||||||
const { initialIndex, index } = source
|
const { initialIndex, index } = source
|
||||||
if (initialIndex !== index) {
|
if (initialIndex !== index) {
|
||||||
props.onReorder(
|
props.onReorder(arrayMove(current, source.initialIndex, source.index))
|
||||||
mergeVisibleTabOrder(
|
|
||||||
props.tabs.map(tabKey),
|
|
||||||
current,
|
|
||||||
arrayMove(current, source.initialIndex, source.index),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
|
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
|
||||||
<For each={props.tabs}>
|
<For each={props.tabs}>
|
||||||
{(tab) => {
|
{(tab, index) => {
|
||||||
const id = tabKey(tab)
|
const id = tabKey(tab)
|
||||||
let ref!: HTMLDivElement
|
let ref!: HTMLDivElement
|
||||||
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
|
useTabShortcut(index, () => props.onNavigate(tab, ref))
|
||||||
useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
|
|
||||||
const serverCtx = createMemo(() => {
|
const serverCtx = createMemo(() => {
|
||||||
if (tab.type !== "session") return
|
if (tab.type !== "session") return
|
||||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
||||||
@@ -347,14 +264,13 @@ export function TitlebarTabStrip(props: {
|
|||||||
|
|
||||||
if (tab.type === "session") {
|
if (tab.type === "session") {
|
||||||
return (
|
return (
|
||||||
<SessionTabEntry
|
<SessionTabSlot
|
||||||
tab={tab}
|
tab={tab}
|
||||||
id={id}
|
id={id}
|
||||||
index={visibleIndex}
|
index={index}
|
||||||
active={() => props.currentTab() === tab}
|
active={() => props.currentTab() === tab}
|
||||||
forceTruncate={props.forceTruncate}
|
forceTruncate={props.forceTruncate}
|
||||||
serverCtx={serverCtx}
|
serverCtx={serverCtx}
|
||||||
onVisibleChange={(visible) => setVisibility(id, visible)}
|
|
||||||
onNavigate={(element) => {
|
onNavigate={(element) => {
|
||||||
ref = element
|
ref = element
|
||||||
props.onNavigate(tab, element)
|
props.onNavigate(tab, element)
|
||||||
@@ -368,7 +284,7 @@ export function TitlebarTabStrip(props: {
|
|||||||
<DraftTabSlot
|
<DraftTabSlot
|
||||||
tab={tab}
|
tab={tab}
|
||||||
id={id}
|
id={id}
|
||||||
index={visibleIndex}
|
index={index}
|
||||||
active={() => props.currentTab() === tab}
|
active={() => props.currentTab() === tab}
|
||||||
title={language.t("command.session.new")}
|
title={language.t("command.session.new")}
|
||||||
onNavigate={(element) => {
|
onNavigate={(element) => {
|
||||||
@@ -402,7 +318,7 @@ function useTabShortcut(index: () => number, onSelect: () => void) {
|
|||||||
|
|
||||||
command.register(() => {
|
command.register(() => {
|
||||||
const number = index() + 1
|
const number = index() + 1
|
||||||
if (number < 1 || number > 9) return []
|
if (number > 9) return []
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: `tab.${number}`,
|
id: `tab.${number}`,
|
||||||
|
|||||||
@@ -338,6 +338,40 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
keybind: "mod+shift+t",
|
keybind: "mod+shift+t",
|
||||||
onSelect: () => tabsStoreActions.reopenClosedTab(),
|
onSelect: () => tabsStoreActions.reopenClosedTab(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: `tab.prev`,
|
||||||
|
category: "tab",
|
||||||
|
title: "",
|
||||||
|
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
|
||||||
|
hidden: true,
|
||||||
|
onSelect: () => {
|
||||||
|
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
index -= 1
|
||||||
|
if (index === -1) index = tabsStore.length - 1
|
||||||
|
|
||||||
|
const next = tabsStore[index]
|
||||||
|
if (next) tabs.select(next)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `tab.next`,
|
||||||
|
category: "tab",
|
||||||
|
title: "",
|
||||||
|
keybind: `mod+option+ArrowRight,ctrl+tab`,
|
||||||
|
hidden: true,
|
||||||
|
onSelect: () => {
|
||||||
|
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
index += 1
|
||||||
|
if (index === tabsStore.length) index = 0
|
||||||
|
|
||||||
|
const next = tabsStore[index]
|
||||||
|
if (next) tabs.select(next)
|
||||||
|
},
|
||||||
|
},
|
||||||
].filter((v) => v !== undefined)
|
].filter((v) => v !== undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -395,25 +429,27 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
}}
|
}}
|
||||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||||
/>
|
/>
|
||||||
<TooltipV2
|
<Show when={!creating()}>
|
||||||
placement="bottom"
|
<TooltipV2
|
||||||
value={
|
placement="bottom"
|
||||||
<>
|
value={
|
||||||
{language.t("command.session.new")}
|
<>
|
||||||
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
|
{language.t("command.session.new")}
|
||||||
</>
|
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
|
||||||
}
|
</>
|
||||||
>
|
}
|
||||||
<IconButtonV2
|
>
|
||||||
type="button"
|
<IconButtonV2
|
||||||
variant="ghost-muted"
|
type="button"
|
||||||
size="large"
|
variant="ghost-muted"
|
||||||
class="shrink-0"
|
size="large"
|
||||||
icon={<IconV2 name="plus" />}
|
class="shrink-0"
|
||||||
onClick={openNewTab}
|
icon={<IconV2 name="plus" />}
|
||||||
aria-label={language.t("command.session.new")}
|
onClick={openNewTab}
|
||||||
/>
|
aria-label={language.t("command.session.new")}
|
||||||
</TooltipV2>
|
/>
|
||||||
|
</TooltipV2>
|
||||||
|
</Show>
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
<TitlebarV2Right state={v2RightState()} />
|
<TitlebarV2Right state={v2RightState()} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function WindowsAppMenu(props: {
|
|||||||
runAction(entry.action)
|
runAction(entry.action)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (entry.href) props.platform.openExternal(entry.href)
|
if (entry.href) props.platform.openLink(entry.href)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -21,29 +21,6 @@ describe("file path helpers", () => {
|
|||||||
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
|
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("normalizes Windows directory separators", () => {
|
|
||||||
const path = createPathHelpers(() => "C:\\repo")
|
|
||||||
expect(path.normalizeDir("frontend\\")).toBe("frontend")
|
|
||||||
expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src")
|
|
||||||
expect(path.normalizeDir("C:\\repo\\frontend\\")).toBe("frontend")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("normalizes separators for Windows roots written with forward slashes", () => {
|
|
||||||
const path = createPathHelpers(() => "C:/repo")
|
|
||||||
expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("normalizes separators for Windows UNC roots", () => {
|
|
||||||
const path = createPathHelpers(() => "\\\\server\\share")
|
|
||||||
expect(path.normalizeDir("\\\\server\\share\\frontend\\")).toBe("frontend")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves backslashes in POSIX directory names", () => {
|
|
||||||
const path = createPathHelpers(() => "/repo")
|
|
||||||
expect(path.normalizeDir("literal\\name\\")).toBe("literal\\name\\")
|
|
||||||
expect(path.normalizeDir("literal\\name/")).toBe("literal\\name")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps query/hash stripping behavior stable", () => {
|
test("keeps query/hash stripping behavior stable", () => {
|
||||||
expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts")
|
expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts")
|
||||||
expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts")
|
expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts")
|
||||||
|
|||||||
@@ -140,12 +140,7 @@ export function createPathHelpers(scope: () => string) {
|
|||||||
return normalize(tabValue)
|
return normalize(tabValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeDir = (input: string) => {
|
const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "")
|
||||||
const path = normalize(input)
|
|
||||||
const root = scope()
|
|
||||||
const windows = /^[A-Za-z]:/.test(root) || root.startsWith("\\\\")
|
|
||||||
return (windows ? path.replace(/\\/g, "/") : path).replace(/\/+$/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
normalize,
|
normalize,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
bootstrapDirectory,
|
bootstrapDirectory,
|
||||||
loadAgentsQuery,
|
loadAgentsQuery,
|
||||||
loadCommands,
|
loadCommands,
|
||||||
loadGlobalConfigQuery,
|
|
||||||
loadPathQuery,
|
loadPathQuery,
|
||||||
loadProjectsQuery,
|
loadProjectsQuery,
|
||||||
loadProvidersQuery,
|
loadProvidersQuery,
|
||||||
@@ -77,7 +76,6 @@ function directoryState() {
|
|||||||
|
|
||||||
describe("bootstrapDirectory", () => {
|
describe("bootstrapDirectory", () => {
|
||||||
test("uses legacy MCP endpoints while refreshing a v1 directory", async () => {
|
test("uses legacy MCP endpoints while refreshing a v1 directory", async () => {
|
||||||
const legacyConfigReads: string[] = []
|
|
||||||
const mcpReads: string[] = []
|
const mcpReads: string[] = []
|
||||||
const [store, setStore] = directoryState()
|
const [store, setStore] = directoryState()
|
||||||
|
|
||||||
@@ -93,12 +91,7 @@ describe("bootstrapDirectory", () => {
|
|||||||
},
|
},
|
||||||
sdk: {
|
sdk: {
|
||||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||||
config: {
|
config: { get: async () => ({ data: {} }) },
|
||||||
get: async () => {
|
|
||||||
legacyConfigReads.push("directory")
|
|
||||||
return { data: {} }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
session: { status: async () => ({ data: {} }) },
|
session: { status: async () => ({ data: {} }) },
|
||||||
vcs: { get: async () => ({ data: undefined }) },
|
vcs: { get: async () => ({ data: undefined }) },
|
||||||
command: {
|
command: {
|
||||||
@@ -141,88 +134,8 @@ describe("bootstrapDirectory", () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||||
|
|
||||||
expect(store.status).toBe("complete")
|
expect(store.status).toBe("complete")
|
||||||
expect(legacyConfigReads).toEqual(["directory"])
|
|
||||||
expect(mcpReads.sort()).toEqual(["command", "resource", "status"])
|
expect(mcpReads.sort()).toEqual(["command", "resource", "status"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("skips legacy config while refreshing a v2 directory", async () => {
|
|
||||||
const [store, setStore] = directoryState()
|
|
||||||
|
|
||||||
await bootstrapDirectory({
|
|
||||||
directory: "/project",
|
|
||||||
scope: ServerScope.local,
|
|
||||||
mcp: false,
|
|
||||||
global: {
|
|
||||||
config: {} satisfies Config,
|
|
||||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
|
||||||
project: [{ id: "project", worktree: "/project" } as Project],
|
|
||||||
provider,
|
|
||||||
},
|
|
||||||
sdk: {
|
|
||||||
config: {
|
|
||||||
get: async () => {
|
|
||||||
throw new Error("legacy directory config should not be called")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as OpencodeClient,
|
|
||||||
api,
|
|
||||||
store,
|
|
||||||
setStore,
|
|
||||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
|
||||||
loadSessions() {},
|
|
||||||
translate: (key) => key,
|
|
||||||
queryClient: new QueryClient(),
|
|
||||||
protocol: Promise.resolve("v2"),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(store.status).toBe("partial")
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
|
||||||
|
|
||||||
expect(store.status).toBe("complete")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("config queries", () => {
|
|
||||||
test("skips legacy global config for v2 servers", async () => {
|
|
||||||
const sdk = {
|
|
||||||
global: {
|
|
||||||
config: {
|
|
||||||
get: async () => {
|
|
||||||
throw new Error("legacy global config should not be called")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as OpencodeClient
|
|
||||||
|
|
||||||
const result = await new QueryClient().fetchQuery(
|
|
||||||
loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v2")),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result).toEqual({})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("loads legacy global config for v1 servers", async () => {
|
|
||||||
const calls: string[] = []
|
|
||||||
const config = { shell: "zsh" } satisfies Config
|
|
||||||
const sdk = {
|
|
||||||
global: {
|
|
||||||
config: {
|
|
||||||
get: async () => {
|
|
||||||
calls.push("global")
|
|
||||||
return { data: config }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as OpencodeClient
|
|
||||||
|
|
||||||
const result = await new QueryClient().fetchQuery(
|
|
||||||
loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v1")),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result).toEqual(config)
|
|
||||||
expect(calls).toEqual(["global"])
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("query keys", () => {
|
describe("query keys", () => {
|
||||||
|
|||||||
@@ -105,13 +105,10 @@ function showErrors(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient, protocol?: Promise<ServerProtocol>) =>
|
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, "config"],
|
queryKey: [scope, "config"],
|
||||||
queryFn: async () => {
|
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
||||||
if ((await protocol) !== "v1") return {}
|
|
||||||
return retry(() => sdk.global.config.get().then((x) => x.data!))
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
type ProjectApi = {
|
type ProjectApi = {
|
||||||
@@ -152,7 +149,7 @@ export async function bootstrapGlobal(input: {
|
|||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
}) {
|
}) {
|
||||||
const slow = [
|
const slow = [
|
||||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK, input.protocol)),
|
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient.fetchQuery(
|
input.queryClient.fetchQuery(
|
||||||
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
|
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
|
||||||
@@ -379,10 +376,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
|
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
|
||||||
.then((data) => input.setStore("agent", data)),
|
.then((data) => input.setStore("agent", data)),
|
||||||
() =>
|
() =>
|
||||||
retry(async () => {
|
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
||||||
if ((await input.protocol) !== "v1") return
|
|
||||||
return input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))
|
|
||||||
}),
|
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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 { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
import { useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { ServerSDK } from "./server-sdk"
|
import type { ServerSDK } from "./server-sdk"
|
||||||
import type { ServerSync } from "./server-sync"
|
import type { ServerSync } from "./server-sync"
|
||||||
@@ -118,7 +118,6 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const navigate = useNavigate()
|
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -154,7 +153,6 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
platform,
|
platform,
|
||||||
settings,
|
settings,
|
||||||
language,
|
language,
|
||||||
navigate,
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
owner ?? undefined,
|
owner ?? undefined,
|
||||||
@@ -219,7 +217,6 @@ function createServerNotificationState(input: {
|
|||||||
platform: ReturnType<typeof usePlatform>
|
platform: ReturnType<typeof usePlatform>
|
||||||
settings: ReturnType<typeof useSettings>
|
settings: ReturnType<typeof useSettings>
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
navigate: (href: string) => void
|
|
||||||
}) {
|
}) {
|
||||||
const serverSDK = () => input.sdk
|
const serverSDK = () => input.sdk
|
||||||
const serverSync = () => input.sync
|
const serverSync = () => input.sync
|
||||||
@@ -356,9 +353,7 @@ function createServerNotificationState(input: {
|
|||||||
|
|
||||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||||
if (settings.notifications.agent()) {
|
if (settings.notifications.agent()) {
|
||||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
|
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
|
||||||
input.navigate(href),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -391,7 +386,7 @@ function createServerNotificationState(input: {
|
|||||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||||
if (settings.notifications.errors()) {
|
if (settings.notifications.errors()) {
|
||||||
void platform.notify(language.t("notification.session.error.title"), description, () => input.navigate(href))
|
void platform.notify(language.t("notification.session.error.title"), description, href)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { DesktopMenuAction } from "../desktop-menu"
|
|||||||
import { ServerConnection } from "./server"
|
import { ServerConnection } from "./server"
|
||||||
import type { WslServersPlatform } from "../wsl/types"
|
import type { WslServersPlatform } from "../wsl/types"
|
||||||
import type { UpdaterPlatform } from "../updater"
|
import type { UpdaterPlatform } from "../updater"
|
||||||
import type { DraftStore } from "@/utils/draft-store"
|
|
||||||
|
|
||||||
type PickerPaths = string | string[] | null
|
type PickerPaths = string | string[] | null
|
||||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||||
@@ -32,23 +31,26 @@ type PlatformBase = {
|
|||||||
/** App version */
|
/** App version */
|
||||||
version?: string
|
version?: string
|
||||||
|
|
||||||
/** Open a web or mail URL in the default system application */
|
/** Open a URL in the default browser */
|
||||||
openExternal(url: string): void
|
openLink(url: string): void
|
||||||
|
|
||||||
/** Open a local path in a local app (desktop only) */
|
/** Open a local path in a local app (desktop only) */
|
||||||
openPath?(path: string, app?: string): Promise<void>
|
openPath?(path: string, app?: string): Promise<void>
|
||||||
|
|
||||||
/** Open a local file URL in its default app (desktop only) */
|
|
||||||
openLocalFile?(url: string): void
|
|
||||||
|
|
||||||
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
|
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
|
||||||
revealPath?(path: string): Promise<boolean>
|
revealPath?(path: string): Promise<boolean>
|
||||||
|
|
||||||
/** Restart the app */
|
/** Restart the app */
|
||||||
restart(): Promise<void>
|
restart(): Promise<void>
|
||||||
|
|
||||||
/** Send a system notification */
|
/** Navigate back in history */
|
||||||
notify(title: string, description?: string, onClick?: () => void): Promise<void>
|
back(): void
|
||||||
|
|
||||||
|
/** Navigate forward in history */
|
||||||
|
forward(): void
|
||||||
|
|
||||||
|
/** Send a system notification (optional deep link) */
|
||||||
|
notify(title: string, description?: string, href?: string): Promise<void>
|
||||||
|
|
||||||
/** Open a native attachment picker and read selected files sequentially (desktop only) */
|
/** Open a native attachment picker and read selected files sequentially (desktop only) */
|
||||||
openAttachmentPickerDialog?(
|
openAttachmentPickerDialog?(
|
||||||
@@ -65,9 +67,6 @@ type PlatformBase = {
|
|||||||
/** Storage mechanism, defaults to localStorage */
|
/** Storage mechanism, defaults to localStorage */
|
||||||
storage?: (name?: string) => SyncStorage | AsyncStorage
|
storage?: (name?: string) => SyncStorage | AsyncStorage
|
||||||
|
|
||||||
/** Prompt drafts, history, and their blobs. */
|
|
||||||
draftStore?: DraftStore
|
|
||||||
|
|
||||||
/** Stable platform window identity for window-scoped persistence */
|
/** Stable platform window identity for window-scoped persistence */
|
||||||
windowID?: string
|
windowID?: string
|
||||||
|
|
||||||
@@ -92,6 +91,9 @@ type PlatformBase = {
|
|||||||
/** Set the preferred display backend (desktop only) */
|
/** Set the preferred display backend (desktop only) */
|
||||||
setDisplayBackend?(backend: DisplayBackend): Promise<void>
|
setDisplayBackend?(backend: DisplayBackend): Promise<void>
|
||||||
|
|
||||||
|
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
|
||||||
|
parseMarkdown?(markdown: string): Promise<string>
|
||||||
|
|
||||||
/** Webview zoom level (desktop only) */
|
/** Webview zoom level (desktop only) */
|
||||||
webviewZoom?: Accessor<number>
|
webviewZoom?: Accessor<number>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { createStore, type SetStoreFunction } from "solid-js/store"
|
|||||||
import type { FileSelection } from "@/context/file"
|
import type { FileSelection } from "@/context/file"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import type { BlobReference } from "@/utils/draft-store"
|
|
||||||
|
|
||||||
interface PartBase {
|
interface PartBase {
|
||||||
content: string
|
content: string
|
||||||
@@ -38,7 +37,7 @@ export interface ImageAttachmentPart {
|
|||||||
filename: string
|
filename: string
|
||||||
sourcePath?: string
|
sourcePath?: string
|
||||||
mime: string
|
mime: string
|
||||||
blob: BlobReference
|
dataUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
||||||
@@ -169,9 +168,9 @@ function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
|
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
|
||||||
if ("draftID" in scope) return Persist.prompt(Persist.draft(scope.draftID, "prompt"))
|
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
|
||||||
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
||||||
return Persist.prompt(Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]))
|
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
||||||
}
|
}
|
||||||
|
|
||||||
function promptStore(initial?: InitialPrompt): PromptStore {
|
function promptStore(initial?: InitialPrompt): PromptStore {
|
||||||
@@ -246,7 +245,7 @@ export function createPromptSession(serverScope: ServerScope, scope: PromptScope
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
|
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
|
||||||
return createPersistedPrompt(Persist.prompt(Persist.draft(draftID, "prompt")), initial)
|
return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptSession = ReturnType<typeof createPromptSession>
|
export type PromptSession = ReturnType<typeof createPromptSession>
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ function makeQueryOptionsApi(
|
|||||||
protocol: Promise<"v1" | "v2">,
|
protocol: Promise<"v1" | "v2">,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK(), protocol),
|
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
|
||||||
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
||||||
providers: (directory: PathKey | null) =>
|
providers: (directory: PathKey | null) =>
|
||||||
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
|
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
|
||||||
@@ -290,10 +290,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
|
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
|
||||||
const refreshProviders = () =>
|
|
||||||
queryClient.refetchQueries({
|
|
||||||
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
|
|
||||||
})
|
|
||||||
|
|
||||||
let bootedAt = 0
|
let bootedAt = 0
|
||||||
let bootingRoot = false
|
let bootingRoot = false
|
||||||
@@ -541,7 +537,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
homeSessions.apply(event)
|
homeSessions.apply(event)
|
||||||
}
|
}
|
||||||
homeSessions.refresh(event.type)
|
homeSessions.refresh(event.type)
|
||||||
if (eventType === "integration.connection.updated") void refreshProviders()
|
|
||||||
|
|
||||||
if (directory === "global") {
|
if (directory === "global") {
|
||||||
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
|
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
|
||||||
@@ -683,7 +678,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
peek: children.peek,
|
peek: children.peek,
|
||||||
disableMcp: children.disableMcp,
|
disableMcp: children.disableMcp,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
refreshProviders,
|
|
||||||
// bootstrap,
|
// bootstrap,
|
||||||
updateConfig: updateConfigMutation.mutateAsync,
|
updateConfig: updateConfigMutation.mutateAsync,
|
||||||
project: projectApi,
|
project: projectApi,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import {
|
import {
|
||||||
hasExistingWebState,
|
hasExistingWebState,
|
||||||
initialAgentVisibility,
|
|
||||||
isAppUpgrade,
|
isAppUpgrade,
|
||||||
layoutTransitionState,
|
layoutTransitionState,
|
||||||
maximumSunsetTimeout,
|
maximumSunsetTimeout,
|
||||||
@@ -12,22 +11,6 @@ import {
|
|||||||
shouldEnableNewLayout,
|
shouldEnableNewLayout,
|
||||||
} from "./settings"
|
} from "./settings"
|
||||||
|
|
||||||
describe("agent visibility", () => {
|
|
||||||
test("shows the picker for existing profiles and hides it for first-time installs", () => {
|
|
||||||
expect(initialAgentVisibility(undefined, true)).toBe(true)
|
|
||||||
expect(initialAgentVisibility(undefined, false)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("shows the picker when updating from a recent release", () => {
|
|
||||||
expect(initialAgentVisibility(undefined, false, "1.18.8")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves the preference after initialization", () => {
|
|
||||||
expect(initialAgentVisibility(true, true, "1.18.8")).toBeUndefined()
|
|
||||||
expect(initialAgentVisibility(true, false)).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("layout transition", () => {
|
describe("layout transition", () => {
|
||||||
test("blank profiles default to the new layout", () => {
|
test("blank profiles default to the new layout", () => {
|
||||||
expect(newLayoutDesignsDefault).toBe(true)
|
expect(newLayoutDesignsDefault).toBe(true)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
@@ -36,7 +36,6 @@ export interface Settings {
|
|||||||
mobileTitlebarPosition: "top" | "bottom"
|
mobileTitlebarPosition: "top" | "bottom"
|
||||||
newLayoutDesigns?: boolean
|
newLayoutDesigns?: boolean
|
||||||
layoutTransitionEligible?: boolean
|
layoutTransitionEligible?: boolean
|
||||||
agentVisibilityInitialized?: boolean
|
|
||||||
newInterfaceNoticeDismissed?: boolean
|
newInterfaceNoticeDismissed?: boolean
|
||||||
shouldDisplayTabsToast?: boolean
|
shouldDisplayTabsToast?: boolean
|
||||||
}
|
}
|
||||||
@@ -94,11 +93,6 @@ export function hasExistingWebState(settings: Promise<string> | string | null, p
|
|||||||
return settings !== null || previousVersion !== undefined
|
return settings !== null || previousVersion !== undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initialAgentVisibility(initialized: boolean | undefined, existing: boolean, previousVersion?: string) {
|
|
||||||
if (initialized === true) return
|
|
||||||
return existing || previousVersion !== undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
|
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
|
||||||
if (!current) return false
|
if (!current) return false
|
||||||
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
|
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
|
||||||
@@ -277,14 +271,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
||||||
const initializeAgentVisibility = (existing: boolean) => {
|
|
||||||
const initial = initialAgentVisibility(store.general?.agentVisibilityInitialized, existing, launchState.previous)
|
|
||||||
if (initial === undefined) return
|
|
||||||
batch(() => {
|
|
||||||
setStore("general", "showCustomAgents", initial)
|
|
||||||
setStore("general", "agentVisibilityInitialized", true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sunset && !oldInterfaceRetired()) {
|
if (sunset && !oldInterfaceRetired()) {
|
||||||
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||||
@@ -313,9 +299,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!ready() || !launchState.classified || platform.platform !== "web") return
|
if (!ready() || !launchState.classified || platform.platform !== "web") return
|
||||||
const existing = hasExistingWebState(settingsInit, launchState.previous)
|
if (layoutTransitionClassified()) return
|
||||||
if (!layoutTransitionClassified()) setStore("general", "layoutTransitionEligible", existing)
|
setStore("general", "layoutTransitionEligible", hasExistingWebState(settingsInit, launchState.previous))
|
||||||
initializeAgentVisibility(existing)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -441,7 +426,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
if (typeof current === "boolean") return
|
if (typeof current === "boolean") return
|
||||||
setStore("general", "layoutTransitionEligible", eligible)
|
setStore("general", "layoutTransitionEligible", eligible)
|
||||||
},
|
},
|
||||||
initializeAgentVisibility,
|
|
||||||
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
|
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
|
||||||
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
|
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
|
||||||
dismissNewInterfaceNotice() {
|
dismissNewInterfaceNotice() {
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { ServerConnection } from "./server"
|
|
||||||
import type { Tab } from "./tabs"
|
|
||||||
|
|
||||||
export function migrateTabs(value: unknown, fallback: ServerConnection.Key): Tab[] {
|
|
||||||
if (!Array.isArray(value)) return []
|
|
||||||
return value.flatMap<Tab>((tab) => {
|
|
||||||
if (!tab || typeof tab !== "object") return []
|
|
||||||
if ("server" in tab && typeof tab.server !== "string") return []
|
|
||||||
const server = ("server" in tab ? tab.server : fallback) as ServerConnection.Key
|
|
||||||
if (tab.type === "session" && typeof tab.sessionId === "string") {
|
|
||||||
return [{ type: tab.type, server, sessionId: tab.sessionId }]
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
tab.type === "draft" &&
|
|
||||||
typeof tab.draftID === "string" &&
|
|
||||||
typeof tab.directory === "string" &&
|
|
||||||
(tab.worktree === undefined || typeof tab.worktree === "string")
|
|
||||||
) {
|
|
||||||
return [{ type: tab.type, server, draftID: tab.draftID, directory: tab.directory, worktree: tab.worktree }]
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import { createRoot, getOwner, onCleanup } from "solid-js"
|
|||||||
import { createTabMemory } from "./tab-memory"
|
import { createTabMemory } from "./tab-memory"
|
||||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||||
import type { SessionTab, Tab } from "./tabs"
|
import type { SessionTab, Tab } from "./tabs"
|
||||||
import { migrateTabs } from "./tab-migration"
|
|
||||||
import type { ServerConnection } from "./server"
|
import type { ServerConnection } from "./server"
|
||||||
|
|
||||||
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
|
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
|
||||||
@@ -12,23 +11,6 @@ function sessionTab(sessionId: string): SessionTab {
|
|||||||
return { type: "session", server, sessionId }
|
return { type: "session", server, sessionId }
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("tab migration", () => {
|
|
||||||
test("drops null and malformed persisted tabs", () => {
|
|
||||||
expect(
|
|
||||||
migrateTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"], server),
|
|
||||||
).toEqual([sessionTab("a")])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("adds the fallback server to valid legacy tabs", () => {
|
|
||||||
expect(migrateTabs([{ type: "session", sessionId: "a", dirBase64: "legacy" }], server)).toEqual([sessionTab("a")])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("replaces invalid top-level persisted data", () => {
|
|
||||||
expect(migrateTabs(null, server)).toEqual([])
|
|
||||||
expect(migrateTabs({}, server)).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("tab memory", () => {
|
describe("tab memory", () => {
|
||||||
test("keeps state until its tab is removed", () => {
|
test("keeps state until its tab is removed", () => {
|
||||||
createRoot((dispose) => {
|
createRoot((dispose) => {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { sessionHref } from "@/utils/session-route"
|
|||||||
import { createTabMemory } from "./tab-memory"
|
import { createTabMemory } from "./tab-memory"
|
||||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||||
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
|
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
|
||||||
import { migrateTabs } from "./tab-migration"
|
|
||||||
|
|
||||||
export type SessionTab = {
|
export type SessionTab = {
|
||||||
type: "session"
|
type: "session"
|
||||||
@@ -60,7 +59,13 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
{
|
{
|
||||||
...Persist.window("tabs"),
|
...Persist.window("tabs"),
|
||||||
migrate: (value: unknown) => migrateTabs(value, fallback),
|
migrate: (value: unknown) => {
|
||||||
|
if (!Array.isArray(value)) return value
|
||||||
|
return value.map((tab) => {
|
||||||
|
if (!tab || typeof tab !== "object" || "server" in tab) return tab
|
||||||
|
return { ...tab, server: fallback }
|
||||||
|
})
|
||||||
|
},
|
||||||
},
|
},
|
||||||
createStore<Tab[]>([]),
|
createStore<Tab[]>([]),
|
||||||
)
|
)
|
||||||
@@ -101,10 +106,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeDraftPersisted = (draftID: string) => {
|
const removeDraftPersisted = (draftID: string) => {
|
||||||
for (const key of draftPersistedKeys()) {
|
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
|
||||||
const target = Persist.draft(draftID, key)
|
|
||||||
removePersisted(key === "prompt" ? Persist.prompt(target) : target, platform)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeInfo = (key: string) => {
|
const removeInfo = (key: string) => {
|
||||||
|
|||||||
+16
-11
@@ -4,9 +4,9 @@ import * as Sentry from "@sentry/solid"
|
|||||||
import { render } from "solid-js/web"
|
import { render } from "solid-js/web"
|
||||||
import { AppBaseProviders, AppInterface } from "@/app"
|
import { AppBaseProviders, AppInterface } from "@/app"
|
||||||
import { type Platform, PlatformProvider } from "@/context/platform"
|
import { type Platform, PlatformProvider } from "@/context/platform"
|
||||||
import { createBrowserDraftStore } from "@/utils/draft-store"
|
|
||||||
import { dict as en } from "@/i18n/en"
|
import { dict as en } from "@/i18n/en"
|
||||||
import { dict as zh } from "@/i18n/zh"
|
import { dict as zh } from "@/i18n/zh"
|
||||||
|
import { handleNotificationClick } from "@/utils/notification-click"
|
||||||
import { authFromToken } from "@/utils/server"
|
import { authFromToken } from "@/utils/server"
|
||||||
import pkg from "../package.json"
|
import pkg from "../package.json"
|
||||||
import { ServerConnection } from "./context/server"
|
import { ServerConnection } from "./context/server"
|
||||||
@@ -54,7 +54,7 @@ const setStorage = (key: string, value: string | null) => {
|
|||||||
const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY)
|
const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY)
|
||||||
const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url)
|
const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url)
|
||||||
|
|
||||||
const notify: Platform["notify"] = async (title, description, onClick) => {
|
const notify: Platform["notify"] = async (title, description, href) => {
|
||||||
if (!("Notification" in window)) return
|
if (!("Notification" in window)) return
|
||||||
|
|
||||||
const permission =
|
const permission =
|
||||||
@@ -73,17 +73,21 @@ const notify: Platform["notify"] = async (title, description, onClick) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
notification.onclick = () => {
|
notification.onclick = () => {
|
||||||
window.focus()
|
handleNotificationClick(href)
|
||||||
onClick?.()
|
|
||||||
notification.close()
|
notification.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openExternal: Platform["openExternal"] = (value) => {
|
const openLink: Platform["openLink"] = (url) => {
|
||||||
if (!URL.canParse(value)) return
|
window.open(url, "_blank")
|
||||||
const url = new URL(value)
|
}
|
||||||
if (url.protocol !== "http:" && url.protocol !== "https:" && url.protocol !== "mailto:") return
|
|
||||||
window.open(url.href, "_blank", "noopener,noreferrer")
|
const back: Platform["back"] = () => {
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
const forward: Platform["forward"] = () => {
|
||||||
|
window.history.forward()
|
||||||
}
|
}
|
||||||
|
|
||||||
const restart: Platform["restart"] = async () => {
|
const restart: Platform["restart"] = async () => {
|
||||||
@@ -117,9 +121,10 @@ const clearAuthToken = () => {
|
|||||||
|
|
||||||
const platform: Platform = {
|
const platform: Platform = {
|
||||||
platform: "web",
|
platform: "web",
|
||||||
draftStore: createBrowserDraftStore(),
|
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
openExternal,
|
openLink,
|
||||||
|
back,
|
||||||
|
forward,
|
||||||
restart,
|
restart,
|
||||||
notify,
|
notify,
|
||||||
getDefaultServer: async () => {
|
getDefaultServer: async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import { selectProviderCatalog } from "./provider-catalog"
|
import { resolveDefaultModel, selectProviderCatalog } from "./provider-catalog"
|
||||||
|
|
||||||
const catalog = (id: string): NormalizedProviderListResponse => ({
|
const catalog = (id: string): NormalizedProviderListResponse => ({
|
||||||
all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
|
all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
|
||||||
@@ -57,3 +57,21 @@ test("falls back to the global catalog for route consumers", () => {
|
|||||||
}),
|
}),
|
||||||
).toBe(global)
|
).toBe(global)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("uses the current server default model", () => {
|
||||||
|
expect(resolveDefaultModel({ providerID: "openai", modelID: "gpt-5" }, "anthropic/claude")).toEqual({
|
||||||
|
providerID: "openai",
|
||||||
|
modelID: "gpt-5",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not use legacy config when the current server has no default", () => {
|
||||||
|
expect(resolveDefaultModel(null, "anthropic/claude")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses config for legacy servers", () => {
|
||||||
|
expect(resolveDefaultModel(undefined, "anthropic/claude")).toEqual({
|
||||||
|
providerID: "anthropic",
|
||||||
|
modelID: "claude",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -25,3 +25,13 @@ export function selectProviderCatalog(input: ProviderCatalogInput) {
|
|||||||
if (input.explicit) return emptyProviderCatalog
|
if (input.explicit) return emptyProviderCatalog
|
||||||
return input.global
|
return input.global
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveDefaultModel(
|
||||||
|
current: NormalizedProviderListResponse["defaultModel"],
|
||||||
|
legacy: string | undefined,
|
||||||
|
) {
|
||||||
|
if (current !== undefined) return current ?? undefined
|
||||||
|
if (!legacy) return undefined
|
||||||
|
const [providerID, modelID] = legacy.split("/")
|
||||||
|
return { providerID, modelID }
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function useProviders(directory: Accessor<string | undefined>) {
|
|||||||
return {
|
return {
|
||||||
all: () => providers().all,
|
all: () => providers().all,
|
||||||
default: () => providers().default,
|
default: () => providers().default,
|
||||||
|
defaultModel: () => providers().defaultModel,
|
||||||
popular: () =>
|
popular: () =>
|
||||||
pipe(
|
pipe(
|
||||||
providers().all,
|
providers().all,
|
||||||
|
|||||||
@@ -277,7 +277,6 @@ export const dict = {
|
|||||||
"prompt.action.send": "إرسال",
|
"prompt.action.send": "إرسال",
|
||||||
"prompt.action.stop": "توقف",
|
"prompt.action.stop": "توقف",
|
||||||
"prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم",
|
"prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم",
|
||||||
"prompt.toast.attachmentDuplicate.title": "تم تحميل هذا الملف بالفعل",
|
|
||||||
"prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.",
|
"prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.",
|
||||||
"prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً",
|
"prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً",
|
||||||
"prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.",
|
"prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.",
|
||||||
|
|||||||
@@ -277,7 +277,6 @@ export const dict = {
|
|||||||
"prompt.action.send": "Enviar",
|
"prompt.action.send": "Enviar",
|
||||||
"prompt.action.stop": "Parar",
|
"prompt.action.stop": "Parar",
|
||||||
"prompt.toast.pasteUnsupported.title": "Anexo não suportado",
|
"prompt.toast.pasteUnsupported.title": "Anexo não suportado",
|
||||||
"prompt.toast.attachmentDuplicate.title": "Este arquivo já foi enviado",
|
|
||||||
"prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.",
|
"prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.",
|
||||||
"prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo",
|
"prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo",
|
||||||
"prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.",
|
"prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.",
|
||||||
|
|||||||
@@ -298,7 +298,6 @@ export const dict = {
|
|||||||
"prompt.action.stop": "Zaustavi",
|
"prompt.action.stop": "Zaustavi",
|
||||||
|
|
||||||
"prompt.toast.pasteUnsupported.title": "Nepodržan prilog",
|
"prompt.toast.pasteUnsupported.title": "Nepodržan prilog",
|
||||||
"prompt.toast.attachmentDuplicate.title": "Ova datoteka je već učitana",
|
|
||||||
"prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.",
|
"prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.",
|
||||||
"prompt.toast.modelAgentRequired.title": "Odaberi agenta i model",
|
"prompt.toast.modelAgentRequired.title": "Odaberi agenta i model",
|
||||||
"prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.",
|
"prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.",
|
||||||
|
|||||||
@@ -296,7 +296,6 @@ export const dict = {
|
|||||||
"prompt.action.stop": "Stop",
|
"prompt.action.stop": "Stop",
|
||||||
|
|
||||||
"prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning",
|
"prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning",
|
||||||
"prompt.toast.attachmentDuplicate.title": "Denne fil er allerede uploadet",
|
|
||||||
"prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.",
|
"prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.",
|
||||||
"prompt.toast.modelAgentRequired.title": "Vælg en agent og model",
|
"prompt.toast.modelAgentRequired.title": "Vælg en agent og model",
|
||||||
"prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.",
|
"prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.",
|
||||||
|
|||||||
@@ -282,7 +282,6 @@ export const dict = {
|
|||||||
"prompt.action.send": "Senden",
|
"prompt.action.send": "Senden",
|
||||||
"prompt.action.stop": "Stopp",
|
"prompt.action.stop": "Stopp",
|
||||||
"prompt.toast.pasteUnsupported.title": "Nicht unterstützter Anhang",
|
"prompt.toast.pasteUnsupported.title": "Nicht unterstützter Anhang",
|
||||||
"prompt.toast.attachmentDuplicate.title": "Diese Datei wurde bereits hochgeladen",
|
|
||||||
"prompt.toast.pasteUnsupported.description": "Hier können nur Bilder, PDFs oder Textdateien angehängt werden.",
|
"prompt.toast.pasteUnsupported.description": "Hier können nur Bilder, PDFs oder Textdateien angehängt werden.",
|
||||||
"prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell",
|
"prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell",
|
||||||
"prompt.toast.modelAgentRequired.description":
|
"prompt.toast.modelAgentRequired.description":
|
||||||
|
|||||||
@@ -301,7 +301,6 @@ export const dict = {
|
|||||||
|
|
||||||
"prompt.toast.pasteUnsupported.title": "Unsupported attachment",
|
"prompt.toast.pasteUnsupported.title": "Unsupported attachment",
|
||||||
"prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.",
|
"prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.",
|
||||||
"prompt.toast.attachmentDuplicate.title": "This file has already been uploaded",
|
|
||||||
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
|
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
|
||||||
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
|
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
|
||||||
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
|
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
|
||||||
|
|||||||
@@ -297,7 +297,6 @@ export const dict = {
|
|||||||
"prompt.action.stop": "Detener",
|
"prompt.action.stop": "Detener",
|
||||||
|
|
||||||
"prompt.toast.pasteUnsupported.title": "Adjunto no compatible",
|
"prompt.toast.pasteUnsupported.title": "Adjunto no compatible",
|
||||||
"prompt.toast.attachmentDuplicate.title": "Este archivo ya se ha subido",
|
|
||||||
"prompt.toast.pasteUnsupported.description": "Solo se pueden adjuntar imágenes, PDFs o archivos de texto aquí.",
|
"prompt.toast.pasteUnsupported.description": "Solo se pueden adjuntar imágenes, PDFs o archivos de texto aquí.",
|
||||||
"prompt.toast.modelAgentRequired.title": "Selecciona un agente y modelo",
|
"prompt.toast.modelAgentRequired.title": "Selecciona un agente y modelo",
|
||||||
"prompt.toast.modelAgentRequired.description": "Elige un agente y modelo antes de enviar un prompt.",
|
"prompt.toast.modelAgentRequired.description": "Elige un agente y modelo antes de enviar un prompt.",
|
||||||
|
|||||||
@@ -277,7 +277,6 @@ export const dict = {
|
|||||||
"prompt.action.send": "Envoyer",
|
"prompt.action.send": "Envoyer",
|
||||||
"prompt.action.stop": "Arrêter",
|
"prompt.action.stop": "Arrêter",
|
||||||
"prompt.toast.pasteUnsupported.title": "Pièce jointe non prise en charge",
|
"prompt.toast.pasteUnsupported.title": "Pièce jointe non prise en charge",
|
||||||
"prompt.toast.attachmentDuplicate.title": "Ce fichier a déjà été téléversé",
|
|
||||||
"prompt.toast.pasteUnsupported.description":
|
"prompt.toast.pasteUnsupported.description":
|
||||||
"Seules les images, les PDF ou les fichiers texte peuvent être joints ici.",
|
"Seules les images, les PDF ou les fichiers texte peuvent être joints ici.",
|
||||||
"prompt.toast.modelAgentRequired.title": "Sélectionnez un agent et un modèle",
|
"prompt.toast.modelAgentRequired.title": "Sélectionnez un agent et un modèle",
|
||||||
|
|||||||
@@ -276,7 +276,6 @@ export const dict = {
|
|||||||
"prompt.action.send": "送信",
|
"prompt.action.send": "送信",
|
||||||
"prompt.action.stop": "停止",
|
"prompt.action.stop": "停止",
|
||||||
"prompt.toast.pasteUnsupported.title": "サポートされていない添付ファイル",
|
"prompt.toast.pasteUnsupported.title": "サポートされていない添付ファイル",
|
||||||
"prompt.toast.attachmentDuplicate.title": "このファイルはすでにアップロードされています",
|
|
||||||
"prompt.toast.pasteUnsupported.description": "画像、PDF、またはテキストファイルのみ添付できます。",
|
"prompt.toast.pasteUnsupported.description": "画像、PDF、またはテキストファイルのみ添付できます。",
|
||||||
"prompt.toast.modelAgentRequired.title": "エージェントとモデルを選択",
|
"prompt.toast.modelAgentRequired.title": "エージェントとモデルを選択",
|
||||||
"prompt.toast.modelAgentRequired.description": "プロンプトを送信する前にエージェントとモデルを選択してください。",
|
"prompt.toast.modelAgentRequired.description": "プロンプトを送信する前にエージェントとモデルを選択してください。",
|
||||||
|
|||||||
@@ -264,7 +264,6 @@ export const dict = {
|
|||||||
"prompt.action.send": "전송",
|
"prompt.action.send": "전송",
|
||||||
"prompt.action.stop": "중지",
|
"prompt.action.stop": "중지",
|
||||||
"prompt.toast.pasteUnsupported.title": "지원되지 않는 첨부 파일",
|
"prompt.toast.pasteUnsupported.title": "지원되지 않는 첨부 파일",
|
||||||
"prompt.toast.attachmentDuplicate.title": "이 파일은 이미 업로드되었습니다",
|
|
||||||
"prompt.toast.pasteUnsupported.description": "이미지, PDF 또는 텍스트 파일만 첨부할 수 있습니다.",
|
"prompt.toast.pasteUnsupported.description": "이미지, PDF 또는 텍스트 파일만 첨부할 수 있습니다.",
|
||||||
"prompt.toast.modelAgentRequired.title": "에이전트 및 모델 선택",
|
"prompt.toast.modelAgentRequired.title": "에이전트 및 모델 선택",
|
||||||
"prompt.toast.modelAgentRequired.description": "프롬프트를 보내기 전에 에이전트와 모델을 선택하세요.",
|
"prompt.toast.modelAgentRequired.description": "프롬프트를 보내기 전에 에이전트와 모델을 선택하세요.",
|
||||||
|
|||||||
@@ -288,7 +288,6 @@ export const dict = {
|
|||||||
"prompt.action.stop": "Stopp",
|
"prompt.action.stop": "Stopp",
|
||||||
|
|
||||||
"prompt.toast.pasteUnsupported.title": "Ikke støttet vedlegg",
|
"prompt.toast.pasteUnsupported.title": "Ikke støttet vedlegg",
|
||||||
"prompt.toast.attachmentDuplicate.title": "Denne filen er allerede lastet opp",
|
|
||||||
"prompt.toast.pasteUnsupported.description": "Kun bilder, PDF-er eller tekstfiler kan legges ved her.",
|
"prompt.toast.pasteUnsupported.description": "Kun bilder, PDF-er eller tekstfiler kan legges ved her.",
|
||||||
"prompt.toast.modelAgentRequired.title": "Velg en agent og modell",
|
"prompt.toast.modelAgentRequired.title": "Velg en agent og modell",
|
||||||
"prompt.toast.modelAgentRequired.description": "Velg en agent og modell før du sender en forespørsel.",
|
"prompt.toast.modelAgentRequired.description": "Velg en agent og modell før du sender en forespørsel.",
|
||||||
|
|||||||
@@ -278,7 +278,6 @@ export const dict = {
|
|||||||
"prompt.action.send": "Wyślij",
|
"prompt.action.send": "Wyślij",
|
||||||
"prompt.action.stop": "Zatrzymaj",
|
"prompt.action.stop": "Zatrzymaj",
|
||||||
"prompt.toast.pasteUnsupported.title": "Nieobsługiwany załącznik",
|
"prompt.toast.pasteUnsupported.title": "Nieobsługiwany załącznik",
|
||||||
"prompt.toast.attachmentDuplicate.title": "Ten plik został już przesłany",
|
|
||||||
"prompt.toast.pasteUnsupported.description": "Można tutaj załączać tylko obrazy, pliki PDF lub pliki tekstowe.",
|
"prompt.toast.pasteUnsupported.description": "Można tutaj załączać tylko obrazy, pliki PDF lub pliki tekstowe.",
|
||||||
"prompt.toast.modelAgentRequired.title": "Wybierz agenta i model",
|
"prompt.toast.modelAgentRequired.title": "Wybierz agenta i model",
|
||||||
"prompt.toast.modelAgentRequired.description": "Wybierz agenta i model przed wysłaniem zapytania.",
|
"prompt.toast.modelAgentRequired.description": "Wybierz agenta i model przed wysłaniem zapytania.",
|
||||||
|
|||||||
@@ -297,7 +297,6 @@ export const dict = {
|
|||||||
"prompt.action.stop": "Остановить",
|
"prompt.action.stop": "Остановить",
|
||||||
|
|
||||||
"prompt.toast.pasteUnsupported.title": "Неподдерживаемое вложение",
|
"prompt.toast.pasteUnsupported.title": "Неподдерживаемое вложение",
|
||||||
"prompt.toast.attachmentDuplicate.title": "Этот файл уже загружен",
|
|
||||||
"prompt.toast.pasteUnsupported.description": "Здесь можно прикрепить только изображения, PDF или текстовые файлы.",
|
"prompt.toast.pasteUnsupported.description": "Здесь можно прикрепить только изображения, PDF или текстовые файлы.",
|
||||||
"prompt.toast.modelAgentRequired.title": "Выберите агента и модель",
|
"prompt.toast.modelAgentRequired.title": "Выберите агента и модель",
|
||||||
"prompt.toast.modelAgentRequired.description": "Выберите агента и модель перед отправкой запроса.",
|
"prompt.toast.modelAgentRequired.description": "Выберите агента и модель перед отправкой запроса.",
|
||||||
|
|||||||
@@ -297,7 +297,6 @@ export const dict = {
|
|||||||
"prompt.action.stop": "หยุด",
|
"prompt.action.stop": "หยุด",
|
||||||
|
|
||||||
"prompt.toast.pasteUnsupported.title": "ไฟล์แนบที่ไม่รองรับ",
|
"prompt.toast.pasteUnsupported.title": "ไฟล์แนบที่ไม่รองรับ",
|
||||||
"prompt.toast.attachmentDuplicate.title": "ไฟล์นี้ถูกอัปโหลดแล้ว",
|
|
||||||
"prompt.toast.pasteUnsupported.description": "แนบได้เฉพาะรูปภาพ, PDF หรือไฟล์ข้อความเท่านั้น",
|
"prompt.toast.pasteUnsupported.description": "แนบได้เฉพาะรูปภาพ, PDF หรือไฟล์ข้อความเท่านั้น",
|
||||||
"prompt.toast.modelAgentRequired.title": "เลือกเอเจนต์และโมเดล",
|
"prompt.toast.modelAgentRequired.title": "เลือกเอเจนต์และโมเดล",
|
||||||
"prompt.toast.modelAgentRequired.description": "เลือกเอเจนต์และโมเดลก่อนส่งพร้อมท์",
|
"prompt.toast.modelAgentRequired.description": "เลือกเอเจนต์และโมเดลก่อนส่งพร้อมท์",
|
||||||
|
|||||||
@@ -302,7 +302,6 @@ export const dict = {
|
|||||||
"prompt.action.stop": "Durdur",
|
"prompt.action.stop": "Durdur",
|
||||||
|
|
||||||
"prompt.toast.pasteUnsupported.title": "Desteklenmeyen ek",
|
"prompt.toast.pasteUnsupported.title": "Desteklenmeyen ek",
|
||||||
"prompt.toast.attachmentDuplicate.title": "Bu dosya zaten yüklendi",
|
|
||||||
"prompt.toast.pasteUnsupported.description": "Buraya yalnızca resimler, PDF'ler veya metin dosyaları eklenebilir.",
|
"prompt.toast.pasteUnsupported.description": "Buraya yalnızca resimler, PDF'ler veya metin dosyaları eklenebilir.",
|
||||||
"prompt.toast.modelAgentRequired.title": "Bir ajan ve model seçin",
|
"prompt.toast.modelAgentRequired.title": "Bir ajan ve model seçin",
|
||||||
"prompt.toast.modelAgentRequired.description": "Komut göndermeden önce bir ajan ve model seçin.",
|
"prompt.toast.modelAgentRequired.description": "Komut göndermeden önce bir ajan ve model seçin.",
|
||||||
|
|||||||
@@ -299,7 +299,6 @@ export const dict = {
|
|||||||
"prompt.action.stop": "Зупинити",
|
"prompt.action.stop": "Зупинити",
|
||||||
|
|
||||||
"prompt.toast.pasteUnsupported.title": "Непідтримуване вкладення",
|
"prompt.toast.pasteUnsupported.title": "Непідтримуване вкладення",
|
||||||
"prompt.toast.attachmentDuplicate.title": "Цей файл уже завантажено",
|
|
||||||
"prompt.toast.pasteUnsupported.description": "Сюди можна прикріплювати лише зображення, PDF або текстові файли.",
|
"prompt.toast.pasteUnsupported.description": "Сюди можна прикріплювати лише зображення, PDF або текстові файли.",
|
||||||
"prompt.toast.modelAgentRequired.title": "Виберіть агента та модель",
|
"prompt.toast.modelAgentRequired.title": "Виберіть агента та модель",
|
||||||
"prompt.toast.modelAgentRequired.description": "Виберіть агента та модель перед надсиланням запиту.",
|
"prompt.toast.modelAgentRequired.description": "Виберіть агента та модель перед надсиланням запиту.",
|
||||||
|
|||||||
@@ -316,7 +316,6 @@ export const dict = {
|
|||||||
"prompt.action.send": "发送",
|
"prompt.action.send": "发送",
|
||||||
"prompt.action.stop": "停止",
|
"prompt.action.stop": "停止",
|
||||||
"prompt.toast.pasteUnsupported.title": "不支持的附件",
|
"prompt.toast.pasteUnsupported.title": "不支持的附件",
|
||||||
"prompt.toast.attachmentDuplicate.title": "此文件已上传",
|
|
||||||
"prompt.toast.pasteUnsupported.description": "此处仅能附加图片、PDF 或文本文件。",
|
"prompt.toast.pasteUnsupported.description": "此处仅能附加图片、PDF 或文本文件。",
|
||||||
"prompt.toast.modelAgentRequired.title": "请选择智能体和模型",
|
"prompt.toast.modelAgentRequired.title": "请选择智能体和模型",
|
||||||
"prompt.toast.modelAgentRequired.description": "发送提示前请先选择智能体和模型。",
|
"prompt.toast.modelAgentRequired.description": "发送提示前请先选择智能体和模型。",
|
||||||
|
|||||||
@@ -297,7 +297,6 @@ export const dict = {
|
|||||||
"prompt.action.stop": "停止",
|
"prompt.action.stop": "停止",
|
||||||
|
|
||||||
"prompt.toast.pasteUnsupported.title": "不支援的附件",
|
"prompt.toast.pasteUnsupported.title": "不支援的附件",
|
||||||
"prompt.toast.attachmentDuplicate.title": "此檔案已上傳",
|
|
||||||
"prompt.toast.pasteUnsupported.description": "此處僅能附加圖片、PDF 或文字檔案。",
|
"prompt.toast.pasteUnsupported.description": "此處僅能附加圖片、PDF 或文字檔案。",
|
||||||
"prompt.toast.modelAgentRequired.title": "請選擇代理程式和模型",
|
"prompt.toast.modelAgentRequired.title": "請選擇代理程式和模型",
|
||||||
"prompt.toast.modelAgentRequired.description": "傳送提示前請先選擇代理程式和模型。",
|
"prompt.toast.modelAgentRequired.description": "傳送提示前請先選擇代理程式和模型。",
|
||||||
|
|||||||
@@ -27,4 +27,4 @@ export {
|
|||||||
type WslServersState,
|
type WslServersState,
|
||||||
} from "./wsl/types"
|
} from "./wsl/types"
|
||||||
export { ServerConnection } from "./context/server"
|
export { ServerConnection } from "./context/server"
|
||||||
export { createDraftStore, type DraftStore } from "./utils/draft-store"
|
export { handleNotificationClick } from "./utils/notification-click"
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ export function DirectoryDataProvider(
|
|||||||
<DataProvider
|
<DataProvider
|
||||||
data={sync().data}
|
data={sync().data}
|
||||||
directory={directory}
|
directory={directory}
|
||||||
sessionID={params.id}
|
|
||||||
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
|
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
|
||||||
onSessionHref={href}
|
onSessionHref={href}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex items-center text-text-interactive-base gap-1"
|
class="flex items-center text-text-interactive-base gap-1"
|
||||||
onClick={() => platform.openExternal("https://opencode.ai/desktop-feedback")}
|
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||||
>
|
>
|
||||||
<div>{language.t("error.page.report.discord")}</div>
|
<div>{language.t("error.page.report.discord")}</div>
|
||||||
<Icon name="discord" class="text-text-interactive-base" />
|
<Icon name="discord" class="text-text-interactive-base" />
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function createHomeProjectsController(home: HomeController) {
|
|||||||
},
|
},
|
||||||
utility: {
|
utility: {
|
||||||
settings: openSettings,
|
settings: openSettings,
|
||||||
help: () => platform.openExternal("https://opencode.ai/desktop-feedback"),
|
help: () => platform.openLink("https://opencode.ai/desktop-feedback"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,26 +360,25 @@ function HomeProjectSlot(
|
|||||||
index: () => number
|
index: () => number
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const initial = props.items.find((item) => item.worktree === props.worktree)
|
const project = createMemo(() => props.items.find((item) => item.worktree === props.worktree))
|
||||||
if (!initial) return
|
|
||||||
const project = createMemo<LocalProject>(
|
|
||||||
(previous) => props.items.find((item) => item.worktree === props.worktree) ?? previous,
|
|
||||||
initial,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HomeProjectRow
|
<Show when={project()}>
|
||||||
{...props}
|
{(item) => (
|
||||||
project={project()}
|
<HomeProjectRow
|
||||||
server={props.server}
|
{...props}
|
||||||
index={props.index}
|
project={item()}
|
||||||
serverSelected={props.selection().server === ServerConnection.key(props.server)}
|
server={props.server}
|
||||||
selected={
|
index={props.index}
|
||||||
props.selection().server === ServerConnection.key(props.server) &&
|
serverSelected={props.selection().server === ServerConnection.key(props.server)}
|
||||||
props.selection().directory === props.worktree
|
selected={
|
||||||
}
|
props.selection().server === ServerConnection.key(props.server) &&
|
||||||
unseen={props.unseenCount(props.server, project())}
|
props.selection().directory === props.worktree
|
||||||
/>
|
}
|
||||||
|
unseen={props.unseenCount(props.server, item())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,6 +481,7 @@ function HomeProjectRow(
|
|||||||
class="pr-16 disabled:opacity-60"
|
class="pr-16 disabled:opacity-60"
|
||||||
classList={{
|
classList={{
|
||||||
"bg-v2-background-bg-layer-01 text-v2-text-text-base": sortable.isDragSource(),
|
"bg-v2-background-bg-layer-01 text-v2-text-text-base": sortable.isDragSource(),
|
||||||
|
"[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]": sortable.isDragSource(),
|
||||||
}}
|
}}
|
||||||
data-selected={props.selected ? "" : undefined}
|
data-selected={props.selected ? "" : undefined}
|
||||||
aria-current={props.selected ? "page" : undefined}
|
aria-current={props.selected ? "page" : undefined}
|
||||||
@@ -589,9 +589,9 @@ function HomeProjectNavButton(props: JSX.ButtonHTMLAttributes<HTMLButtonElement>
|
|||||||
class={`
|
class={`
|
||||||
flex h-7 min-w-0 w-full shrink-0 cursor-default items-center gap-2 rounded-[6px] bg-transparent px-1.5 text-left
|
flex h-7 min-w-0 w-full shrink-0 cursor-default items-center gap-2 rounded-[6px] bg-transparent px-1.5 text-left
|
||||||
text-v2-text-text-muted [font-weight:440] transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
|
text-v2-text-text-muted [font-weight:440] transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
|
||||||
hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base
|
hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]
|
||||||
data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base
|
data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base
|
||||||
data-[selected]:hover:bg-v2-background-bg-layer-03
|
data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-03
|
||||||
focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:outline-none
|
focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:outline-none
|
||||||
focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]
|
focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]
|
||||||
${local.class ?? ""}
|
${local.class ?? ""}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { type Accessor, createMemo, For, Show, Suspense } from "solid-js"
|
import { type Accessor, createMemo, For, Show } from "solid-js"
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
@@ -39,6 +39,7 @@ function isBackgroundOpen(event: MouseEvent) {
|
|||||||
export type HomeSessionsViewProps = {
|
export type HomeSessionsViewProps = {
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
groups: Accessor<HomeSessionGroup[]>
|
groups: Accessor<HomeSessionGroup[]>
|
||||||
|
loading: Accessor<boolean>
|
||||||
showProjectName: Accessor<boolean>
|
showProjectName: Accessor<boolean>
|
||||||
server: Accessor<ServerConnection.Key>
|
server: Accessor<ServerConnection.Key>
|
||||||
canCreateSession: Accessor<boolean>
|
canCreateSession: Accessor<boolean>
|
||||||
@@ -80,22 +81,20 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
|||||||
>
|
>
|
||||||
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
|
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
|
||||||
<HomeSessionSearch {...props} />
|
<HomeSessionSearch {...props} />
|
||||||
<Suspense>
|
<Show when={props.groups().length > 0 && props.canCreateSession()}>
|
||||||
<Show when={props.groups().length > 0 && props.canCreateSession()}>
|
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
||||||
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
<ButtonV2
|
||||||
<ButtonV2
|
data-action="home-new-session"
|
||||||
data-action="home-new-session"
|
variant="ghost-muted"
|
||||||
variant="ghost-muted"
|
size="normal"
|
||||||
size="normal"
|
icon="edit"
|
||||||
icon="edit"
|
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
||||||
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
onClick={props.onCreateSession}
|
||||||
onClick={props.onCreateSession}
|
>
|
||||||
>
|
{props.language.t("command.session.new")}
|
||||||
{props.language.t("command.session.new")}
|
</ButtonV2>
|
||||||
</ButtonV2>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
</Show>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
|
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
|
||||||
<div
|
<div
|
||||||
@@ -105,7 +104,8 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
|
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
|
||||||
<Suspense
|
<Show
|
||||||
|
when={!props.loading()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="pt-3">
|
<div class="pt-3">
|
||||||
<HomeSessionSkeleton label={props.language.t("common.loading")} />
|
<HomeSessionSkeleton label={props.language.t("common.loading")} />
|
||||||
@@ -141,7 +141,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
|||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Suspense>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function HomeSessions(props: {
|
|||||||
<HomeSessionsView
|
<HomeSessionsView
|
||||||
language={props.sessions.copy.language}
|
language={props.sessions.copy.language}
|
||||||
groups={props.sessions.data.groups}
|
groups={props.sessions.data.groups}
|
||||||
|
loading={props.sessions.data.loading}
|
||||||
showProjectName={props.sessions.session.showProjectName}
|
showProjectName={props.sessions.session.showProjectName}
|
||||||
server={props.sessions.session.server}
|
server={props.sessions.session.server}
|
||||||
canCreateSession={props.sessions.session.canCreate}
|
canCreateSession={props.sessions.session.canCreate}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { DebugBar } from "@/components/debug-bar"
|
import { DebugBar } from "@/components/debug-bar"
|
||||||
import { TabsInfoPopup } from "@/components/help-button"
|
import { TabsInfoPopup } from "@/components/help-button"
|
||||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { setNavigate } from "@/utils/notification-click"
|
||||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||||
|
|
||||||
export default function NewLayout(props: ParentProps) {
|
export default function NewLayout(props: ParentProps) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
setNavigate(navigate)
|
||||||
const [state, setState] = createStore({ debugTools: true })
|
const [state, setState] = createStore({ debugTools: true })
|
||||||
|
|
||||||
createEffect(() => setV2Toast(true))
|
createEffect(() => setV2Toast(true))
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
|||||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { dismissToast, setV2Toast, showToast, ToastRegion } from "@/utils/toast"
|
import { toaster } from "@opencode-ai/ui/toast"
|
||||||
|
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
||||||
import { clearWorkspaceTerminals } from "@/context/terminal"
|
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||||
@@ -44,6 +45,7 @@ import { Binary } from "@opencode-ai/core/util/binary"
|
|||||||
import { retry } from "@opencode-ai/core/util/retry"
|
import { retry } from "@opencode-ai/core/util/retry"
|
||||||
import { playSoundById } from "@/utils/sound"
|
import { playSoundById } from "@/utils/sound"
|
||||||
import { createAim } from "@/utils/aim"
|
import { createAim } from "@/utils/aim"
|
||||||
|
import { setNavigate } from "@/utils/notification-click"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||||
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
|
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
|
||||||
@@ -116,6 +118,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
setNavigate(navigate)
|
||||||
const providers = useProviders(() => undefined)
|
const providers = useProviders(() => undefined)
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
@@ -381,7 +384,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
const dismissSessionAlert = (sessionKey: string) => {
|
const dismissSessionAlert = (sessionKey: string) => {
|
||||||
const toastId = toastBySession.get(sessionKey)
|
const toastId = toastBySession.get(sessionKey)
|
||||||
if (toastId === undefined) return
|
if (toastId === undefined) return
|
||||||
dismissToast(toastId)
|
toaster.dismiss(toastId)
|
||||||
toastBySession.delete(sessionKey)
|
toastBySession.delete(sessionKey)
|
||||||
alertedAtBySession.delete(sessionKey)
|
alertedAtBySession.delete(sessionKey)
|
||||||
}
|
}
|
||||||
@@ -446,13 +449,13 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
void playSoundById(settings.sounds.permissions())
|
void playSoundById(settings.sounds.permissions())
|
||||||
}
|
}
|
||||||
if (settings.notifications.permissions()) {
|
if (settings.notifications.permissions()) {
|
||||||
void platform.notify(title, description, () => navigate(href))
|
void platform.notify(title, description, href)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.details.type === "question.asked") {
|
if (e.details.type === "question.asked") {
|
||||||
if (settings.notifications.agent()) {
|
if (settings.notifications.agent()) {
|
||||||
void platform.notify(title, description, () => navigate(href))
|
void platform.notify(title, description, href)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1456,7 +1459,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
title: language.t("workspace.resetting.title"),
|
title: language.t("workspace.resetting.title"),
|
||||||
description: language.t("workspace.resetting.description"),
|
description: language.t("workspace.resetting.description"),
|
||||||
})
|
})
|
||||||
const dismiss = () => dismissToast(progress)
|
const dismiss = () => toaster.dismiss(progress)
|
||||||
|
|
||||||
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
|
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
|
||||||
|
|
||||||
@@ -2249,7 +2252,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
settingsKeybind={() => command.keybind("settings.open")}
|
settingsKeybind={() => command.keybind("settings.open")}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
helpLabel={() => language.t("sidebar.help")}
|
helpLabel={() => language.t("sidebar.help")}
|
||||||
onOpenHelp={() => platform.openExternal("https://opencode.ai/desktop-feedback")}
|
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||||
renderPanel={() =>
|
renderPanel={() =>
|
||||||
mobile ? <SidebarPanel project={currentProject} mobile /> : <SidebarPanel project={currentProject} merged />
|
mobile ? <SidebarPanel project={currentProject} mobile /> : <SidebarPanel project={currentProject} merged />
|
||||||
}
|
}
|
||||||
@@ -2447,7 +2450,7 @@ function UpdateAvailableToast(props: {
|
|||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (toastId === undefined) return
|
if (toastId === undefined) return
|
||||||
dismissToast(toastId)
|
toaster.dismiss(toastId)
|
||||||
})
|
})
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -1,49 +1,282 @@
|
|||||||
import { createPromptProjectController } from "@/components/prompt-project-selector"
|
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, untrack } from "solid-js"
|
||||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { Portal } from "solid-js/web"
|
||||||
|
import { useSearchParams } from "@solidjs/router"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
|
import { NewSessionDesignView } from "@/components/session"
|
||||||
|
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
||||||
|
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||||
|
import {
|
||||||
|
PromptProjectAddButton,
|
||||||
|
PromptProjectSelector,
|
||||||
|
createPromptProjectController,
|
||||||
|
} from "@/components/prompt-project-selector"
|
||||||
|
import { useComments } from "@/context/comments"
|
||||||
|
import { usePrompt } from "@/context/prompt"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { createEffect, createResource } from "solid-js"
|
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
|
||||||
import { createNewSessionDraftController } from "./new-session/new-session-draft-controller"
|
import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view"
|
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||||
import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller"
|
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||||
import { useNewSessionCommands } from "./new-session/use-new-session-commands"
|
import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||||
|
import { useTitlebarRightMount } from "@/components/titlebar"
|
||||||
|
import { useCommand } from "@/context/command"
|
||||||
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
|
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||||
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import createPresence from "solid-presence"
|
||||||
|
import { useLocal } from "@/context/local"
|
||||||
|
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
|
||||||
|
|
||||||
/** The draft-only V2 session page. Submitting promotes the draft into a real session. */
|
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||||
|
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
||||||
|
const providerTipExitDuration = 250
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
|
||||||
|
* composer for a brand-new session — no terminal, review pane, file tree, or message
|
||||||
|
* timeline. Submitting promotes the draft into a real session (see prompt-input/submit).
|
||||||
|
*/
|
||||||
export default function NewSessionPage() {
|
export default function NewSessionPage() {
|
||||||
|
const prompt = usePrompt()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const sync = useSync()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const comments = useComments()
|
||||||
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const command = useCommand()
|
||||||
|
const providers = useProviders(() => sdk().directory)
|
||||||
|
const openProviders = () => {
|
||||||
|
void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
|
||||||
|
void dialog.show(() => <DialogConnectProvider directory={() => sdk().directory} />)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
useSettingsCommand()
|
||||||
|
const route = useSessionKey()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||||
|
const local = useLocal()
|
||||||
|
const model = createPromptModelSelection({ agent: local.agent.current })
|
||||||
|
|
||||||
|
useComposerCommands({ model })
|
||||||
|
|
||||||
|
const inputController = createPromptInputController({
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
sessionID: () => route.params.id,
|
||||||
|
queryOptions: serverSync().queryOptions,
|
||||||
|
model,
|
||||||
|
})
|
||||||
|
const projectControls = createPromptProjectControls()
|
||||||
|
|
||||||
|
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||||
const rightMount = useTitlebarRightMount()
|
const rightMount = useTitlebarRightMount()
|
||||||
const workspace = createNewSessionWorkspaceController()
|
|
||||||
const draft = createNewSessionDraftController({
|
const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
|
||||||
worktree: workspace.selection.value,
|
const newSessionWorktree = createMemo(() => {
|
||||||
resetWorktree: workspace.selection.reset,
|
if (!showWorkspaceBar()) return "main"
|
||||||
|
if (store.worktree) return store.worktree
|
||||||
|
const project = sync().project
|
||||||
|
if (project && sdk().directory !== project.worktree) return sdk().directory
|
||||||
|
return "main"
|
||||||
})
|
})
|
||||||
const project = createPromptProjectController({
|
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||||
controls: draft.project.controls,
|
const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
|
||||||
onDone: draft.input.restoreFocus,
|
const selectedBranch = createMemo(() => {
|
||||||
|
const worktree = newSessionWorktree()
|
||||||
|
if (worktree === "main" || worktree === "create") return localBranch()
|
||||||
|
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
|
||||||
})
|
})
|
||||||
useNewSessionCommands({
|
const promptInputV2Controller = usePromptInputV2Controller({
|
||||||
restoreFocus: draft.input.restoreFocus,
|
get controls() {
|
||||||
project: {
|
return inputController()
|
||||||
empty: project.empty,
|
|
||||||
open: () => project.setOpen(true),
|
|
||||||
},
|
},
|
||||||
|
get newSessionWorktree() {
|
||||||
|
return newSessionWorktree()
|
||||||
|
},
|
||||||
|
onNewSessionWorktreeReset: () => setStore("worktree", undefined),
|
||||||
|
onSubmit: () => comments.clear(),
|
||||||
})
|
})
|
||||||
|
const projectController = createPromptProjectController({
|
||||||
|
controls: projectControls,
|
||||||
|
onDone: promptInputV2Controller.restoreFocus,
|
||||||
|
})
|
||||||
|
|
||||||
|
command.register("new-session", () => [
|
||||||
|
{
|
||||||
|
id: "command.palette",
|
||||||
|
title: language.t("command.palette"),
|
||||||
|
hidden: true,
|
||||||
|
onSelect: async () => {
|
||||||
|
const { DialogSelectFile } = await import("@/components/dialog-select-file")
|
||||||
|
void dialog.show(() => <DialogSelectFile />)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "input.focus",
|
||||||
|
title: language.t("command.input.focus"),
|
||||||
|
category: language.t("command.category.view"),
|
||||||
|
keybind: "ctrl+l",
|
||||||
|
onSelect: () => promptInputV2Controller.restoreFocus(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!draft.prompt.ready()) return
|
if (!prompt.ready()) return
|
||||||
draft.input.restoreFocus()
|
untrack(() => {
|
||||||
|
const text = searchParams.prompt
|
||||||
|
if (!text) return
|
||||||
|
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||||
|
setSearchParams({ ...searchParams, prompt: undefined })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!prompt.ready()) return
|
||||||
|
promptInputV2Controller.restoreFocus()
|
||||||
|
})
|
||||||
|
|
||||||
const ready = Promise.resolve()
|
const ready = Promise.resolve()
|
||||||
const [suspendUntilPromptReady] = createResource(
|
const [suspendUntilPromptReady] = createResource(
|
||||||
() => draft.prompt.readyPromise() ?? ready,
|
() => prompt.ready.promise ?? ready,
|
||||||
(promise) => promise.then(() => true),
|
(promise) => promise.then(() => true),
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative size-full overflow-hidden flex flex-col">
|
<div class="relative size-full overflow-hidden flex flex-col">
|
||||||
{suspendUntilPromptReady()}
|
{suspendUntilPromptReady()}
|
||||||
<NewSessionStatus mount={rightMount} visible={settings.visibility.status} />
|
<Show when={rightMount()}>
|
||||||
|
{(mount) => (
|
||||||
|
<Portal mount={mount()}>
|
||||||
|
<Show when={settings.visibility.status()}>
|
||||||
|
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||||
|
<StatusPopoverV2 />
|
||||||
|
</Tooltip>
|
||||||
|
</Show>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||||
<NewSessionView input={draft.input} project={project} workspace={workspace} />
|
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
||||||
|
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
|
||||||
|
<NewSessionDesignView>
|
||||||
|
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<PromptInputV2Composer controller={promptInputV2Controller} />
|
||||||
|
<Show when={projectController.empty()}>
|
||||||
|
<PromptProjectAddButton controller={projectController} />
|
||||||
|
</Show>
|
||||||
|
<Show when={projectController.selected()}>
|
||||||
|
<div class="flex min-h-7 min-w-0 flex-col items-center justify-center gap-0 text-v2-text-text-faint sm:flex-row">
|
||||||
|
<PromptProjectSelector controller={projectController} placement="bottom" />
|
||||||
|
<Show
|
||||||
|
when={showWorkspaceBar()}
|
||||||
|
fallback={<PromptGitStatus branch={selectedBranch()} noGit={sync().project?.vcs !== "git"} />}
|
||||||
|
>
|
||||||
|
<PromptWorkspaceSelector
|
||||||
|
value={newSessionWorktree()}
|
||||||
|
projectRoot={projectRoot()}
|
||||||
|
workspaces={sync().project?.sandboxes ?? []}
|
||||||
|
branch={selectedBranch()}
|
||||||
|
onChange={(value) =>
|
||||||
|
setStore(
|
||||||
|
"worktree",
|
||||||
|
value === "main" && sync().project?.worktree !== sdk().directory
|
||||||
|
? sync().project?.worktree
|
||||||
|
: value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDone={promptInputV2Controller.restoreFocus}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
{/*</Show>*/}
|
||||||
|
</div>
|
||||||
|
</NewSessionDesignView>
|
||||||
|
<ProviderTip
|
||||||
|
ready={() => serverSync().child(sdk().directory)[0].provider_ready}
|
||||||
|
connected={() => providers.paid().length > 0}
|
||||||
|
openProviders={openProviders}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProviderTip(props: { ready: () => boolean; connected: () => boolean; openProviders: () => void }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const [persistedState, setPersistedState, , persistedReady] = persisted(
|
||||||
|
Persist.global("new-session.provider-tip"),
|
||||||
|
createStore({ dismissedAt: 0 }),
|
||||||
|
)
|
||||||
|
const visible = createMemo(
|
||||||
|
() =>
|
||||||
|
props.ready() &&
|
||||||
|
persistedReady() &&
|
||||||
|
!props.connected() &&
|
||||||
|
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
|
||||||
|
)
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
setPersistedState("dismissedAt", Date.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ref, setRef] = createSignal<HTMLDivElement>()
|
||||||
|
const presence = createPresence({
|
||||||
|
show: () => visible(),
|
||||||
|
element: () => ref() ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={presence.present()}>
|
||||||
|
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
|
||||||
|
<div
|
||||||
|
ref={setRef}
|
||||||
|
data-component="provider-tip"
|
||||||
|
data-visible={visible()}
|
||||||
|
class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
|
||||||
|
classList={{
|
||||||
|
"data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
|
||||||
|
onClick={props.openProviders}
|
||||||
|
>
|
||||||
|
<span class="truncate">{language.t("home.providerTip")}</span>
|
||||||
|
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
|
||||||
|
<IconV2 name="chevron-down" size="small" class="-rotate-90" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<TooltipV2
|
||||||
|
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
|
||||||
|
placement="top"
|
||||||
|
openDelay={1000}
|
||||||
|
value={language.t("common.dismiss")}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
|
||||||
|
aria-label={language.t("common.dismiss")}
|
||||||
|
onClick={dismiss}
|
||||||
|
>
|
||||||
|
<IconV2 name="xmark-small" />
|
||||||
|
</button>
|
||||||
|
</TooltipV2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import { useSearchParams } from "@solidjs/router"
|
|
||||||
import { createEffect, untrack } from "solid-js"
|
|
||||||
import { usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
|
||||||
import { useComments } from "@/context/comments"
|
|
||||||
import { useLocal } from "@/context/local"
|
|
||||||
import { usePrompt } from "@/context/prompt"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
|
|
||||||
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
|
|
||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
|
||||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
|
||||||
|
|
||||||
export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) {
|
|
||||||
const prompt = usePrompt()
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const comments = useComments()
|
|
||||||
const local = useLocal()
|
|
||||||
const route = useSessionKey()
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
|
||||||
const model = createPromptModelSelection({ agent: () => local.agent.current() })
|
|
||||||
|
|
||||||
useComposerCommands({ model })
|
|
||||||
|
|
||||||
const controls = createPromptInputController({
|
|
||||||
sessionKey: route.sessionKey,
|
|
||||||
sessionID: () => route.params.id,
|
|
||||||
queryOptions: serverSync().queryOptions,
|
|
||||||
model,
|
|
||||||
})
|
|
||||||
const projectControls = createPromptProjectControls()
|
|
||||||
const input = usePromptInputV2Controller({
|
|
||||||
get controls() {
|
|
||||||
return controls()
|
|
||||||
},
|
|
||||||
get newSessionWorktree() {
|
|
||||||
return workspace.worktree()
|
|
||||||
},
|
|
||||||
onNewSessionWorktreeReset: workspace.resetWorktree,
|
|
||||||
onSubmit: comments.clear,
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!prompt.ready()) return
|
|
||||||
untrack(() => {
|
|
||||||
const text = searchParams.prompt
|
|
||||||
if (!text) return
|
|
||||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
|
||||||
setSearchParams({ ...searchParams, prompt: undefined })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
input,
|
|
||||||
prompt: {
|
|
||||||
ready: prompt.ready,
|
|
||||||
readyPromise: () => prompt.ready.promise,
|
|
||||||
},
|
|
||||||
project: {
|
|
||||||
controls: projectControls,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NewSessionDraftController = ReturnType<typeof createNewSessionDraftController>
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
|
||||||
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
|
|
||||||
import { Show, createMemo, createSignal, type Accessor } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { Portal } from "solid-js/web"
|
|
||||||
import createPresence from "solid-presence"
|
|
||||||
import { PromptInputV2Composer } from "@/components/prompt-input-v2"
|
|
||||||
import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
|
||||||
import {
|
|
||||||
PromptProjectAddButton,
|
|
||||||
PromptProjectSelector,
|
|
||||||
type PromptProjectController,
|
|
||||||
} from "@/components/prompt-project-selector"
|
|
||||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useSDK } from "@/context/sdk"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
|
||||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
|
||||||
import type { NewSessionDraftController } from "./new-session-draft-controller"
|
|
||||||
import type { NewSessionWorkspaceController } from "./new-session-workspace-controller"
|
|
||||||
|
|
||||||
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
|
||||||
|
|
||||||
export function NewSessionView(props: {
|
|
||||||
input: NewSessionDraftController["input"]
|
|
||||||
project: PromptProjectController
|
|
||||||
workspace: NewSessionWorkspaceController
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
|
||||||
<div
|
|
||||||
data-component="session-new-design"
|
|
||||||
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] 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 flex flex-col gap-8">
|
|
||||||
<PromptInputV2Composer controller={props.input} />
|
|
||||||
<Show when={props.project.empty()}>
|
|
||||||
<PromptProjectAddButton controller={props.project} />
|
|
||||||
</Show>
|
|
||||||
<Show when={props.project.selected()}>
|
|
||||||
<div class="flex min-h-7 min-w-0 flex-col items-center justify-center gap-0 text-v2-text-text-faint sm:flex-row">
|
|
||||||
<PromptProjectSelector controller={props.project} placement="bottom" />
|
|
||||||
<Show
|
|
||||||
when={props.workspace.bar.visible()}
|
|
||||||
fallback={
|
|
||||||
<PromptGitStatus branch={props.workspace.bar.branch()} noGit={!props.workspace.project.git()} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PromptWorkspaceSelector
|
|
||||||
value={props.workspace.selection.value()}
|
|
||||||
projectRoot={props.workspace.project.root()}
|
|
||||||
workspaces={props.workspace.project.workspaces()}
|
|
||||||
branch={props.workspace.bar.branch()}
|
|
||||||
onChange={props.workspace.selection.set}
|
|
||||||
onDone={props.input.restoreFocus}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ProviderTip />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NewSessionStatus(props: { mount: Accessor<HTMLElement | null>; visible: Accessor<boolean> }) {
|
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={props.mount()} keyed>
|
|
||||||
{(mount) => (
|
|
||||||
<Portal mount={mount}>
|
|
||||||
<Show when={props.visible()}>
|
|
||||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
|
||||||
<StatusPopoverV2 />
|
|
||||||
</Tooltip>
|
|
||||||
</Show>
|
|
||||||
</Portal>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderTip() {
|
|
||||||
const language = useLanguage()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const sdk = useSDK()
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const providers = useProviders(() => sdk().directory)
|
|
||||||
const [persistedState, setPersistedState, , persistedReady] = persisted(
|
|
||||||
Persist.global("new-session.provider-tip"),
|
|
||||||
createStore({ dismissedAt: 0 }),
|
|
||||||
)
|
|
||||||
const visible = createMemo(
|
|
||||||
() =>
|
|
||||||
serverSync().child(sdk().directory)[0].provider_ready &&
|
|
||||||
persistedReady() &&
|
|
||||||
providers.paid().length === 0 &&
|
|
||||||
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
|
|
||||||
)
|
|
||||||
const [ref, setRef] = createSignal<HTMLDivElement>()
|
|
||||||
const presence = createPresence({
|
|
||||||
show: visible,
|
|
||||||
element: () => ref() ?? null,
|
|
||||||
})
|
|
||||||
const openProviders = () => {
|
|
||||||
void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
|
|
||||||
void dialog.show(() => <DialogConnectProvider directory={() => sdk().directory} />)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={presence.present()}>
|
|
||||||
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
|
|
||||||
<div
|
|
||||||
ref={setRef}
|
|
||||||
data-component="provider-tip"
|
|
||||||
data-visible={visible()}
|
|
||||||
class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
|
|
||||||
classList={{ "data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
|
|
||||||
onClick={openProviders}
|
|
||||||
>
|
|
||||||
<span class="truncate">{language.t("home.providerTip")}</span>
|
|
||||||
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
|
|
||||||
<IconV2 name="chevron-down" size="small" class="-rotate-90" />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<TooltipV2
|
|
||||||
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
|
|
||||||
placement="top"
|
|
||||||
openDelay={1000}
|
|
||||||
value={language.t("common.dismiss")}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
|
|
||||||
aria-label={language.t("common.dismiss")}
|
|
||||||
onClick={() => setPersistedState("dismissedAt", Date.now())}
|
|
||||||
>
|
|
||||||
<IconV2 name="xmark-small" />
|
|
||||||
</button>
|
|
||||||
</TooltipV2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user