mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-13 12:24:29 +00:00
Compare commits
161 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f51c0912d | ||
|
|
1c71604e0a | ||
|
|
e242fe19e4 | ||
|
|
f991a6c0b6 | ||
|
|
b1764b2ffd | ||
|
|
ebe5a2b74a | ||
|
|
9f20e0d14b | ||
|
|
ebb907d646 | ||
|
|
b8ee882126 | ||
|
|
693127d382 | ||
|
|
0d90a22f90 | ||
|
|
34ebe814dd | ||
|
|
1fb6c0b5b3 | ||
|
|
98aeb60a7f | ||
|
|
1608565c80 | ||
|
|
b06afd657d | ||
|
|
dd296f7033 | ||
|
|
fb7b2f6b4d | ||
|
|
e0f1c3c20e | ||
|
|
dec304a273 | ||
|
|
c9719dff72 | ||
|
|
7f95cc64c5 | ||
|
|
b525c03d20 | ||
|
|
8da5fd0a66 | ||
|
|
0303c29e3f | ||
|
|
adb0c4d4f9 | ||
|
|
991496a753 | ||
|
|
76db218674 | ||
|
|
29671c1397 | ||
|
|
f66624fe6e | ||
|
|
d475fd6137 | ||
|
|
93eee0daf4 | ||
|
|
445e0d7676 | ||
|
|
4018c863e3 | ||
|
|
a8f2884521 | ||
|
|
c0814da785 | ||
|
|
20dcff1e2e | ||
|
|
11dd281c92 | ||
|
|
548608b7ad | ||
|
|
4e0f509e7b | ||
|
|
ff3b174c42 | ||
|
|
70303d0b42 | ||
|
|
7ccf223c84 | ||
|
|
e9b9a62fe4 | ||
|
|
81c623f26e | ||
|
|
3696d1ded1 | ||
|
|
50f208d69f | ||
|
|
958320f9c1 | ||
|
|
d1ee4c8dca | ||
|
|
ac018e3a35 | ||
|
|
e6e9c15d34 | ||
|
|
aaee5fb680 | ||
|
|
ff0abacf4b | ||
|
|
0771e3a8be | ||
|
|
da952135ca | ||
|
|
789705ea96 | ||
|
|
ba54cee55e | ||
|
|
847e06f9e1 | ||
|
|
2db618dea3 | ||
|
|
ecab692ca1 | ||
|
|
59a323e9a8 | ||
|
|
658bf6fa58 | ||
|
|
a82ca86008 | ||
|
|
ed472d8a67 | ||
|
|
ff4414bb15 | ||
|
|
56ad2db020 | ||
|
|
ae811ad8d2 | ||
|
|
85df106713 | ||
|
|
892bb75265 | ||
|
|
a115565054 | ||
|
|
d82d22b2d7 | ||
|
|
d723147083 | ||
|
|
9f9f0fb8eb | ||
|
|
ecb274273a | ||
|
|
5f421883a8 | ||
|
|
fa97475ee8 | ||
|
|
0eaeb4588e | ||
|
|
1413d77b1f | ||
|
|
624dd94b5d | ||
|
|
d86f24b6b3 | ||
|
|
03de51bd3c | ||
|
|
8f9742d988 | ||
|
|
f6e7aefa72 | ||
|
|
e269788a8f | ||
|
|
66780195dc | ||
|
|
ad2087094d | ||
|
|
5bdf1c4b96 | ||
|
|
135f8ffb2a | ||
|
|
bf5a01edd9 | ||
|
|
81ca2df6ad | ||
|
|
aea68c386a | ||
|
|
8eea53a41e | ||
|
|
3befd0c6c5 | ||
|
|
8577eb8ec9 | ||
|
|
c856f875a1 | ||
|
|
264dd213f9 | ||
|
|
125727d09c | ||
|
|
8c7b35ad05 | ||
|
|
e2a33f75e1 | ||
|
|
006d673ed2 | ||
|
|
6b4d617df0 | ||
|
|
e3471526f4 | ||
|
|
6b30e0b752 | ||
|
|
fbabce1125 | ||
|
|
8f56ed5b85 | ||
|
|
81b5a6a08b | ||
|
|
94cb6390aa | ||
|
|
42bea5d297 | ||
|
|
f252e3234c | ||
|
|
eef3ae3e1f | ||
|
|
fc88dde63f | ||
|
|
4619e9d183 | ||
|
|
4dc363f306 | ||
|
|
2e8082dd21 | ||
|
|
a52fe28246 | ||
|
|
8c5ba8aeb0 | ||
|
|
50330820c0 | ||
|
|
7222fc0ba0 | ||
|
|
17bdb5d56a | ||
|
|
7a463cd193 | ||
|
|
352a54c698 | ||
|
|
93957da2c9 | ||
|
|
edcfd562af | ||
|
|
ef5ec5dc28 | ||
|
|
c426cb0f14 | ||
|
|
dd1862cc2b | ||
|
|
a25b2af05a | ||
|
|
8bfd6fdba2 | ||
|
|
cf7a1b8d80 | ||
|
|
5ba4c0e024 | ||
|
|
567e094e6c | ||
|
|
b523998329 | ||
|
|
7e1247c420 | ||
|
|
783888131e | ||
|
|
213a87234d | ||
|
|
d98bd4bd52 | ||
|
|
22125d1347 | ||
|
|
8c120f2fab | ||
|
|
c6ec2f47ef | ||
|
|
0fd6f365be | ||
|
|
60bdb6e9ba | ||
|
|
6e9cd576ea | ||
|
|
53ec15a56a | ||
|
|
a90b62267f | ||
|
|
24556331c8 | ||
|
|
39145b99e8 | ||
|
|
0afa6e03a8 | ||
|
|
7a3c775dc1 | ||
|
|
3ea58bb790 | ||
|
|
50c705cd2d | ||
|
|
3894c217cc | ||
|
|
66c2bb8f37 | ||
|
|
1bbbd51d48 | ||
|
|
50f3e74d05 | ||
|
|
21475a1dfd | ||
|
|
dce4c05fa9 | ||
|
|
8c56571ef9 | ||
|
|
92a77b72fb | ||
|
|
4f6b929784 | ||
|
|
55119559b3 | ||
|
|
fd5531316f |
3
.github/VOUCHED.td
vendored
3
.github/VOUCHED.td
vendored
@@ -8,6 +8,8 @@
|
||||
# - Denounce with minus prefix: -username or -platform:username.
|
||||
# - Optional details after a space following the handle.
|
||||
adamdotdevin
|
||||
ariane-emory
|
||||
-florianleibert
|
||||
fwang
|
||||
iamdavidhill
|
||||
jayair
|
||||
@@ -15,4 +17,5 @@ kitlangton
|
||||
kommander
|
||||
r44vc0rp
|
||||
rekram1-node
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
thdxr
|
||||
|
||||
11
.github/workflows/docs-locale-sync.yml
vendored
11
.github/workflows/docs-locale-sync.yml
vendored
@@ -64,10 +64,13 @@ jobs:
|
||||
|
||||
Requirements:
|
||||
1. Update all relevant locale docs under packages/web/src/content/docs/<locale>/ so they reflect these English page changes.
|
||||
2. Preserve frontmatter keys, internal links, code blocks, and existing locale-specific metadata unless the English change requires an update.
|
||||
3. Keep locale docs structure aligned with their corresponding English pages.
|
||||
4. Do not modify English source docs in packages/web/src/content/docs/*.mdx.
|
||||
5. If no locale updates are needed, make no changes.
|
||||
2. You MUST use the Task tool for translation work and launch subagents with subagent_type `translator` (defined in .opencode/agent/translator.md).
|
||||
3. Do not translate directly in the primary agent. Use translator subagent output as the source for locale text updates.
|
||||
4. Run translator subagent Task calls in parallel whenever file/locale translation work is independent.
|
||||
5. Preserve frontmatter keys, internal links, code blocks, and existing locale-specific metadata unless the English change requires an update.
|
||||
6. Keep locale docs structure aligned with their corresponding English pages.
|
||||
7. Do not modify English source docs in packages/web/src/content/docs/*.mdx.
|
||||
8. If no locale updates are needed, make no changes.
|
||||
|
||||
- name: Commit and push locale docs updates
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
|
||||
4
.github/workflows/pr-management.yml
vendored
4
.github/workflows/pr-management.yml
vendored
@@ -60,9 +60,11 @@ jobs:
|
||||
run: |
|
||||
COMMENT=$(bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details and search for duplicates")
|
||||
|
||||
gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
|
||||
if [ "$COMMENT" != "No duplicate PRs found" ]; then
|
||||
gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
|
||||
|
||||
$COMMENT"
|
||||
fi
|
||||
|
||||
add-contributor-label:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
54
.github/workflows/sign-cli.yml
vendored
Normal file
54
.github/workflows/sign-cli.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: sign-cli
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- brendan/desktop-signpath
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
sign-cli:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-tags: true
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
./packages/opencode/script/build.ts
|
||||
|
||||
- name: Upload unsigned Windows CLI
|
||||
id: upload_unsigned_windows_cli
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: unsigned-opencode-windows-cli
|
||||
path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Submit SignPath signing request
|
||||
id: submit_signpath_signing_request
|
||||
uses: signpath/github-action-submit-signing-request@v1
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_KEY }}
|
||||
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
|
||||
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
|
||||
artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
|
||||
github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: signed-opencode-cli
|
||||
|
||||
- name: Upload signed Windows CLI
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signed-opencode-windows-cli
|
||||
path: signed-opencode-cli/*.exe
|
||||
if-no-files-found: error
|
||||
@@ -32,6 +32,9 @@ description: Use this when you are working on file operations like reading, writ
|
||||
- Decode tool stderr with `Bun.readableStreamToText`.
|
||||
- For large writes, use `Bun.write(Bun.file(path), text)`.
|
||||
|
||||
NOTE: Bun.file(...).exists() will return `false` if the value is a directory.
|
||||
Use Filesystem.exists(...) instead if path can be file or directory
|
||||
|
||||
## Quick checklist
|
||||
|
||||
- Use Bun APIs first.
|
||||
|
||||
112
bun.lock
112
bun.lock
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.65",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -73,7 +73,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.65",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -107,7 +107,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.65",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -134,7 +134,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.65",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -158,7 +158,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.65",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -182,7 +182,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.65",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -215,7 +215,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.65",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -244,7 +244,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.65",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -260,7 +260,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.65",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -268,12 +268,12 @@
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.14.1",
|
||||
"@ai-sdk/amazon-bedrock": "3.0.74",
|
||||
"@ai-sdk/anthropic": "2.0.58",
|
||||
"@ai-sdk/amazon-bedrock": "3.0.79",
|
||||
"@ai-sdk/anthropic": "2.0.62",
|
||||
"@ai-sdk/azure": "2.0.91",
|
||||
"@ai-sdk/cerebras": "1.0.36",
|
||||
"@ai-sdk/cohere": "2.0.22",
|
||||
"@ai-sdk/deepinfra": "1.0.33",
|
||||
"@ai-sdk/deepinfra": "1.0.36",
|
||||
"@ai-sdk/gateway": "2.0.30",
|
||||
"@ai-sdk/google": "2.0.52",
|
||||
"@ai-sdk/google-vertex": "3.0.98",
|
||||
@@ -301,8 +301,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.77",
|
||||
"@opentui/solid": "0.1.77",
|
||||
"@opentui/core": "0.1.79",
|
||||
"@opentui/solid": "0.1.79",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -366,7 +366,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.65",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -386,7 +386,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.65",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -397,7 +397,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.65",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -410,7 +410,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.65",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -452,7 +452,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.65",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -463,7 +463,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.65",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -513,7 +513,7 @@
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/diffs": "1.0.2",
|
||||
"@pierre/diffs": "1.1.0-beta.13",
|
||||
"@playwright/test": "1.51.0",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
@@ -522,7 +522,7 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.8",
|
||||
"@types/bun": "1.3.9",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "22.13.9",
|
||||
"@types/semver": "7.7.1",
|
||||
@@ -565,7 +565,7 @@
|
||||
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="],
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.79", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.62", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GfAQUb1GEmdTjLu5Ud1d5sieNHDpwoQdb4S14KmJlA5RsGREUZ1tfSKngFaiClxFtL0xPSZjePhTMV6Z65A7/g=="],
|
||||
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
|
||||
|
||||
@@ -577,7 +577,7 @@
|
||||
|
||||
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-lqmINr+1Jy2yGXxnQB6IrC2xMtUY5uK96pyKfqTj1kLlXGatKnJfXF7WTkOGgQrFqIYqpjDz+sPVR3n0KUEUtA=="],
|
||||
|
||||
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="],
|
||||
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.33", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LndvRktEgY2IFu4peDJMEXcjhHEEFtM0upLx/J64kCpFHCifalXpK4PPSX3PVndnn0bJzvamO5+fc0z2ooqBZw=="],
|
||||
|
||||
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NiKjvqXI/96e/7SjZGgQH141PBqggsF7fNbjGTv4RgVWayMXp9mj0Ou2NjAUGwwxJwj/qseY0gXiDCYaHWFBkw=="],
|
||||
|
||||
@@ -1279,21 +1279,21 @@
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.1.77", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.77", "@opentui/core-darwin-x64": "0.1.77", "@opentui/core-linux-arm64": "0.1.77", "@opentui/core-linux-x64": "0.1.77", "@opentui/core-win32-arm64": "0.1.77", "@opentui/core-win32-x64": "0.1.77", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-lE3kabm6jdqK3AuBq+O0zZrXdxt6ulmibTc57sf+AsPny6cmwYHnWI4wD6hcreFiYoQVNVvdiJchVgPtowMlEg=="],
|
||||
"@opentui/core": ["@opentui/core@0.1.79", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.79", "@opentui/core-darwin-x64": "0.1.79", "@opentui/core-linux-arm64": "0.1.79", "@opentui/core-linux-x64": "0.1.79", "@opentui/core-win32-arm64": "0.1.79", "@opentui/core-win32-x64": "0.1.79", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-job/t09w8A/aHb/WuaVbimu5fIffyN+PCuVO5cYhXEg/NkOkC/WdFi80B8bwncR/DBPyLAh6oJ3EG86grOVo5g=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.77", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SNqmygCMEsPCW7xWjzCZ5caBf36xaprwVdAnFijGDOuIzLA4iaDa6um8cj3TJh7awenN3NTRsuRc7OuH42UH+g=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.79", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kgsGniV+DM5G1P3GideyJhvfnthNKcVCAm2mPTIr9InQ3L0gS/Feh7zgwOS/jxDvdlQbOWGKMk2Z3JApeC1MLw=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.77", "", { "os": "darwin", "cpu": "x64" }, "sha512-/8fsa03swEHTQt/9NrGm98kemlU+VuTURI/OFZiH53vPDRrOYIYoa4Jyga/H7ZMcG+iFhkq97zIe+0Kw95LGmA=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.79", "", { "os": "darwin", "cpu": "x64" }, "sha512-OpyAmFqAAKQ2CeFmf/oLWcNksmP6Ryx/3R5dbKXThOudMCeQvfvInJTRbc2jTn9VFpf+Qj4BgHkJg1h90tf/EA=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.77", "", { "os": "linux", "cpu": "arm64" }, "sha512-QfUXZJPc69OvqoMu+AlLgjqXrwu4IeqcBuUWYMuH8nPTeLsVUc3CBbXdV2lv9UDxWzxzrxdS4ALPaxvmEv9lsQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.79", "", { "os": "linux", "cpu": "arm64" }, "sha512-DCa5YaknS4bWhFt8TMEGH+qmTinyzuY8hoZbO4crtWXAxofPP7Pas76Cwxlvis/PyLffA+pPxAl1l5sUZpsvqw=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.77", "", { "os": "linux", "cpu": "x64" }, "sha512-Kmfx0yUKnPj67AoXYIgL7qQo0QVsUG5Iw8aRtv6XFzXqa5SzBPhaKkKZ9yHPjOmTalZquUs+9zcCRNKpYYuL7A=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.79", "", { "os": "linux", "cpu": "x64" }, "sha512-V6xjvFfHh3NGvsuuDae1KHPRZXHMEE8XL0A/GM6v4I4OCC23kDmkK60Vn6OptQwAzwwbz0X0IX+Ut/GQU9qGgA=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.77", "", { "os": "win32", "cpu": "arm64" }, "sha512-HGTscPXc7gdd23Nh1DbzUNjog1I+5IZp95XPtLftGTpjrWs60VcetXcyJqK2rQcXNxewJK5yDyaa5QyMjfEhCQ=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.79", "", { "os": "win32", "cpu": "arm64" }, "sha512-sPRKnVzOdT5szI59tte7pxwwkYA+07EQN+6miFAvkFuiLmRUngONUD8HVjL7nCnxcPFqxaU4Rvl1y40ST86g8g=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.77", "", { "os": "win32", "cpu": "x64" }, "sha512-c7GijsbvVgnlzd2murIbwuwrGbcv76KdUw6WlVv7a0vex50z6xJCpv1keGzpe0QfxrZ/6fFEFX7JnwGLno0wjA=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.79", "", { "os": "win32", "cpu": "x64" }, "sha512-vmQcFTvKf9fqajnDtgU6/uAsiTGwx8//khqHVBmiTEXUsiT792Ki9l8sgNughbuldqG5iZOiF6IaAWU1H67UpA=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.1.77", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.77", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-JY+hUbXVV+XCk6bC8dvcwawWCEmC3Gid6GDs23AJWBgHZ3TU2kRKrgwTdltm45DOq2cZXrYCt690/yE8bP+Gxg=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.1.79", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.79", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-c5+0jexKxb8GwRDDkQ/U6isZZqClAzHccXmYiLYmSnqdoQQp2lIGHLartL+K8lfIQrsKClzP2ZHumN6nexRfRg=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -1409,7 +1409,7 @@
|
||||
|
||||
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
|
||||
|
||||
"@pierre/diffs": ["@pierre/diffs@1.0.2", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-RkFSDD5X/U+8QjyilPViYGJfmJNWXR17zTL8zw48+DcVC1Ujbh6I1edyuRnFfgRzpft05x2DSCkz2cjoIAxPvQ=="],
|
||||
"@pierre/diffs": ["@pierre/diffs@1.1.0-beta.13", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-D35rxDu5V7XHX5aVGU6PF12GhscL+I+9QYgxK/i3h0d2XSirAxDdVNm49aYwlOhgmdvL0NbS1IHxPswVB5yJvw=="],
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
@@ -1853,7 +1853,7 @@
|
||||
|
||||
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
@@ -2181,7 +2181,7 @@
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
|
||||
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
|
||||
|
||||
@@ -4151,7 +4151,9 @@
|
||||
|
||||
"@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.62", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-I3RhaOEMnWlWnrvjNBOYvUb19Dwf2nw01IruZrVJRDi688886e11wnd5DxrBZLd2V29Gizo3vpOPnnExsA+wTA=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
|
||||
|
||||
@@ -4163,7 +4165,9 @@
|
||||
|
||||
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ=="],
|
||||
|
||||
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
|
||||
|
||||
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
@@ -4387,13 +4391,9 @@
|
||||
|
||||
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
||||
|
||||
"@pierre/diffs/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||
|
||||
"@pierre/diffs/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ZfWJNm2VMhKkQIKT9qXbs76RRcT0SF/CAvEz0+RkpUDAoDaCx0uFdCGzSRiD9gSlhm6AHkjdieOBJMaO2eC1rQ=="],
|
||||
|
||||
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.19.0", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/types": "3.19.0" } }, "sha512-e6vwrsyw+wx4OkcrDbL+FVCxwx8jgKiCoXzakVur++mIWVcgpzIi8vxf4/b4dVTYrV/nUx5RjinMf4tq8YV8Fw=="],
|
||||
|
||||
"@pierre/diffs/shiki": ["shiki@3.19.0", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/engine-oniguruma": "3.19.0", "@shikijs/langs": "3.19.0", "@shikijs/themes": "3.19.0", "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-77VJr3OR/VUZzPiStyRhADmO2jApMM0V2b1qf0RpfWya8Zr1PeZev5AEpPGAAKWdiYUtcZGBE4F5QvJml1PvWA=="],
|
||||
"@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
@@ -4457,6 +4457,8 @@
|
||||
|
||||
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
|
||||
@@ -4579,7 +4581,7 @@
|
||||
|
||||
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.62", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-I3RhaOEMnWlWnrvjNBOYvUb19Dwf2nw01IruZrVJRDi688886e11wnd5DxrBZLd2V29Gizo3vpOPnnExsA+wTA=="],
|
||||
|
||||
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
|
||||
|
||||
@@ -4973,23 +4975,9 @@
|
||||
|
||||
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@pierre/diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
"@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||
|
||||
"@pierre/diffs/@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
|
||||
|
||||
"@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA=="],
|
||||
|
||||
"@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
|
||||
|
||||
"@pierre/diffs/shiki/@shikijs/core": ["@shikijs/core@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA=="],
|
||||
|
||||
"@pierre/diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1hRxtYIJfJSZeM5ivbUXv9hcJP3PWRo5prG/V2sWwiubUKTa+7P62d2qxCW8jiVFX4pgRHhnHNp+qeR7Xl+6kg=="],
|
||||
|
||||
"@pierre/diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-dBMFzzg1QiXqCVQ5ONc0z2ebyoi5BKz+MtfByLm0o5/nbUu3Iz8uaTCa5uzGiscQKm7lVShfZHU1+OG3t5hgwg=="],
|
||||
|
||||
"@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-H36qw+oh91Y0s6OlFfdSuQ0Ld+5CgB/VE6gNPK+Hk4VRbVG/XQgkjnt4KzfnnoO6tZPtKJKHPjwebOCfjd6F8A=="],
|
||||
|
||||
"@pierre/diffs/shiki/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
|
||||
"@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
|
||||
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
@@ -5013,6 +5001,8 @@
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
|
||||
@@ -5117,6 +5107,8 @@
|
||||
|
||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"opencode/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
|
||||
|
||||
@@ -166,14 +166,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||
|
||||
let logProcessor
|
||||
if ($app.stage === "production" || $app.stage === "frank") {
|
||||
const HONEYCOMB_API_KEY = new sst.Secret("HONEYCOMB_API_KEY")
|
||||
logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
||||
handler: "packages/console/function/src/log-processor.ts",
|
||||
link: [HONEYCOMB_API_KEY],
|
||||
})
|
||||
}
|
||||
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
||||
handler: "packages/console/function/src/log-processor.ts",
|
||||
link: [new sst.Secret("HONEYCOMB_API_KEY")],
|
||||
})
|
||||
|
||||
new sst.cloudflare.x.SolidStart("Console", {
|
||||
domain,
|
||||
@@ -211,7 +207,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
transform: {
|
||||
worker: {
|
||||
placement: { mode: "smart" },
|
||||
tailConsumers: logProcessor ? [{ service: logProcessor.nodes.worker.scriptName }] : [],
|
||||
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
16
install
16
install
@@ -130,7 +130,7 @@ else
|
||||
needs_baseline=false
|
||||
if [ "$arch" = "x64" ]; then
|
||||
if [ "$os" = "linux" ]; then
|
||||
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
|
||||
if ! grep -qwi avx2 /proc/cpuinfo 2>/dev/null; then
|
||||
needs_baseline=true
|
||||
fi
|
||||
fi
|
||||
@@ -141,6 +141,20 @@ else
|
||||
needs_baseline=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$os" = "windows" ]; then
|
||||
ps="(Add-Type -MemberDefinition \"[DllImport(\"\"kernel32.dll\"\")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);\" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)"
|
||||
out=""
|
||||
if command -v powershell.exe >/dev/null 2>&1; then
|
||||
out=$(powershell.exe -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true)
|
||||
elif command -v pwsh >/dev/null 2>&1; then
|
||||
out=$(pwsh -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true)
|
||||
fi
|
||||
out=$(echo "$out" | tr -d '\r' | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
|
||||
if [ "$out" != "true" ] && [ "$out" != "1" ]; then
|
||||
needs_baseline=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
target="$os-$arch"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"keep": {
|
||||
"days": true,
|
||||
"amount": 14
|
||||
},
|
||||
"auditLog": "/home/thdxr/dev/projects/sst/opencode/logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json",
|
||||
"files": [
|
||||
{
|
||||
"date": 1759827172859,
|
||||
"name": "/home/thdxr/dev/projects/sst/opencode/logs/mcp-puppeteer-2025-10-07.log",
|
||||
"hash": "a3d98b26edd793411b968a0d24cfeee8332138e282023c3b83ec169d55c67f16"
|
||||
}
|
||||
],
|
||||
"hashType": "sha256"
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.879"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.880"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.191"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.192"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.267"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.268"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.276"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.277"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.838"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.839"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.499"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.500"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
|
||||
{"arguments":{"url":"https://google.com"},"level":"debug","message":"Tool call received","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150","tool":"puppeteer_navigate"}
|
||||
{"0":"n","1":"p","2":"x","level":"info","message":"Launching browser with config:","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.488"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.489"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.815"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.816"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.934"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.935"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.154"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.155"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.426"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.427"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.715"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.716"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.063"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.064"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.567"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.568"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.937"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.938"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.120"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.121"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.490"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.491"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.524"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.525"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.126"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.127"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.175"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.176"}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-cvRBvHRuunNjF07c4GVHl5rRgoTn1qfI/HdJWtOV63M=",
|
||||
"aarch64-linux": "sha256-DJUI4pMZ7wQTnyOiuDHALmZz7FZtrTbzRzCuNOShmWE=",
|
||||
"aarch64-darwin": "sha256-JnkqDwuC7lNsjafV+jOGfvs8K1xC8rk5CTOW+spjiCA=",
|
||||
"x86_64-darwin": "sha256-GBeTqq2vDn/mXplYNglrAT2xajjFVzB4ATHnMS0j7z4="
|
||||
"x86_64-linux": "sha256-FsFTitxnN2brebZDBRGJB0NWTOVYDa/QcNRH0ip/Gk4=",
|
||||
"aarch64-linux": "sha256-knSEqEPyonBUfmGZKTq5Om4HikItWbfPdfT7p6iljzs=",
|
||||
"aarch64-darwin": "sha256-uRgWfuOlLECRCOszm8XhySiWxu9IdDhpSbosPZPAZVI=",
|
||||
"x86_64-darwin": "sha256-gHuA+Ud9L+XLvKm5Vp5jCXfZWOtunnmX/lB8vczHsG0="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "AI-powered development tool",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.8",
|
||||
"packageManager": "bun@1.3.9",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||
@@ -23,7 +23,7 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@types/bun": "1.3.8",
|
||||
"@types/bun": "1.3.9",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
@@ -35,7 +35,7 @@
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/diffs": "1.0.2",
|
||||
"@pierre/diffs": "1.1.0-beta.13",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSidebar, withSession } from "../actions"
|
||||
import { defocus, openSidebar, withSession } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { modKey } from "../utils"
|
||||
|
||||
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
@@ -40,3 +41,84 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("titlebar forward is cleared after branching history from sidebar", async ({ page, slug, sdk, gotoSession }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const stamp = Date.now()
|
||||
|
||||
await withSession(sdk, `e2e titlebar history a ${stamp}`, async (a) => {
|
||||
await withSession(sdk, `e2e titlebar history b ${stamp}`, async (b) => {
|
||||
await withSession(sdk, `e2e titlebar history c ${stamp}`, async (c) => {
|
||||
await gotoSession(a.id)
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
|
||||
await expect(second).toBeVisible()
|
||||
await second.scrollIntoViewIfNeeded()
|
||||
await second.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
|
||||
const back = page.getByRole("button", { name: "Back" })
|
||||
const forward = page.getByRole("button", { name: "Forward" })
|
||||
|
||||
await expect(back).toBeVisible()
|
||||
await expect(back).toBeEnabled()
|
||||
await back.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${a.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
|
||||
await expect(third).toBeVisible()
|
||||
await third.scrollIntoViewIfNeeded()
|
||||
await third.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
|
||||
await expect(forward).toBeVisible()
|
||||
await expect(forward).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, gotoSession }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const stamp = Date.now()
|
||||
|
||||
await withSession(sdk, `e2e titlebar shortcuts 1 ${stamp}`, async (one) => {
|
||||
await withSession(sdk, `e2e titlebar shortcuts 2 ${stamp}`, async (two) => {
|
||||
await gotoSession(one.id)
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||
await expect(link).toBeVisible()
|
||||
await link.scrollIntoViewIfNeeded()
|
||||
await link.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
|
||||
await defocus(page)
|
||||
await page.keyboard.press(`${modKey}+[`)
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
|
||||
await defocus(page)
|
||||
await page.keyboard.press(`${modKey}+]`)
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openPalette, clickListItem } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openPalette(page)
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type("/open")
|
||||
|
||||
const command = page.locator('[data-slash-id="file.open"]').first()
|
||||
await expect(command).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const dialog = page
|
||||
.getByRole("dialog")
|
||||
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||
.first()
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const input = dialog.getByRole("textbox").first()
|
||||
await input.fill("package.json")
|
||||
|
||||
await clickListItem(dialog, { keyStartsWith: "file:" })
|
||||
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
||||
await expect(item).toBeVisible({ timeout: 30_000 })
|
||||
await item.click()
|
||||
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
|
||||
@@ -1,37 +1,49 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
|
||||
test.skip("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
|
||||
test("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const toggle = page.getByRole("button", { name: "Toggle file tree" })
|
||||
const treeTabs = page.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')
|
||||
const panel = page.locator("#file-tree-panel")
|
||||
const treeTabs = panel.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')
|
||||
|
||||
await expect(toggle).toBeVisible()
|
||||
if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(treeTabs).toBeVisible()
|
||||
|
||||
await treeTabs.locator('[data-slot="tabs-trigger"]').nth(1).click()
|
||||
const allTab = treeTabs.getByRole("tab", { name: /^all files$/i })
|
||||
await expect(allTab).toBeVisible()
|
||||
await allTab.click()
|
||||
await expect(allTab).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
const node = (name: string) => treeTabs.getByRole("button", { name, exact: true })
|
||||
const tree = treeTabs.locator('[data-slot="tabs-content"]:not([hidden])')
|
||||
await expect(tree).toBeVisible()
|
||||
|
||||
await expect(node("packages")).toBeVisible()
|
||||
await node("packages").click()
|
||||
const expand = async (name: string) => {
|
||||
const folder = tree.getByRole("button", { name, exact: true }).first()
|
||||
await expect(folder).toBeVisible()
|
||||
await expect(folder).toHaveAttribute("aria-expanded", /true|false/)
|
||||
if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click()
|
||||
await expect(folder).toHaveAttribute("aria-expanded", "true")
|
||||
}
|
||||
|
||||
await expect(node("app")).toBeVisible()
|
||||
await node("app").click()
|
||||
await expand("packages")
|
||||
await expand("app")
|
||||
await expand("src")
|
||||
await expand("components")
|
||||
|
||||
await expect(node("src")).toBeVisible()
|
||||
await node("src").click()
|
||||
|
||||
await expect(node("components")).toBeVisible()
|
||||
await node("components").click()
|
||||
|
||||
await expect(node("file-tree.tsx")).toBeVisible()
|
||||
await node("file-tree.tsx").click()
|
||||
const file = tree.getByRole("button", { name: "file-tree.tsx", exact: true }).first()
|
||||
await expect(file).toBeVisible()
|
||||
await file.click()
|
||||
|
||||
const tab = page.getByRole("tab", { name: "file-tree.tsx" })
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
const code = page.locator('[data-component="code"]').first()
|
||||
await expect(code.getByText("export default function FileTree")).toBeVisible()
|
||||
await expect(code).toBeVisible()
|
||||
await expect(code).toContainText("export default function FileTree")
|
||||
})
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openPalette, clickListItem } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const sep = process.platform === "win32" ? "\\" : "/"
|
||||
const file = ["packages", "app", "package.json"].join(sep)
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type("/open")
|
||||
|
||||
const dialog = await openPalette(page)
|
||||
const command = page.locator('[data-slash-id="file.open"]').first()
|
||||
await expect(command).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const dialog = page
|
||||
.getByRole("dialog")
|
||||
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||
.first()
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const input = dialog.getByRole("textbox").first()
|
||||
await input.fill(file)
|
||||
await input.fill("package.json")
|
||||
|
||||
await clickListItem(dialog, { text: /packages.*app.*package.json/ })
|
||||
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
|
||||
let index = -1
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
|
||||
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
|
||||
return index >= 0
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const item = items.nth(index)
|
||||
await expect(item).toBeVisible()
|
||||
await item.click()
|
||||
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
@@ -22,5 +45,5 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
|
||||
|
||||
const code = page.locator('[data-component="code"]').first()
|
||||
await expect(code).toBeVisible()
|
||||
await expect(code.getByText("@opencode-ai/app")).toBeVisible()
|
||||
await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
|
||||
import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
|
||||
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
|
||||
import { projectCloseHoverSelector, projectSwitchSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
test("can close a project via hover card close button", async ({ page, withProject }) => {
|
||||
@@ -31,16 +31,15 @@ test("can close a project via hover card close button", async ({ page, withProje
|
||||
}
|
||||
})
|
||||
|
||||
test("can close a project via project header more options menu", async ({ page, withProject }) => {
|
||||
test("closing active project navigates to another open project", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherName = other.split("/").pop() ?? other
|
||||
const otherSlug = dirSlug(other)
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async () => {
|
||||
async ({ slug }) => {
|
||||
await openSidebar(page)
|
||||
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
@@ -49,21 +48,20 @@ test("can close a project via project header more options menu", async ({ page,
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||
|
||||
const header = page
|
||||
.locator(".group\\/project")
|
||||
.filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
|
||||
.first()
|
||||
await expect(header).toContainText(otherName)
|
||||
|
||||
const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
|
||||
await expect(trigger).toHaveCount(1)
|
||||
await trigger.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||
await expect(menu).toBeVisible({ timeout: 10_000 })
|
||||
const menu = await openProjectMenu(page, otherSlug)
|
||||
|
||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||
|
||||
await expect
|
||||
.poll(() => {
|
||||
const pathname = new URL(page.url()).pathname
|
||||
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
||||
if (pathname === "/") return "home"
|
||||
return ""
|
||||
})
|
||||
.toMatch(/^(project|home)$/)
|
||||
|
||||
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
},
|
||||
{ extra: [other] },
|
||||
|
||||
@@ -69,15 +69,19 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await expect(prompt).toBeVisible()
|
||||
await expect(prompt).toBeEditable()
|
||||
await prompt.click()
|
||||
await page.keyboard.type(text)
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(prompt).toBeFocused()
|
||||
await prompt.fill(text)
|
||||
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
||||
await prompt.press("Enter")
|
||||
|
||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/[^/?#]+`), { timeout: 30_000 })
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||
|
||||
const sessionID = sessionIDFromUrl(page.url())
|
||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
|
||||
return sessionID
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import type { Page } from "@playwright/test"
|
||||
|
||||
@@ -14,7 +15,8 @@ import {
|
||||
openWorkspaceMenu,
|
||||
setWorkspacesEnabled,
|
||||
} from "../actions"
|
||||
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||
import { createSdk, dirSlug } from "../utils"
|
||||
|
||||
function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
@@ -126,6 +128,49 @@ test("can create a workspace", async ({ page, withProject }) => {
|
||||
})
|
||||
})
|
||||
|
||||
test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
|
||||
const nonGitSlug = dirSlug(nonGit)
|
||||
|
||||
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
||||
|
||||
try {
|
||||
await withProject(async () => {
|
||||
await page.goto(`/${nonGitSlug}/session`)
|
||||
|
||||
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
||||
|
||||
const activeDir = base64Decode(slugFromUrl(page.url()))
|
||||
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
||||
|
||||
await openSidebar(page)
|
||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||
|
||||
const trigger = page.locator('[data-action="project-menu"]').first()
|
||||
const hasMenu = await trigger
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
if (!hasMenu) return
|
||||
|
||||
await trigger.click({ force: true })
|
||||
|
||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
|
||||
|
||||
await expect(toggle).toBeVisible()
|
||||
await expect(toggle).toBeDisabled()
|
||||
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
|
||||
})
|
||||
} finally {
|
||||
await cleanupTestProject(nonGit)
|
||||
}
|
||||
})
|
||||
|
||||
test("can rename a workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
@@ -214,14 +259,45 @@ test("can delete a workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async (project) => {
|
||||
const { rootSlug, slug } = await setupWorkspaceTest(page, project)
|
||||
const sdk = createSdk(project.directory)
|
||||
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const worktrees = await sdk.worktree
|
||||
.list()
|
||||
.then((r) => r.data ?? [])
|
||||
.catch(() => [] as string[])
|
||||
return worktrees.includes(directory)
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const menu = await openWorkspaceMenu(page, slug)
|
||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||
await confirmDialog(page, /^Delete workspace$/i)
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const worktrees = await sdk.worktree
|
||||
.list()
|
||||
.then((r) => r.data ?? [])
|
||||
.catch(() => [] as string[])
|
||||
return worktrees.includes(directory)
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(false)
|
||||
|
||||
await project.gotoSession()
|
||||
|
||||
await openSidebar(page)
|
||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
|
||||
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,40 +1,95 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { withSession } from "../actions"
|
||||
|
||||
function contextButton(page: Page) {
|
||||
return page
|
||||
.locator('[data-component="button"]')
|
||||
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
|
||||
.first()
|
||||
}
|
||||
|
||||
async function seedContextSession(input: { sessionID: string; sdk: Parameters<typeof withSession>[0] }) {
|
||||
await input.sdk.session.promptAsync({
|
||||
sessionID: input.sessionID,
|
||||
noReply: true,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "seed context",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const messages = await input.sdk.session
|
||||
.messages({ sessionID: input.sessionID, limit: 1 })
|
||||
.then((r) => r.data ?? [])
|
||||
return messages.length
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
|
||||
const title = `e2e smoke context ${Date.now()}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await sdk.session.promptAsync({
|
||||
sessionID: session.id,
|
||||
noReply: true,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "seed context",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? [])
|
||||
return messages.length
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
await seedContextSession({ sessionID: session.id, sdk })
|
||||
|
||||
await gotoSession(session.id)
|
||||
|
||||
const contextButton = page
|
||||
.locator('[data-component="button"]')
|
||||
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
|
||||
.first()
|
||||
|
||||
await expect(contextButton).toBeVisible()
|
||||
await contextButton.click()
|
||||
const trigger = contextButton(page)
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
|
||||
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test("context panel can be closed from the context tab close action", async ({ page, sdk, gotoSession }) => {
|
||||
await withSession(sdk, `e2e context toggle ${Date.now()}`, async (session) => {
|
||||
await seedContextSession({ sessionID: session.id, sdk })
|
||||
await gotoSession(session.id)
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
|
||||
const trigger = contextButton(page)
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
|
||||
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||
const context = tabs.getByRole("tab", { name: "Context" })
|
||||
await expect(context).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "Close tab" }).first().click()
|
||||
await expect(context).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test("context panel can open file picker from context actions", async ({ page, sdk, gotoSession }) => {
|
||||
await withSession(sdk, `e2e context tabs ${Date.now()}`, async (session) => {
|
||||
await seedContextSession({ sessionID: session.id, sdk })
|
||||
await gotoSession(session.id)
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
|
||||
const trigger = contextButton(page)
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
|
||||
await expect(page.getByRole("tab", { name: "Context" })).toBeVisible()
|
||||
await page.getByRole("button", { name: "Open file" }).first().click()
|
||||
|
||||
const dialog = page
|
||||
.getByRole("dialog")
|
||||
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||
.first()
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
43
packages/app/e2e/prompt/prompt-async.spec.ts
Normal file
43
packages/app/e2e/prompt/prompt-async.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { sessionIDFromUrl } from "../actions"
|
||||
|
||||
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||
// the connection open while the agent works, causing "Failed to fetch" over
|
||||
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
||||
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
// Simulate Tailscale/VPN killing the long-lived sync connection
|
||||
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
||||
|
||||
await gotoSession()
|
||||
|
||||
const token = `E2E_ASYNC_${Date.now()}`
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
const sessionID = sessionIDFromUrl(page.url())!
|
||||
|
||||
try {
|
||||
// Agent response arrives via SSE despite sync endpoint being dead
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||
return messages
|
||||
.filter((m) => m.info.role === "assistant")
|
||||
.flatMap((m) => m.parts)
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n")
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toContain(token)
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
@@ -44,9 +44,6 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
|
||||
)
|
||||
|
||||
.toContain(token)
|
||||
|
||||
const reply = page.locator('[data-slot="session-turn-summary-section"]').filter({ hasText: token }).first()
|
||||
await expect(reply).toBeVisible({ timeout: 90_000 })
|
||||
} finally {
|
||||
page.off("pageerror", onPageError)
|
||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||
|
||||
@@ -10,8 +10,11 @@ export const settingsNotificationsAgentSelector = '[data-action="settings-notifi
|
||||
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
|
||||
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
|
||||
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
|
||||
export const settingsSoundsAgentEnabledSelector = '[data-action="settings-sounds-agent-enabled"]'
|
||||
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
|
||||
export const settingsSoundsPermissionsEnabledSelector = '[data-action="settings-sounds-permissions-enabled"]'
|
||||
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
|
||||
export const settingsSoundsErrorsEnabledSelector = '[data-action="settings-sounds-errors-enabled"]'
|
||||
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
||||
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
||||
|
||||
|
||||
@@ -10,39 +10,42 @@ async function seedConversation(input: {
|
||||
sessionID: string
|
||||
token: string
|
||||
}) {
|
||||
const messages = async () =>
|
||||
await input.sdk.session.messages({ sessionID: input.sessionID, limit: 100 }).then((r) => r.data ?? [])
|
||||
const seeded = await messages()
|
||||
const userIDs = new Set(seeded.filter((m) => m.info.role === "user").map((m) => m.info.id))
|
||||
|
||||
const prompt = input.page.locator(promptSelector)
|
||||
await expect(prompt).toBeVisible()
|
||||
await prompt.click()
|
||||
await input.page.keyboard.type(`Reply with exactly: ${input.token}`)
|
||||
await input.page.keyboard.press("Enter")
|
||||
await input.sdk.session.promptAsync({
|
||||
sessionID: input.sessionID,
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: input.token }],
|
||||
})
|
||||
|
||||
let userMessageID: string | undefined
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await input.sdk.session
|
||||
.messages({ sessionID: input.sessionID, limit: 50 })
|
||||
.then((r) => r.data ?? [])
|
||||
const users = messages.filter((m) => m.info.role === "user")
|
||||
const users = (await messages()).filter(
|
||||
(m) =>
|
||||
!userIDs.has(m.info.id) &&
|
||||
m.info.role === "user" &&
|
||||
m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)),
|
||||
)
|
||||
if (users.length === 0) return false
|
||||
|
||||
const user = users.reduce((acc, item) => (item.info.id > acc.info.id ? item : acc))
|
||||
const user = users[users.length - 1]
|
||||
if (!user) return false
|
||||
userMessageID = user.info.id
|
||||
|
||||
const assistantText = messages
|
||||
.filter((m) => m.info.role === "assistant")
|
||||
.flatMap((m) => m.parts)
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n")
|
||||
|
||||
return assistantText.includes(input.token)
|
||||
return true
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
{ timeout: 90_000, intervals: [250, 500, 1_000] },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
if (!userMessageID) throw new Error("Expected a user message id")
|
||||
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 })
|
||||
return { prompt, userMessageID }
|
||||
}
|
||||
|
||||
@@ -124,3 +127,107 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const firstToken = `undo_redo_first_${Date.now()}`
|
||||
const secondToken = `undo_redo_second_${Date.now()}`
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
|
||||
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
const first = await seedConversation({
|
||||
page,
|
||||
sdk,
|
||||
sessionID: session.id,
|
||||
token: firstToken,
|
||||
})
|
||||
const second = await seedConversation({
|
||||
page,
|
||||
sdk,
|
||||
sessionID: session.id,
|
||||
token: secondToken,
|
||||
})
|
||||
|
||||
expect(first.userMessageID).not.toBe(second.userMessageID)
|
||||
|
||||
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
|
||||
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
|
||||
|
||||
await expect(firstMessage.first()).toBeVisible()
|
||||
await expect(secondMessage.first()).toBeVisible()
|
||||
|
||||
await second.prompt.click()
|
||||
await page.keyboard.press(`${modKey}+A`)
|
||||
await page.keyboard.press("Backspace")
|
||||
await page.keyboard.type("/undo")
|
||||
|
||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||
await expect(undo).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe(second.userMessageID)
|
||||
|
||||
await expect(firstMessage.first()).toBeVisible()
|
||||
await expect(secondMessage).toHaveCount(0)
|
||||
|
||||
await second.prompt.click()
|
||||
await page.keyboard.press(`${modKey}+A`)
|
||||
await page.keyboard.press("Backspace")
|
||||
await page.keyboard.type("/undo")
|
||||
await expect(undo).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe(first.userMessageID)
|
||||
|
||||
await expect(firstMessage).toHaveCount(0)
|
||||
await expect(secondMessage).toHaveCount(0)
|
||||
|
||||
await second.prompt.click()
|
||||
await page.keyboard.press(`${modKey}+A`)
|
||||
await page.keyboard.press("Backspace")
|
||||
await page.keyboard.type("/redo")
|
||||
|
||||
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
||||
await expect(redo).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe(second.userMessageID)
|
||||
|
||||
await expect(firstMessage.first()).toBeVisible()
|
||||
await expect(secondMessage).toHaveCount(0)
|
||||
|
||||
await second.prompt.click()
|
||||
await page.keyboard.press(`${modKey}+A`)
|
||||
await page.keyboard.press("Backspace")
|
||||
await page.keyboard.type("/redo")
|
||||
await expect(redo).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeUndefined()
|
||||
|
||||
await expect(firstMessage.first()).toBeVisible()
|
||||
await expect(secondMessage.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,21 +34,34 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
|
||||
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
const originalTitle = `e2e rename test ${stamp}`
|
||||
const newTitle = `e2e renamed ${stamp}`
|
||||
const renamedTitle = `e2e renamed ${stamp}`
|
||||
|
||||
await withSession(sdk, originalTitle, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
||||
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /rename/i)
|
||||
|
||||
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(newTitle)
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill(renamedTitle)
|
||||
await expect(input).toHaveValue(renamedTitle)
|
||||
await input.press("Enter")
|
||||
|
||||
await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.title
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(renamedTitle)
|
||||
|
||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,8 +129,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
|
||||
const { rightSection, popoverBody } = await openSharePopover(page)
|
||||
await popoverBody.getByRole("button", { name: "Publish" }).first().click()
|
||||
const shared = await openSharePopover(page)
|
||||
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
||||
await expect(publish).toBeVisible({ timeout: 30_000 })
|
||||
await publish.click()
|
||||
|
||||
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
@@ -129,14 +148,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
|
||||
const copyButton = rightSection.locator('button[aria-label="Copy link"]').first()
|
||||
await expect(copyButton).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const sharedPopover = await openSharePopover(page)
|
||||
const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
||||
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
||||
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
||||
await unpublish.click()
|
||||
|
||||
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
@@ -147,10 +166,8 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
||||
)
|
||||
.toBeUndefined()
|
||||
|
||||
await expect(copyButton).not.toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const unsharedPopover = await openSharePopover(page)
|
||||
await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||
const unshared = await openSharePopover(page)
|
||||
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle")).first()
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const initialKeybind = await keybindButton.textContent()
|
||||
@@ -51,6 +51,40 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
||||
expect(finalClosed).toBe(initiallyClosed)
|
||||
})
|
||||
|
||||
test("sidebar toggle keybind guards against shortcut conflicts", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const initialKeybind = await keybindButton.textContent()
|
||||
expect(initialKeybind).toContain("B")
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+KeyP`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const toast = page.locator('[data-component="toast"]').last()
|
||||
await expect(toast).toBeVisible()
|
||||
await expect(toast).toContainText(/already/i)
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toContainText("B")
|
||||
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("settings.v3")
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
})
|
||||
|
||||
test("resetting all keybinds to defaults works", async ({ page, gotoSession }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "sidebar.toggle": "mod+shift+x" } }))
|
||||
@@ -277,6 +311,44 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
|
||||
await expect(terminal).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("terminal toggle keybind persists after reload", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+KeyY`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await expect(keybindButton).toContainText("Y")
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
await page.reload()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("settings.v3")
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed?.keybinds?.["terminal.toggle"]
|
||||
})
|
||||
})
|
||||
.toBe("mod+shift+y")
|
||||
|
||||
const reloaded = await openSettings(page)
|
||||
await reloaded.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
const reloadedKeybind = reloaded.locator(keybindButtonSelector("terminal.toggle")).first()
|
||||
await expect(reloadedKeybind).toContainText("Y")
|
||||
await closeDialog(page, reloaded)
|
||||
})
|
||||
|
||||
test("changing command palette keybind works", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
settingsNotificationsPermissionsSelector,
|
||||
settingsReleaseNotesSelector,
|
||||
settingsSoundsAgentSelector,
|
||||
settingsSoundsAgentEnabledSelector,
|
||||
settingsSoundsErrorsSelector,
|
||||
settingsSoundsPermissionsSelector,
|
||||
settingsThemeSelector,
|
||||
settingsUpdatesStartupSelector,
|
||||
} from "../selectors"
|
||||
@@ -139,6 +142,105 @@ test("changing font persists in localStorage and updates CSS variable", async ({
|
||||
expect(newFontFamily).not.toBe(initialFontFamily)
|
||||
})
|
||||
|
||||
test("color scheme and font rehydrate after reload", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
|
||||
const colorSchemeSelect = dialog.locator(settingsColorSchemeSelector)
|
||||
await expect(colorSchemeSelect).toBeVisible()
|
||||
await colorSchemeSelect.locator('[data-slot="select-select-trigger"]').click()
|
||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
||||
|
||||
const fontSelect = dialog.locator(settingsFontSelector)
|
||||
await expect(fontSelect).toBeVisible()
|
||||
|
||||
const initialFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||
})
|
||||
|
||||
const initialSettings = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
const currentFont =
|
||||
(await fontSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||
await fontSelect.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
const fontItems = page.locator('[data-slot="select-select-item"]')
|
||||
expect(await fontItems.count()).toBeGreaterThan(1)
|
||||
|
||||
if (currentFont) {
|
||||
await fontItems.filter({ hasNotText: currentFont }).first().click()
|
||||
}
|
||||
if (!currentFont) {
|
||||
await fontItems.nth(1).click()
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: expect.any(String),
|
||||
},
|
||||
})
|
||||
|
||||
const updatedSettings = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
const updatedFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||
})
|
||||
expect(updatedFontFamily).not.toBe(initialFontFamily)
|
||||
expect(updatedSettings?.appearance?.font).not.toBe(initialSettings?.appearance?.font)
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
await page.reload()
|
||||
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: updatedSettings?.appearance?.font,
|
||||
},
|
||||
})
|
||||
|
||||
const rehydratedSettings = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||
})
|
||||
})
|
||||
.not.toBe(initialFontFamily)
|
||||
|
||||
const rehydratedFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||
})
|
||||
expect(rehydratedFontFamily).not.toBe(initialFontFamily)
|
||||
expect(rehydratedSettings?.appearance?.font).toBe(updatedSettings?.appearance?.font)
|
||||
})
|
||||
|
||||
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
@@ -234,6 +336,91 @@ test("changing sound agent selection persists in localStorage", async ({ page, g
|
||||
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
|
||||
})
|
||||
|
||||
test("disabling agent sound disables sound selection", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const select = dialog.locator(settingsSoundsAgentSelector)
|
||||
const switchContainer = dialog.locator(settingsSoundsAgentEnabledSelector)
|
||||
const trigger = select.locator('[data-slot="select-select-trigger"]')
|
||||
await expect(select).toBeVisible()
|
||||
await expect(switchContainer).toBeVisible()
|
||||
await expect(trigger).toBeEnabled()
|
||||
|
||||
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await expect(trigger).toBeDisabled()
|
||||
|
||||
const stored = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
expect(stored?.sounds?.agentEnabled).toBe(false)
|
||||
})
|
||||
|
||||
test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const permissionsSelect = dialog.locator(settingsSoundsPermissionsSelector)
|
||||
const errorsSelect = dialog.locator(settingsSoundsErrorsSelector)
|
||||
await expect(permissionsSelect).toBeVisible()
|
||||
await expect(errorsSelect).toBeVisible()
|
||||
|
||||
const initial = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
const permissionsCurrent =
|
||||
(await permissionsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||
await permissionsSelect.locator('[data-slot="select-select-trigger"]').click()
|
||||
const permissionItems = page.locator('[data-slot="select-select-item"]')
|
||||
expect(await permissionItems.count()).toBeGreaterThan(1)
|
||||
if (permissionsCurrent) {
|
||||
await permissionItems.filter({ hasNotText: permissionsCurrent }).first().click()
|
||||
}
|
||||
if (!permissionsCurrent) {
|
||||
await permissionItems.nth(1).click()
|
||||
}
|
||||
|
||||
const errorsCurrent =
|
||||
(await errorsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||
await errorsSelect.locator('[data-slot="select-select-trigger"]').click()
|
||||
const errorItems = page.locator('[data-slot="select-select-item"]')
|
||||
expect(await errorItems.count()).toBeGreaterThan(1)
|
||||
if (errorsCurrent) {
|
||||
await errorItems.filter({ hasNotText: errorsCurrent }).first().click()
|
||||
}
|
||||
if (!errorsCurrent) {
|
||||
await errorItems.nth(1).click()
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
sounds: {
|
||||
permissions: expect.any(String),
|
||||
errors: expect.any(String),
|
||||
},
|
||||
})
|
||||
|
||||
const stored = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
expect(stored?.sounds?.permissions).not.toBe(initial?.sounds?.permissions)
|
||||
expect(stored?.sounds?.errors).not.toBe(initial?.sounds?.errors)
|
||||
})
|
||||
|
||||
test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
|
||||
36
packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts
Normal file
36
packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { closeSidebar, hoverSessionItem } from "../actions"
|
||||
import { projectSwitchSelector, sessionItemSelector } from "../selectors"
|
||||
|
||||
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
|
||||
const one = await sdk.session.create({ title: `e2e sidebar popover archive 1 ${stamp}` }).then((r) => r.data)
|
||||
const two = await sdk.session.create({ title: `e2e sidebar popover archive 2 ${stamp}` }).then((r) => r.data)
|
||||
|
||||
if (!one?.id) throw new Error("Session create did not return an id")
|
||||
if (!two?.id) throw new Error("Session create did not return an id")
|
||||
|
||||
try {
|
||||
await gotoSession(one.id)
|
||||
await closeSidebar(page)
|
||||
|
||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||
await expect(project).toBeVisible()
|
||||
await project.hover()
|
||||
|
||||
await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
|
||||
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
|
||||
|
||||
const item = await hoverSessionItem(page, one.id)
|
||||
await item
|
||||
.getByRole("button", { name: /archive/i })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
||||
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSidebar, toggleSidebar } from "../actions"
|
||||
import { openSidebar, toggleSidebar, withSession } from "../actions"
|
||||
|
||||
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
@@ -12,3 +12,26 @@ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
||||
await toggleSidebar(page)
|
||||
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
||||
})
|
||||
|
||||
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
|
||||
await withSession(sdk, "sidebar persist session 1", async (session1) => {
|
||||
await withSession(sdk, "sidebar persist session 2", async (session2) => {
|
||||
await gotoSession(session1.id)
|
||||
|
||||
await openSidebar(page)
|
||||
await toggleSidebar(page)
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
|
||||
await gotoSession(session2.id)
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
|
||||
await page.reload()
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
|
||||
const opened = await page.evaluate(
|
||||
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
|
||||
)
|
||||
await expect(opened).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.65",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "@/index.css"
|
||||
import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js"
|
||||
import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
|
||||
import { Router, Route, Navigate } from "@solidjs/router"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
@@ -30,12 +30,26 @@ import { HighlightsProvider } from "@/context/highlights"
|
||||
import Layout from "@/pages/layout"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { Suspense, JSX } from "solid-js"
|
||||
|
||||
const Home = lazy(() => import("@/pages/home"))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
const Loading = () => <div class="size-full" />
|
||||
|
||||
const HomeRoute = () => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Home />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
const SessionRoute = () => (
|
||||
<SessionProviders>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Session />
|
||||
</Suspense>
|
||||
</SessionProviders>
|
||||
)
|
||||
|
||||
const SessionIndexRoute = () => <Navigate href="session" />
|
||||
|
||||
function UiI18nBridge(props: ParentProps) {
|
||||
const language = useLanguage()
|
||||
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
|
||||
@@ -43,7 +57,7 @@ function UiI18nBridge(props: ParentProps) {
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[] }
|
||||
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +66,71 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
|
||||
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
||||
}
|
||||
|
||||
function AppShellProviders(props: ParentProps) {
|
||||
return (
|
||||
<SettingsProvider>
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<ModelsProvider>
|
||||
<CommandProvider>
|
||||
<HighlightsProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
</SettingsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionProviders(props: ParentProps) {
|
||||
return (
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<CommentsProvider>{props.children}</CommentsProvider>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||
return (
|
||||
<AppShellProviders>
|
||||
{props.appChildren}
|
||||
{props.children}
|
||||
</AppShellProviders>
|
||||
)
|
||||
}
|
||||
|
||||
const getStoredDefaultServerUrl = (platform: ReturnType<typeof usePlatform>) => {
|
||||
if (platform.platform !== "web") return
|
||||
const result = platform.getDefaultServerUrl?.()
|
||||
if (result instanceof Promise) return
|
||||
if (!result) return
|
||||
return normalizeServerUrl(result)
|
||||
}
|
||||
|
||||
const resolveDefaultServerUrl = (props: {
|
||||
defaultUrl?: string
|
||||
storedDefaultServerUrl?: string
|
||||
hostname: string
|
||||
origin: string
|
||||
isDev: boolean
|
||||
devHost?: string
|
||||
devPort?: string
|
||||
}) => {
|
||||
if (props.defaultUrl) return props.defaultUrl
|
||||
if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl
|
||||
if (props.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||
if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}`
|
||||
return props.origin
|
||||
}
|
||||
|
||||
export function AppBaseProviders(props: ParentProps) {
|
||||
return (
|
||||
<MetaProvider>
|
||||
@@ -86,80 +165,29 @@ function ServerKey(props: ParentProps) {
|
||||
|
||||
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
|
||||
const platform = usePlatform()
|
||||
|
||||
const stored = (() => {
|
||||
if (platform.platform !== "web") return
|
||||
const result = platform.getDefaultServerUrl?.()
|
||||
if (result instanceof Promise) return
|
||||
if (!result) return
|
||||
return normalizeServerUrl(result)
|
||||
})()
|
||||
|
||||
const defaultServerUrl = () => {
|
||||
if (props.defaultUrl) return props.defaultUrl
|
||||
if (stored) return stored
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
|
||||
return window.location.origin
|
||||
}
|
||||
const storedDefaultServerUrl = getStoredDefaultServerUrl(platform)
|
||||
const defaultServerUrl = resolveDefaultServerUrl({
|
||||
defaultUrl: props.defaultUrl,
|
||||
storedDefaultServerUrl,
|
||||
hostname: location.hostname,
|
||||
origin: window.location.origin,
|
||||
isDev: import.meta.env.DEV,
|
||||
devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST,
|
||||
devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT,
|
||||
})
|
||||
|
||||
return (
|
||||
<ServerProvider defaultUrl={defaultServerUrl()} isSidecar={props.isSidecar}>
|
||||
<ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}>
|
||||
<ServerKey>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<Router
|
||||
root={(routerProps) => (
|
||||
<SettingsProvider>
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<ModelsProvider>
|
||||
<CommandProvider>
|
||||
<HighlightsProvider>
|
||||
<Layout>
|
||||
{props.children}
|
||||
{routerProps.children}
|
||||
</Layout>
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
</SettingsProvider>
|
||||
)}
|
||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||
>
|
||||
<Route
|
||||
path="/"
|
||||
component={() => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Home />
|
||||
</Suspense>
|
||||
)}
|
||||
/>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route
|
||||
path="/session/:id?"
|
||||
component={(p) => (
|
||||
<Show when={p.params.id ?? "new"}>
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<CommentsProvider>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Session />
|
||||
</Suspense>
|
||||
</CommentsProvider>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
</Show>
|
||||
)}
|
||||
/>
|
||||
<Route path="/" component={SessionIndexRoute} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Router>
|
||||
</GlobalSyncProvider>
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
@@ -55,6 +54,47 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
type Action =
|
||||
| { type: "method.select"; index: number }
|
||||
| { type: "method.reset" }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||
| { type: "auth.error"; error: string }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
if (action.type === "method.select") {
|
||||
draft.methodIndex = action.index
|
||||
draft.authorization = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "method.reset") {
|
||||
draft.methodIndex = undefined
|
||||
draft.authorization = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.pending") {
|
||||
draft.state = "pending"
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.complete") {
|
||||
draft.state = "complete"
|
||||
draft.authorization = action.authorization
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
draft.state = "error"
|
||||
draft.error = action.error
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
||||
|
||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||
@@ -63,6 +103,24 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
return value.label ?? ""
|
||||
}
|
||||
|
||||
function formatError(value: unknown, fallback: string): string {
|
||||
if (value && typeof value === "object" && "data" in value) {
|
||||
const data = (value as { data?: { message?: unknown } }).data
|
||||
if (typeof data?.message === "string" && data.message) return data.message
|
||||
}
|
||||
if (value && typeof value === "object" && "error" in value) {
|
||||
const nested = formatError((value as { error?: unknown }).error, "")
|
||||
if (nested) return nested
|
||||
}
|
||||
if (value && typeof value === "object" && "message" in value) {
|
||||
const message = (value as { message?: unknown }).message
|
||||
if (typeof message === "string" && message) return message
|
||||
}
|
||||
if (value instanceof Error && value.message) return value.message
|
||||
if (typeof value === "string" && value) return value
|
||||
return fallback
|
||||
}
|
||||
|
||||
async function selectMethod(index: number) {
|
||||
if (timer.current !== undefined) {
|
||||
clearTimeout(timer.current)
|
||||
@@ -70,17 +128,10 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
}
|
||||
|
||||
const method = methods()[index]
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.methodIndex = index
|
||||
draft.authorization = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
}),
|
||||
)
|
||||
dispatch({ type: "method.select", index })
|
||||
|
||||
if (method.type === "oauth") {
|
||||
setStore("state", "pending")
|
||||
dispatch({ type: "auth.pending" })
|
||||
const start = Date.now()
|
||||
await globalSDK.client.provider.oauth
|
||||
.authorize(
|
||||
@@ -100,18 +151,15 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = undefined
|
||||
if (!alive.value) return
|
||||
setStore("state", "complete")
|
||||
setStore("authorization", x.data!)
|
||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||
}, delay)
|
||||
return
|
||||
}
|
||||
setStore("state", "complete")
|
||||
setStore("authorization", x.data!)
|
||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive.value) return
|
||||
setStore("state", "error")
|
||||
setStore("error", String(e))
|
||||
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -129,10 +177,6 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
if (methods().length === 1) {
|
||||
selectMethod(0)
|
||||
}
|
||||
document.addEventListener("keydown", handleKey)
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKey)
|
||||
})
|
||||
})
|
||||
|
||||
async function complete() {
|
||||
@@ -152,17 +196,243 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
return
|
||||
}
|
||||
if (store.authorization) {
|
||||
setStore("authorization", undefined)
|
||||
setStore("methodIndex", undefined)
|
||||
dispatch({ type: "method.reset" })
|
||||
return
|
||||
}
|
||||
if (store.methodIndex) {
|
||||
setStore("methodIndex", undefined)
|
||||
if (store.methodIndex !== undefined) {
|
||||
dispatch({ type: "method.reset" })
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}
|
||||
|
||||
function MethodSelection() {
|
||||
return (
|
||||
<>
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
||||
</div>
|
||||
<div>
|
||||
<List
|
||||
ref={(ref) => {
|
||||
listRef = ref
|
||||
}}
|
||||
items={methods}
|
||||
key={(m) => m?.label}
|
||||
onSelect={async (selected, index) => {
|
||||
if (!selected) return
|
||||
selectMethod(index)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-2">
|
||||
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
||||
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
||||
</div>
|
||||
<span>{methodLabel(i)}</span>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiAuthView() {
|
||||
const [formStore, setFormStore] = createStore({
|
||||
value: "",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
const form = e.currentTarget as HTMLFormElement
|
||||
const formData = new FormData(form)
|
||||
const apiKey = formData.get("apiKey") as string
|
||||
|
||||
if (!apiKey?.trim()) {
|
||||
setFormStore("error", language.t("provider.connect.apiKey.required"))
|
||||
return
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
await globalSDK.client.auth.set({
|
||||
providerID: props.provider,
|
||||
auth: {
|
||||
type: "api",
|
||||
key: apiKey,
|
||||
},
|
||||
})
|
||||
await complete()
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6">
|
||||
<Switch>
|
||||
<Match when={provider().id === "opencode"}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line1")}</div>
|
||||
<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.visit.prefix")}
|
||||
<Link href="https://opencode.ai/zen" tabIndex={-1}>
|
||||
{language.t("provider.connect.opencodeZen.visit.link")}
|
||||
</Link>
|
||||
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
type="text"
|
||||
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||
name="apiKey"
|
||||
value={formStore.value}
|
||||
onChange={(v) => setFormStore("value", v)}
|
||||
validationState={formStore.error ? "invalid" : undefined}
|
||||
error={formStore.error}
|
||||
/>
|
||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||
{language.t("common.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OAuthCodeView() {
|
||||
const [formStore, setFormStore] = createStore({
|
||||
value: "",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (store.authorization?.method === "code" && store.authorization?.url) {
|
||||
platform.openLink(store.authorization.url)
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
const form = e.currentTarget as HTMLFormElement
|
||||
const formData = new FormData(form)
|
||||
const code = formData.get("code") as string
|
||||
|
||||
if (!code?.trim()) {
|
||||
setFormStore("error", language.t("provider.connect.oauth.code.required"))
|
||||
return
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
const result = await globalSDK.client.provider.oauth
|
||||
.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
code,
|
||||
})
|
||||
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (result.ok) {
|
||||
await complete()
|
||||
return
|
||||
}
|
||||
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
|
||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
type="text"
|
||||
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
||||
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
||||
name="code"
|
||||
value={formStore.value}
|
||||
onChange={(v) => setFormStore("value", v)}
|
||||
validationState={formStore.error ? "invalid" : undefined}
|
||||
error={formStore.error}
|
||||
/>
|
||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||
{language.t("common.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OAuthAutoView() {
|
||||
const code = createMemo(() => {
|
||||
const instructions = store.authorization?.instructions
|
||||
if (instructions?.includes(":")) {
|
||||
return instructions.split(":")[1]?.trim()
|
||||
}
|
||||
return instructions
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
if (store.authorization?.url) {
|
||||
platform.openLink(store.authorization.url)
|
||||
}
|
||||
|
||||
const result = await globalSDK.client.provider.oauth
|
||||
.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
})
|
||||
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
|
||||
if (!alive.value) return
|
||||
|
||||
if (!result.ok) {
|
||||
const message = formatError(result.error, language.t("common.requestFailed"))
|
||||
dispatch({ type: "auth.error", error: message })
|
||||
return
|
||||
}
|
||||
|
||||
await complete()
|
||||
})()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
||||
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
|
||||
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
||||
</div>
|
||||
<TextField
|
||||
label={language.t("provider.connect.oauth.auto.confirmationCode")}
|
||||
class="font-mono"
|
||||
value={code()}
|
||||
readOnly
|
||||
copyable
|
||||
/>
|
||||
<div class="text-14-regular text-text-base flex items-center gap-4">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.waiting")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={
|
||||
@@ -188,267 +458,42 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||
<Switch>
|
||||
<Match when={store.methodIndex === undefined}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
||||
</div>
|
||||
<div class="">
|
||||
<List
|
||||
ref={(ref) => {
|
||||
listRef = ref
|
||||
}}
|
||||
items={methods}
|
||||
key={(m) => m?.label}
|
||||
onSelect={async (method, index) => {
|
||||
if (!method) return
|
||||
selectMethod(index)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-2">
|
||||
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
||||
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
||||
</div>
|
||||
<span>{methodLabel(i)}</span>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.state === "pending"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.state === "error"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={method()?.type === "api"}>
|
||||
{iife(() => {
|
||||
const [formStore, setFormStore] = createStore({
|
||||
value: "",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
const form = e.currentTarget as HTMLFormElement
|
||||
const formData = new FormData(form)
|
||||
const apiKey = formData.get("apiKey") as string
|
||||
|
||||
if (!apiKey?.trim()) {
|
||||
setFormStore("error", language.t("provider.connect.apiKey.required"))
|
||||
return
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
await globalSDK.client.auth.set({
|
||||
providerID: props.provider,
|
||||
auth: {
|
||||
type: "api",
|
||||
key: apiKey,
|
||||
},
|
||||
})
|
||||
await complete()
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6">
|
||||
<Switch>
|
||||
<Match when={provider().id === "opencode"}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.opencodeZen.line1")}
|
||||
</div>
|
||||
<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.visit.prefix")}
|
||||
<Link href="https://opencode.ai/zen" tabIndex={-1}>
|
||||
{language.t("provider.connect.opencodeZen.visit.link")}
|
||||
</Link>
|
||||
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
type="text"
|
||||
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||
name="apiKey"
|
||||
value={formStore.value}
|
||||
onChange={setFormStore.bind(null, "value")}
|
||||
validationState={formStore.error ? "invalid" : undefined}
|
||||
error={formStore.error}
|
||||
/>
|
||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||
{language.t("common.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||
<Switch>
|
||||
<Match when={store.methodIndex === undefined}>
|
||||
<MethodSelection />
|
||||
</Match>
|
||||
<Match when={store.state === "pending"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Match>
|
||||
<Match when={method()?.type === "oauth"}>
|
||||
<Switch>
|
||||
<Match when={store.authorization?.method === "code"}>
|
||||
{iife(() => {
|
||||
const [formStore, setFormStore] = createStore({
|
||||
value: "",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (store.authorization?.method === "code" && store.authorization?.url) {
|
||||
platform.openLink(store.authorization.url)
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
const form = e.currentTarget as HTMLFormElement
|
||||
const formData = new FormData(form)
|
||||
const code = formData.get("code") as string
|
||||
|
||||
if (!code?.trim()) {
|
||||
setFormStore("error", language.t("provider.connect.oauth.code.required"))
|
||||
return
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
const result = await globalSDK.client.provider.oauth
|
||||
.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
code,
|
||||
})
|
||||
.then((value) =>
|
||||
value.error ? { ok: false as const, error: value.error } : { ok: true as const },
|
||||
)
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (result.ok) {
|
||||
await complete()
|
||||
return
|
||||
}
|
||||
const message = result.error instanceof Error ? result.error.message : String(result.error)
|
||||
setFormStore("error", message || language.t("provider.connect.oauth.code.invalid"))
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||
<Link href={store.authorization!.url}>
|
||||
{language.t("provider.connect.oauth.code.visit.link")}
|
||||
</Link>
|
||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
type="text"
|
||||
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
||||
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
||||
name="code"
|
||||
value={formStore.value}
|
||||
onChange={setFormStore.bind(null, "value")}
|
||||
validationState={formStore.error ? "invalid" : undefined}
|
||||
error={formStore.error}
|
||||
/>
|
||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||
{language.t("common.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Match>
|
||||
<Match when={store.authorization?.method === "auto"}>
|
||||
{iife(() => {
|
||||
const code = createMemo(() => {
|
||||
const instructions = store.authorization?.instructions
|
||||
if (instructions?.includes(":")) {
|
||||
return instructions?.split(":")[1]?.trim()
|
||||
}
|
||||
return instructions
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
if (store.authorization?.url) {
|
||||
platform.openLink(store.authorization.url)
|
||||
}
|
||||
|
||||
const result = await globalSDK.client.provider.oauth
|
||||
.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
})
|
||||
.then((value) =>
|
||||
value.error ? { ok: false as const, error: value.error } : { ok: true as const },
|
||||
)
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
|
||||
if (!alive.value) return
|
||||
|
||||
if (!result.ok) {
|
||||
const message = result.error instanceof Error ? result.error.message : String(result.error)
|
||||
setStore("state", "error")
|
||||
setStore("error", message)
|
||||
return
|
||||
}
|
||||
|
||||
await complete()
|
||||
})()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
||||
<Link href={store.authorization!.url}>
|
||||
{language.t("provider.connect.oauth.auto.visit.link")}
|
||||
</Link>
|
||||
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
||||
</div>
|
||||
<TextField
|
||||
label={language.t("provider.connect.oauth.auto.confirmationCode")}
|
||||
class="font-mono"
|
||||
value={code()}
|
||||
readOnly
|
||||
copyable
|
||||
/>
|
||||
<div class="text-14-regular text-text-base flex items-center gap-4">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.waiting")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.state === "error"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={method()?.type === "api"}>
|
||||
<ApiAuthView />
|
||||
</Match>
|
||||
<Match when={method()?.type === "oauth"}>
|
||||
<Switch>
|
||||
<Match when={store.authorization?.method === "code"}>
|
||||
<OAuthCodeView />
|
||||
</Match>
|
||||
<Match when={store.authorization?.method === "auto"}>
|
||||
<OAuthAutoView />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { For } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
@@ -16,6 +16,147 @@ import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
||||
|
||||
type Translator = ReturnType<typeof useLanguage>["t"]
|
||||
|
||||
type ModelRow = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type HeaderRow = {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
providerID: string
|
||||
name: string
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
models: ModelRow[]
|
||||
headers: HeaderRow[]
|
||||
saving: boolean
|
||||
}
|
||||
|
||||
type FormErrors = {
|
||||
providerID: string | undefined
|
||||
name: string | undefined
|
||||
baseURL: string | undefined
|
||||
models: Array<{ id?: string; name?: string }>
|
||||
headers: Array<{ key?: string; value?: string }>
|
||||
}
|
||||
|
||||
type ValidateArgs = {
|
||||
form: FormState
|
||||
t: Translator
|
||||
disabledProviders: string[]
|
||||
existingProviderIDs: Set<string>
|
||||
}
|
||||
|
||||
function validateCustomProvider(input: ValidateArgs) {
|
||||
const providerID = input.form.providerID.trim()
|
||||
const name = input.form.name.trim()
|
||||
const baseURL = input.form.baseURL.trim()
|
||||
const apiKey = input.form.apiKey.trim()
|
||||
|
||||
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
||||
const key = apiKey && !env ? apiKey : undefined
|
||||
|
||||
const idError = !providerID
|
||||
? input.t("provider.custom.error.providerID.required")
|
||||
: !PROVIDER_ID.test(providerID)
|
||||
? input.t("provider.custom.error.providerID.format")
|
||||
: undefined
|
||||
|
||||
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
||||
const urlError = !baseURL
|
||||
? input.t("provider.custom.error.baseURL.required")
|
||||
: !/^https?:\/\//.test(baseURL)
|
||||
? input.t("provider.custom.error.baseURL.format")
|
||||
: undefined
|
||||
|
||||
const disabled = input.disabledProviders.includes(providerID)
|
||||
const existsError = idError
|
||||
? undefined
|
||||
: input.existingProviderIDs.has(providerID) && !disabled
|
||||
? input.t("provider.custom.error.providerID.exists")
|
||||
: undefined
|
||||
|
||||
const seenModels = new Set<string>()
|
||||
const modelErrors = input.form.models.map((m) => {
|
||||
const id = m.id.trim()
|
||||
const modelIdError = !id
|
||||
? input.t("provider.custom.error.required")
|
||||
: seenModels.has(id)
|
||||
? input.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenModels.add(id)
|
||||
return undefined
|
||||
})()
|
||||
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
||||
return { id: modelIdError, name: modelNameError }
|
||||
})
|
||||
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
||||
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
||||
|
||||
const seenHeaders = new Set<string>()
|
||||
const headerErrors = input.form.headers.map((h) => {
|
||||
const key = h.key.trim()
|
||||
const value = h.value.trim()
|
||||
|
||||
if (!key && !value) return {}
|
||||
const keyError = !key
|
||||
? input.t("provider.custom.error.required")
|
||||
: seenHeaders.has(key.toLowerCase())
|
||||
? input.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenHeaders.add(key.toLowerCase())
|
||||
return undefined
|
||||
})()
|
||||
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
||||
return { key: keyError, value: valueError }
|
||||
})
|
||||
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
||||
const headers = Object.fromEntries(
|
||||
input.form.headers
|
||||
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
||||
.filter((h) => !!h.key && !!h.value)
|
||||
.map((h) => [h.key, h.value]),
|
||||
)
|
||||
|
||||
const errors: FormErrors = {
|
||||
providerID: idError ?? existsError,
|
||||
name: nameError,
|
||||
baseURL: urlError,
|
||||
models: modelErrors,
|
||||
headers: headerErrors,
|
||||
}
|
||||
|
||||
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
||||
if (!ok) return { errors }
|
||||
|
||||
const options = {
|
||||
baseURL,
|
||||
...(Object.keys(headers).length ? { headers } : {}),
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
result: {
|
||||
providerID,
|
||||
name,
|
||||
key,
|
||||
config: {
|
||||
npm: OPENAI_COMPATIBLE,
|
||||
name,
|
||||
...(env ? { env: [env] } : {}),
|
||||
options,
|
||||
models,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
back?: "providers" | "close"
|
||||
}
|
||||
@@ -26,7 +167,7 @@ export function DialogCustomProvider(props: Props) {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
const [form, setForm] = createStore({
|
||||
const [form, setForm] = createStore<FormState>({
|
||||
providerID: "",
|
||||
name: "",
|
||||
baseURL: "",
|
||||
@@ -36,12 +177,12 @@ export function DialogCustomProvider(props: Props) {
|
||||
saving: false,
|
||||
})
|
||||
|
||||
const [errors, setErrors] = createStore({
|
||||
providerID: undefined as string | undefined,
|
||||
name: undefined as string | undefined,
|
||||
baseURL: undefined as string | undefined,
|
||||
models: [{} as { id?: string; name?: string }],
|
||||
headers: [{} as { key?: string; value?: string }],
|
||||
const [errors, setErrors] = createStore<FormErrors>({
|
||||
providerID: undefined,
|
||||
name: undefined,
|
||||
baseURL: undefined,
|
||||
models: [{}],
|
||||
headers: [{}],
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
@@ -53,169 +194,36 @@ export function DialogCustomProvider(props: Props) {
|
||||
}
|
||||
|
||||
const addModel = () => {
|
||||
setForm(
|
||||
"models",
|
||||
produce((draft) => {
|
||||
draft.push({ id: "", name: "" })
|
||||
}),
|
||||
)
|
||||
setErrors(
|
||||
"models",
|
||||
produce((draft) => {
|
||||
draft.push({})
|
||||
}),
|
||||
)
|
||||
setForm("models", (v) => [...v, { id: "", name: "" }])
|
||||
setErrors("models", (v) => [...v, {}])
|
||||
}
|
||||
|
||||
const removeModel = (index: number) => {
|
||||
if (form.models.length <= 1) return
|
||||
setForm(
|
||||
"models",
|
||||
produce((draft) => {
|
||||
draft.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
setErrors(
|
||||
"models",
|
||||
produce((draft) => {
|
||||
draft.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
setForm("models", (v) => v.filter((_, i) => i !== index))
|
||||
setErrors("models", (v) => v.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const addHeader = () => {
|
||||
setForm(
|
||||
"headers",
|
||||
produce((draft) => {
|
||||
draft.push({ key: "", value: "" })
|
||||
}),
|
||||
)
|
||||
setErrors(
|
||||
"headers",
|
||||
produce((draft) => {
|
||||
draft.push({})
|
||||
}),
|
||||
)
|
||||
setForm("headers", (v) => [...v, { key: "", value: "" }])
|
||||
setErrors("headers", (v) => [...v, {}])
|
||||
}
|
||||
|
||||
const removeHeader = (index: number) => {
|
||||
if (form.headers.length <= 1) return
|
||||
setForm(
|
||||
"headers",
|
||||
produce((draft) => {
|
||||
draft.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
setErrors(
|
||||
"headers",
|
||||
produce((draft) => {
|
||||
draft.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
setForm("headers", (v) => v.filter((_, i) => i !== index))
|
||||
setErrors("headers", (v) => v.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
const providerID = form.providerID.trim()
|
||||
const name = form.name.trim()
|
||||
const baseURL = form.baseURL.trim()
|
||||
const apiKey = form.apiKey.trim()
|
||||
|
||||
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
||||
const key = apiKey && !env ? apiKey : undefined
|
||||
|
||||
const idError = !providerID
|
||||
? language.t("provider.custom.error.providerID.required")
|
||||
: !PROVIDER_ID.test(providerID)
|
||||
? language.t("provider.custom.error.providerID.format")
|
||||
: undefined
|
||||
|
||||
const nameError = !name ? language.t("provider.custom.error.name.required") : undefined
|
||||
const urlError = !baseURL
|
||||
? language.t("provider.custom.error.baseURL.required")
|
||||
: !/^https?:\/\//.test(baseURL)
|
||||
? language.t("provider.custom.error.baseURL.format")
|
||||
: undefined
|
||||
|
||||
const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
|
||||
const existingProvider = globalSync.data.provider.all.find((p) => p.id === providerID)
|
||||
const existsError = idError
|
||||
? undefined
|
||||
: existingProvider && !disabled
|
||||
? language.t("provider.custom.error.providerID.exists")
|
||||
: undefined
|
||||
|
||||
const seenModels = new Set<string>()
|
||||
const modelErrors = form.models.map((m) => {
|
||||
const id = m.id.trim()
|
||||
const modelIdError = !id
|
||||
? language.t("provider.custom.error.required")
|
||||
: seenModels.has(id)
|
||||
? language.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenModels.add(id)
|
||||
return undefined
|
||||
})()
|
||||
const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined
|
||||
return { id: modelIdError, name: modelNameError }
|
||||
const output = validateCustomProvider({
|
||||
form,
|
||||
t: language.t,
|
||||
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
||||
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
|
||||
})
|
||||
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
||||
const models = Object.fromEntries(form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
||||
|
||||
const seenHeaders = new Set<string>()
|
||||
const headerErrors = form.headers.map((h) => {
|
||||
const key = h.key.trim()
|
||||
const value = h.value.trim()
|
||||
|
||||
if (!key && !value) return {}
|
||||
const keyError = !key
|
||||
? language.t("provider.custom.error.required")
|
||||
: seenHeaders.has(key.toLowerCase())
|
||||
? language.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenHeaders.add(key.toLowerCase())
|
||||
return undefined
|
||||
})()
|
||||
const valueError = !value ? language.t("provider.custom.error.required") : undefined
|
||||
return { key: keyError, value: valueError }
|
||||
})
|
||||
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
||||
const headers = Object.fromEntries(
|
||||
form.headers
|
||||
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
||||
.filter((h) => !!h.key && !!h.value)
|
||||
.map((h) => [h.key, h.value]),
|
||||
)
|
||||
|
||||
setErrors(
|
||||
produce((draft) => {
|
||||
draft.providerID = idError ?? existsError
|
||||
draft.name = nameError
|
||||
draft.baseURL = urlError
|
||||
draft.models = modelErrors
|
||||
draft.headers = headerErrors
|
||||
}),
|
||||
)
|
||||
|
||||
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
||||
if (!ok) return
|
||||
|
||||
const options = {
|
||||
baseURL,
|
||||
...(Object.keys(headers).length ? { headers } : {}),
|
||||
}
|
||||
|
||||
return {
|
||||
providerID,
|
||||
name,
|
||||
key,
|
||||
config: {
|
||||
npm: OPENAI_COMPATIBLE,
|
||||
name,
|
||||
...(env ? { env: [env] } : {}),
|
||||
options,
|
||||
models,
|
||||
},
|
||||
}
|
||||
setErrors(output.errors)
|
||||
return output.result
|
||||
}
|
||||
|
||||
const save = async (e: SubmitEvent) => {
|
||||
@@ -297,7 +305,7 @@ export function DialogCustomProvider(props: Props) {
|
||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||
description={language.t("provider.custom.field.providerID.description")}
|
||||
value={form.providerID}
|
||||
onChange={setForm.bind(null, "providerID")}
|
||||
onChange={(v) => setForm("providerID", v)}
|
||||
validationState={errors.providerID ? "invalid" : undefined}
|
||||
error={errors.providerID}
|
||||
/>
|
||||
@@ -305,7 +313,7 @@ export function DialogCustomProvider(props: Props) {
|
||||
label={language.t("provider.custom.field.name.label")}
|
||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||
value={form.name}
|
||||
onChange={setForm.bind(null, "name")}
|
||||
onChange={(v) => setForm("name", v)}
|
||||
validationState={errors.name ? "invalid" : undefined}
|
||||
error={errors.name}
|
||||
/>
|
||||
@@ -313,7 +321,7 @@ export function DialogCustomProvider(props: Props) {
|
||||
label={language.t("provider.custom.field.baseURL.label")}
|
||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||
value={form.baseURL}
|
||||
onChange={setForm.bind(null, "baseURL")}
|
||||
onChange={(v) => setForm("baseURL", v)}
|
||||
validationState={errors.baseURL ? "invalid" : undefined}
|
||||
error={errors.baseURL}
|
||||
/>
|
||||
@@ -322,7 +330,7 @@ export function DialogCustomProvider(props: Props) {
|
||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||
description={language.t("provider.custom.field.apiKey.description")}
|
||||
value={form.apiKey}
|
||||
onChange={setForm.bind(null, "apiKey")}
|
||||
onChange={(v) => setForm("apiKey", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
iconHover: false,
|
||||
})
|
||||
|
||||
let iconInput: HTMLInputElement | undefined
|
||||
|
||||
function handleFileSelect(file: File) {
|
||||
if (!file.type.startsWith("image/")) return
|
||||
const reader = new FileReader()
|
||||
@@ -72,31 +74,35 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
setStore("saving", true)
|
||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||
const start = store.startup.trim()
|
||||
await Promise.resolve()
|
||||
.then(async () => {
|
||||
setStore("saving", true)
|
||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await globalSDK.client.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
icon: { color: store.color, override: store.iconUrl },
|
||||
commands: { start },
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await globalSDK.client.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
icon: { color: store.color, override: store.iconUrl },
|
||||
commands: { start },
|
||||
})
|
||||
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
|
||||
globalSync.project.meta(props.project.worktree, {
|
||||
name,
|
||||
icon: { color: store.color, override: store.iconUrl || undefined },
|
||||
commands: { start: start || undefined },
|
||||
})
|
||||
dialog.close()
|
||||
})
|
||||
.finally(() => {
|
||||
setStore("saving", false)
|
||||
})
|
||||
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
||||
setStore("saving", false)
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
|
||||
globalSync.project.meta(props.project.worktree, {
|
||||
name,
|
||||
icon: { color: store.color, override: store.iconUrl || undefined },
|
||||
commands: { start: start || undefined },
|
||||
})
|
||||
setStore("saving", false)
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -134,7 +140,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
if (store.iconUrl && store.iconHover) {
|
||||
clearIcon()
|
||||
} else {
|
||||
document.getElementById("icon-upload")?.click()
|
||||
iconInput?.click()
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -176,7 +182,16 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
|
||||
<input
|
||||
id="icon-upload"
|
||||
ref={(el) => {
|
||||
iconInput = el
|
||||
}}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { usePrompt } from "@/context/prompt"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
@@ -66,15 +67,23 @@ export const DialogFork: Component = () => {
|
||||
attachmentName: language.t("common.attachment"),
|
||||
})
|
||||
|
||||
dialog.close()
|
||||
|
||||
sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => {
|
||||
if (!forked.data) return
|
||||
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
|
||||
requestAnimationFrame(() => {
|
||||
prompt.set(restored)
|
||||
sdk.client.session
|
||||
.fork({ sessionID, messageID: item.id })
|
||||
.then((forked) => {
|
||||
if (!forked.data) {
|
||||
showToast({ title: language.t("common.requestFailed") })
|
||||
return
|
||||
}
|
||||
dialog.close()
|
||||
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
|
||||
requestAnimationFrame(() => {
|
||||
prompt.set(restored)
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import type { Component } from "solid-js"
|
||||
import { useLocal } from "@/context/local"
|
||||
@@ -17,6 +18,15 @@ export const DialogManageModels: Component = () => {
|
||||
const handleConnectProvider = () => {
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}
|
||||
const providerRank = (id: string) => popularProviders.indexOf(id)
|
||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||
const providerVisible = (providerID: string) =>
|
||||
providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
|
||||
const setProviderVisibility = (providerID: string, checked: boolean) => {
|
||||
providerList(providerID).forEach((x) => {
|
||||
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -35,21 +45,41 @@ export const DialogManageModels: Component = () => {
|
||||
items={local.model.list()}
|
||||
filterKeys={["provider.name", "name", "id"]}
|
||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||
groupBy={(x) => x.provider.name}
|
||||
groupBy={(x) => x.provider.id}
|
||||
groupHeader={(group) => {
|
||||
const provider = group.items[0].provider
|
||||
return (
|
||||
<>
|
||||
<span>{provider.name}</span>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={language.t("dialog.model.manage.provider.toggle", { provider: provider.name })}
|
||||
>
|
||||
<Switch
|
||||
class="-mr-1"
|
||||
checked={providerVisible(provider.id)}
|
||||
onChange={(checked) => setProviderVisibility(provider.id, checked)}
|
||||
hideLabel
|
||||
>
|
||||
{provider.name}
|
||||
</Switch>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
sortGroupsBy={(a, b) => {
|
||||
const aProvider = a.items[0].provider.id
|
||||
const bProvider = b.items[0].provider.id
|
||||
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
|
||||
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
|
||||
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
|
||||
const aRank = providerRank(a.items[0].provider.id)
|
||||
const bRank = providerRank(b.items[0].provider.id)
|
||||
const aPopular = aRank >= 0
|
||||
const bPopular = bRank >= 0
|
||||
if (aPopular && !bPopular) return -1
|
||||
if (!aPopular && bPopular) return 1
|
||||
return aRank - bRank
|
||||
}}
|
||||
onSelect={(x) => {
|
||||
if (!x) return
|
||||
const visible = local.model.visible({
|
||||
modelID: x.id,
|
||||
providerID: x.provider.id,
|
||||
})
|
||||
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
|
||||
const key = { modelID: x.id, providerID: x.provider.id }
|
||||
local.model.setVisibility(key, !local.model.visible(key))
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
@@ -57,12 +87,7 @@ export const DialogManageModels: Component = () => {
|
||||
<span>{i.name}</span>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={
|
||||
!!local.model.visible({
|
||||
modelID: i.id,
|
||||
providerID: i.provider.id,
|
||||
})
|
||||
}
|
||||
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
|
||||
onChange={(checked) => {
|
||||
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, createEffect, onMount, onCleanup } from "solid-js"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -40,8 +40,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
||||
handleClose()
|
||||
}
|
||||
|
||||
let focusTrap: HTMLDivElement | undefined
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
@@ -60,27 +58,13 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
focusTrap?.focus()
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
||||
})
|
||||
|
||||
// Refocus the trap when index changes to ensure escape always works
|
||||
createEffect(() => {
|
||||
index() // track index
|
||||
focusTrap?.focus()
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
size="large"
|
||||
fit
|
||||
class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden"
|
||||
>
|
||||
{/* Hidden element to capture initial focus and handle escape */}
|
||||
<div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" />
|
||||
<div class="flex flex-1 min-w-0 min-h-0">
|
||||
<div class="flex flex-1 min-w-0 min-h-0" tabIndex={0} autofocus onKeyDown={handleKeyDown}>
|
||||
{/* Left side - Text content */}
|
||||
<div class="flex flex-col flex-1 min-w-0 p-8">
|
||||
{/* Top section - feature content (fixed position from top) */}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import type { ListRef } from "@opencode-ai/ui/list"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { createMemo, createResource, createSignal } from "solid-js"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { ListRef } from "@opencode-ai/ui/list"
|
||||
|
||||
interface DialogSelectDirectoryProps {
|
||||
title?: string
|
||||
@@ -21,157 +21,131 @@ type Row = {
|
||||
search: string
|
||||
}
|
||||
|
||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const sync = useGlobalSync()
|
||||
const sdk = useGlobalSDK()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
function cleanInput(value: string) {
|
||||
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
||||
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
||||
}
|
||||
|
||||
const [filter, setFilter] = createSignal("")
|
||||
function normalizePath(input: string) {
|
||||
const v = input.replaceAll("\\", "/")
|
||||
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
||||
return v.replace(/\/+/g, "/")
|
||||
}
|
||||
|
||||
let list: ListRef | undefined
|
||||
function normalizeDriveRoot(input: string) {
|
||||
const v = normalizePath(input)
|
||||
if (/^[A-Za-z]:$/.test(v)) return v + "/"
|
||||
return v
|
||||
}
|
||||
|
||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||
function trimTrailing(input: string) {
|
||||
const v = normalizeDriveRoot(input)
|
||||
if (v === "/") return v
|
||||
if (v === "//") return v
|
||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||
return v.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
async () => {
|
||||
return sdk.client.path
|
||||
.get()
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
},
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
function joinPath(base: string | undefined, rel: string) {
|
||||
const b = trimTrailing(base ?? "")
|
||||
const r = trimTrailing(rel).replace(/^\/+/, "")
|
||||
if (!b) return r
|
||||
if (!r) return b
|
||||
if (b.endsWith("/")) return b + r
|
||||
return b + "/" + r
|
||||
}
|
||||
|
||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||
function rootOf(input: string) {
|
||||
const v = normalizeDriveRoot(input)
|
||||
if (v.startsWith("//")) return "//"
|
||||
if (v.startsWith("/")) return "/"
|
||||
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
|
||||
return ""
|
||||
}
|
||||
|
||||
const start = createMemo(
|
||||
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
||||
)
|
||||
function parentOf(input: string) {
|
||||
const v = trimTrailing(input)
|
||||
if (v === "/") return v
|
||||
if (v === "//") return v
|
||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||
|
||||
const i = v.lastIndexOf("/")
|
||||
if (i <= 0) return "/"
|
||||
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
|
||||
return v.slice(0, i)
|
||||
}
|
||||
|
||||
function modeOf(input: string) {
|
||||
const raw = normalizeDriveRoot(input.trim())
|
||||
if (!raw) return "relative" as const
|
||||
if (raw.startsWith("~")) return "tilde" as const
|
||||
if (rootOf(raw)) return "absolute" as const
|
||||
return "relative" as const
|
||||
}
|
||||
|
||||
function tildeOf(absolute: string, home: string) {
|
||||
const full = trimTrailing(absolute)
|
||||
if (!home) return ""
|
||||
|
||||
const hn = trimTrailing(home)
|
||||
const lc = full.toLowerCase()
|
||||
const hc = hn.toLowerCase()
|
||||
if (lc === hc) return "~"
|
||||
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
|
||||
return ""
|
||||
}
|
||||
|
||||
function displayPath(path: string, input: string, home: string) {
|
||||
const full = trimTrailing(path)
|
||||
if (modeOf(input) === "absolute") return full
|
||||
return tildeOf(full, home) || full
|
||||
}
|
||||
|
||||
function toRow(absolute: string, home: string): Row {
|
||||
const full = trimTrailing(absolute)
|
||||
const tilde = tildeOf(full, home)
|
||||
const withSlash = (value: string) => {
|
||||
if (!value) return ""
|
||||
if (value.endsWith("/")) return value
|
||||
return value + "/"
|
||||
}
|
||||
|
||||
const search = Array.from(
|
||||
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
|
||||
).join("\n")
|
||||
return { absolute: full, search }
|
||||
}
|
||||
|
||||
function useDirectorySearch(args: {
|
||||
sdk: ReturnType<typeof useGlobalSDK>
|
||||
start: () => string | undefined
|
||||
home: () => string
|
||||
}) {
|
||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||
let current = 0
|
||||
|
||||
const clean = (value: string) => {
|
||||
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
||||
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
||||
}
|
||||
|
||||
function normalize(input: string) {
|
||||
const v = input.replaceAll("\\", "/")
|
||||
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
||||
return v.replace(/\/+/g, "/")
|
||||
}
|
||||
|
||||
function normalizeDriveRoot(input: string) {
|
||||
const v = normalize(input)
|
||||
if (/^[A-Za-z]:$/.test(v)) return v + "/"
|
||||
return v
|
||||
}
|
||||
|
||||
function trimTrailing(input: string) {
|
||||
const v = normalizeDriveRoot(input)
|
||||
if (v === "/") return v
|
||||
if (v === "//") return v
|
||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||
return v.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function join(base: string | undefined, rel: string) {
|
||||
const b = trimTrailing(base ?? "")
|
||||
const r = trimTrailing(rel).replace(/^\/+/, "")
|
||||
if (!b) return r
|
||||
if (!r) return b
|
||||
if (b.endsWith("/")) return b + r
|
||||
return b + "/" + r
|
||||
}
|
||||
|
||||
function rootOf(input: string) {
|
||||
const v = normalizeDriveRoot(input)
|
||||
if (v.startsWith("//")) return "//"
|
||||
if (v.startsWith("/")) return "/"
|
||||
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
|
||||
return ""
|
||||
}
|
||||
|
||||
function parentOf(input: string) {
|
||||
const v = trimTrailing(input)
|
||||
if (v === "/") return v
|
||||
if (v === "//") return v
|
||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||
|
||||
const i = v.lastIndexOf("/")
|
||||
if (i <= 0) return "/"
|
||||
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
|
||||
return v.slice(0, i)
|
||||
}
|
||||
|
||||
function modeOf(input: string) {
|
||||
const raw = normalizeDriveRoot(input.trim())
|
||||
if (!raw) return "relative" as const
|
||||
if (raw.startsWith("~")) return "tilde" as const
|
||||
if (rootOf(raw)) return "absolute" as const
|
||||
return "relative" as const
|
||||
}
|
||||
|
||||
function display(path: string, input: string) {
|
||||
const full = trimTrailing(path)
|
||||
if (modeOf(input) === "absolute") return full
|
||||
|
||||
return tildeOf(full) || full
|
||||
}
|
||||
|
||||
function tildeOf(absolute: string) {
|
||||
const full = trimTrailing(absolute)
|
||||
const h = home()
|
||||
if (!h) return ""
|
||||
|
||||
const hn = trimTrailing(h)
|
||||
const lc = full.toLowerCase()
|
||||
const hc = hn.toLowerCase()
|
||||
if (lc === hc) return "~"
|
||||
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
|
||||
return ""
|
||||
}
|
||||
|
||||
function row(absolute: string): Row {
|
||||
const full = trimTrailing(absolute)
|
||||
const tilde = tildeOf(full)
|
||||
|
||||
const withSlash = (value: string) => {
|
||||
if (!value) return ""
|
||||
if (value.endsWith("/")) return value
|
||||
return value + "/"
|
||||
}
|
||||
|
||||
const search = Array.from(
|
||||
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
|
||||
).join("\n")
|
||||
return { absolute: full, search }
|
||||
}
|
||||
|
||||
function scoped(value: string) {
|
||||
const base = start()
|
||||
const scoped = (value: string) => {
|
||||
const base = args.start()
|
||||
if (!base) return
|
||||
|
||||
const raw = normalizeDriveRoot(value)
|
||||
if (!raw) return { directory: trimTrailing(base), path: "" }
|
||||
|
||||
const h = home()
|
||||
if (raw === "~") return { directory: trimTrailing(h ?? base), path: "" }
|
||||
if (raw.startsWith("~/")) return { directory: trimTrailing(h ?? base), path: raw.slice(2) }
|
||||
const h = args.home()
|
||||
if (raw === "~") return { directory: trimTrailing(h || base), path: "" }
|
||||
if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) }
|
||||
|
||||
const root = rootOf(raw)
|
||||
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
|
||||
return { directory: trimTrailing(base), path: raw }
|
||||
}
|
||||
|
||||
async function dirs(dir: string) {
|
||||
const dirs = async (dir: string) => {
|
||||
const key = trimTrailing(dir)
|
||||
const existing = cache.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const request = sdk.client.file
|
||||
const request = args.sdk.client.file
|
||||
.list({ directory: key, path: "" })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
@@ -188,32 +162,34 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
return request
|
||||
}
|
||||
|
||||
async function match(dir: string, query: string, limit: number) {
|
||||
const match = async (dir: string, query: string, limit: number) => {
|
||||
const items = await dirs(dir)
|
||||
if (!query) return items.slice(0, limit).map((x) => x.absolute)
|
||||
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
|
||||
}
|
||||
|
||||
const directories = async (filter: string) => {
|
||||
const value = clean(filter)
|
||||
return async (filter: string) => {
|
||||
const token = ++current
|
||||
const active = () => token === current
|
||||
|
||||
const value = cleanInput(filter)
|
||||
const scopedInput = scoped(value)
|
||||
if (!scopedInput) return [] as string[]
|
||||
|
||||
const raw = normalizeDriveRoot(value)
|
||||
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
|
||||
|
||||
const query = normalizeDriveRoot(scopedInput.path)
|
||||
|
||||
const find = () =>
|
||||
sdk.client.find
|
||||
args.sdk.client.find
|
||||
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
|
||||
if (!isPath) {
|
||||
const results = await find()
|
||||
|
||||
return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
|
||||
if (!active()) return []
|
||||
return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50)
|
||||
}
|
||||
|
||||
const segments = query.replace(/^\/+/, "").split("/")
|
||||
@@ -224,17 +200,20 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const branch = 4
|
||||
let paths = [scopedInput.directory]
|
||||
for (const part of head) {
|
||||
if (!active()) return []
|
||||
if (part === "..") {
|
||||
paths = paths.map(parentOf)
|
||||
continue
|
||||
}
|
||||
|
||||
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
|
||||
if (!active()) return []
|
||||
paths = Array.from(new Set(next)).slice(0, cap)
|
||||
if (paths.length === 0) return [] as string[]
|
||||
}
|
||||
|
||||
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
|
||||
if (!active()) return []
|
||||
const deduped = Array.from(new Set(out))
|
||||
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
|
||||
const expand = !raw.endsWith("/")
|
||||
@@ -249,13 +228,47 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
if (!target) return deduped.slice(0, 50)
|
||||
|
||||
const children = await match(target, "", 30)
|
||||
if (!active()) return []
|
||||
const items = Array.from(new Set([...deduped, ...children]))
|
||||
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
|
||||
}
|
||||
}
|
||||
|
||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const sync = useGlobalSync()
|
||||
const sdk = useGlobalSDK()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
const [filter, setFilter] = createSignal("")
|
||||
let list: ListRef | undefined
|
||||
|
||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
async () => {
|
||||
return sdk.client.path
|
||||
.get()
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
},
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
|
||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||
const start = createMemo(
|
||||
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
||||
)
|
||||
|
||||
const directories = useDirectorySearch({
|
||||
sdk,
|
||||
home,
|
||||
start,
|
||||
})
|
||||
|
||||
const items = async (value: string) => {
|
||||
const results = await directories(value)
|
||||
return results.map(row)
|
||||
return results.map((absolute) => toRow(absolute, home()))
|
||||
}
|
||||
|
||||
function resolve(absolute: string) {
|
||||
@@ -273,7 +286,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
key={(x) => x.absolute}
|
||||
filterKeys={["search"]}
|
||||
ref={(r) => (list = r)}
|
||||
onFilter={(value) => setFilter(clean(value))}
|
||||
onFilter={(value) => setFilter(cleanInput(value))}
|
||||
onKeyEvent={(e, item) => {
|
||||
if (e.key !== "Tab") return
|
||||
if (e.shiftKey) return
|
||||
@@ -282,7 +295,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const value = display(item.absolute, filter())
|
||||
const value = displayPath(item.absolute, filter(), home())
|
||||
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
||||
}}
|
||||
onSelect={(path) => {
|
||||
@@ -291,7 +304,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
}}
|
||||
>
|
||||
{(item) => {
|
||||
const path = display(item.absolute, filter())
|
||||
const path = displayPath(item.absolute, filter(), home())
|
||||
if (path === "~") {
|
||||
return (
|
||||
<div class="w-full flex items-center justify-between rounded-md">
|
||||
|
||||
@@ -36,6 +36,223 @@ type Entry = {
|
||||
|
||||
type DialogSelectFileMode = "all" | "files"
|
||||
|
||||
const ENTRY_LIMIT = 5
|
||||
const COMMON_COMMAND_IDS = [
|
||||
"session.new",
|
||||
"workspace.new",
|
||||
"session.previous",
|
||||
"session.next",
|
||||
"terminal.toggle",
|
||||
"review.toggle",
|
||||
] as const
|
||||
|
||||
const uniqueEntries = (items: Entry[]) => {
|
||||
const seen = new Set<string>()
|
||||
const out: Entry[] = []
|
||||
for (const item of items) {
|
||||
if (seen.has(item.id)) continue
|
||||
seen.add(item.id)
|
||||
out.push(item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const createCommandEntry = (option: CommandOption, category: string): Entry => ({
|
||||
id: "command:" + option.id,
|
||||
type: "command",
|
||||
title: option.title,
|
||||
description: option.description,
|
||||
keybind: option.keybind,
|
||||
category,
|
||||
option,
|
||||
})
|
||||
|
||||
const createFileEntry = (path: string, category: string): Entry => ({
|
||||
id: "file:" + path,
|
||||
type: "file",
|
||||
title: path,
|
||||
category,
|
||||
path,
|
||||
})
|
||||
|
||||
const createSessionEntry = (
|
||||
input: {
|
||||
directory: string
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
archived?: number
|
||||
updated?: number
|
||||
},
|
||||
category: string,
|
||||
): Entry => ({
|
||||
id: `session:${input.directory}:${input.id}`,
|
||||
type: "session",
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
category,
|
||||
directory: input.directory,
|
||||
sessionID: input.id,
|
||||
archived: input.archived,
|
||||
updated: input.updated,
|
||||
})
|
||||
|
||||
function createCommandEntries(props: {
|
||||
filesOnly: () => boolean
|
||||
command: ReturnType<typeof useCommand>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const allowed = createMemo(() => {
|
||||
if (props.filesOnly()) return []
|
||||
return props.command.options.filter(
|
||||
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
||||
)
|
||||
})
|
||||
|
||||
const list = createMemo(() => {
|
||||
const category = props.language.t("palette.group.commands")
|
||||
return allowed().map((option) => createCommandEntry(option, category))
|
||||
})
|
||||
|
||||
const picks = createMemo(() => {
|
||||
const all = allowed()
|
||||
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
|
||||
const picked = all.filter((option) => order.has(option.id))
|
||||
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
||||
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
||||
const category = props.language.t("palette.group.commands")
|
||||
return sorted.map((option) => createCommandEntry(option, category))
|
||||
})
|
||||
|
||||
return { allowed, list, picks }
|
||||
}
|
||||
|
||||
function createFileEntries(props: {
|
||||
file: ReturnType<typeof useFile>
|
||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const recent = createMemo(() => {
|
||||
const all = props.tabs().all()
|
||||
const active = props.tabs().active()
|
||||
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
||||
const seen = new Set<string>()
|
||||
const category = props.language.t("palette.group.files")
|
||||
const items: Entry[] = []
|
||||
|
||||
for (const item of order) {
|
||||
const path = props.file.pathFromTab(item)
|
||||
if (!path) continue
|
||||
if (seen.has(path)) continue
|
||||
seen.add(path)
|
||||
items.push(createFileEntry(path, category))
|
||||
}
|
||||
|
||||
return items.slice(0, ENTRY_LIMIT)
|
||||
})
|
||||
|
||||
const root = createMemo(() => {
|
||||
const category = props.language.t("palette.group.files")
|
||||
const nodes = props.file.tree.children("")
|
||||
const paths = nodes
|
||||
.filter((node) => node.type === "file")
|
||||
.map((node) => node.path)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
|
||||
})
|
||||
|
||||
return { recent, root }
|
||||
}
|
||||
|
||||
function createSessionEntries(props: {
|
||||
workspaces: () => string[]
|
||||
label: (directory: string) => string
|
||||
globalSDK: ReturnType<typeof useGlobalSDK>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const state: {
|
||||
token: number
|
||||
inflight: Promise<Entry[]> | undefined
|
||||
cached: Entry[] | undefined
|
||||
} = {
|
||||
token: 0,
|
||||
inflight: undefined,
|
||||
cached: undefined,
|
||||
}
|
||||
|
||||
const sessions = (text: string) => {
|
||||
const query = text.trim()
|
||||
if (!query) {
|
||||
state.token += 1
|
||||
state.inflight = undefined
|
||||
state.cached = undefined
|
||||
return [] as Entry[]
|
||||
}
|
||||
|
||||
if (state.cached) return state.cached
|
||||
if (state.inflight) return state.inflight
|
||||
|
||||
const current = state.token
|
||||
const dirs = props.workspaces()
|
||||
if (dirs.length === 0) return [] as Entry[]
|
||||
|
||||
state.inflight = Promise.all(
|
||||
dirs.map((directory) => {
|
||||
const description = props.label(directory)
|
||||
return props.globalSDK.client.session
|
||||
.list({ directory, roots: true })
|
||||
.then((x) =>
|
||||
(x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title ?? props.language.t("command.session.new"),
|
||||
description,
|
||||
directory,
|
||||
archived: s.time?.archived,
|
||||
updated: s.time?.updated,
|
||||
})),
|
||||
)
|
||||
.catch(
|
||||
() =>
|
||||
[] as {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
directory: string
|
||||
archived?: number
|
||||
updated?: number
|
||||
}[],
|
||||
)
|
||||
}),
|
||||
)
|
||||
.then((results) => {
|
||||
if (state.token !== current) return [] as Entry[]
|
||||
const seen = new Set<string>()
|
||||
const category = props.language.t("command.category.session")
|
||||
const next = results
|
||||
.flat()
|
||||
.filter((item) => {
|
||||
const key = `${item.directory}:${item.id}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
.map((item) => createSessionEntry(item, category))
|
||||
state.cached = next
|
||||
return next
|
||||
})
|
||||
.catch(() => [] as Entry[])
|
||||
.finally(() => {
|
||||
state.inflight = undefined
|
||||
})
|
||||
|
||||
return state.inflight
|
||||
}
|
||||
|
||||
return { sessions }
|
||||
}
|
||||
|
||||
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
@@ -52,40 +269,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||
const [grouped, setGrouped] = createSignal(false)
|
||||
const common = [
|
||||
"session.new",
|
||||
"workspace.new",
|
||||
"session.previous",
|
||||
"session.next",
|
||||
"terminal.toggle",
|
||||
"review.toggle",
|
||||
]
|
||||
const limit = 5
|
||||
|
||||
const allowed = createMemo(() => {
|
||||
if (filesOnly()) return []
|
||||
return command.options.filter(
|
||||
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
||||
)
|
||||
})
|
||||
|
||||
const commandItem = (option: CommandOption): Entry => ({
|
||||
id: "command:" + option.id,
|
||||
type: "command",
|
||||
title: option.title,
|
||||
description: option.description,
|
||||
keybind: option.keybind,
|
||||
category: language.t("palette.group.commands"),
|
||||
option,
|
||||
})
|
||||
|
||||
const fileItem = (path: string): Entry => ({
|
||||
id: "file:" + path,
|
||||
type: "file",
|
||||
title: path,
|
||||
category: language.t("palette.group.files"),
|
||||
path,
|
||||
})
|
||||
const commandEntries = createCommandEntries({ filesOnly, command, language })
|
||||
const fileEntries = createFileEntries({ file, tabs, language })
|
||||
|
||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||
const project = createMemo(() => {
|
||||
@@ -116,136 +301,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
return `${kind} : ${name || path}`
|
||||
}
|
||||
|
||||
const sessionItem = (input: {
|
||||
directory: string
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
archived?: number
|
||||
updated?: number
|
||||
}): Entry => ({
|
||||
id: `session:${input.directory}:${input.id}`,
|
||||
type: "session",
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
category: language.t("command.category.session"),
|
||||
directory: input.directory,
|
||||
sessionID: input.id,
|
||||
archived: input.archived,
|
||||
updated: input.updated,
|
||||
})
|
||||
|
||||
const list = createMemo(() => allowed().map(commandItem))
|
||||
|
||||
const picks = createMemo(() => {
|
||||
const all = allowed()
|
||||
const order = new Map(common.map((id, index) => [id, index]))
|
||||
const picked = all.filter((option) => order.has(option.id))
|
||||
const base = picked.length ? picked : all.slice(0, limit)
|
||||
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
||||
return sorted.map(commandItem)
|
||||
})
|
||||
|
||||
const recent = createMemo(() => {
|
||||
const all = tabs().all()
|
||||
const active = tabs().active()
|
||||
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
||||
const seen = new Set<string>()
|
||||
const items: Entry[] = []
|
||||
|
||||
for (const item of order) {
|
||||
const path = file.pathFromTab(item)
|
||||
if (!path) continue
|
||||
if (seen.has(path)) continue
|
||||
seen.add(path)
|
||||
items.push(fileItem(path))
|
||||
}
|
||||
|
||||
return items.slice(0, limit)
|
||||
})
|
||||
|
||||
const root = createMemo(() => {
|
||||
const nodes = file.tree.children("")
|
||||
const paths = nodes
|
||||
.filter((node) => node.type === "file")
|
||||
.map((node) => node.path)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
return paths.slice(0, limit).map(fileItem)
|
||||
})
|
||||
|
||||
const unique = (items: Entry[]) => {
|
||||
const seen = new Set<string>()
|
||||
const out: Entry[] = []
|
||||
for (const item of items) {
|
||||
if (seen.has(item.id)) continue
|
||||
seen.add(item.id)
|
||||
out.push(item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const sessionToken = { value: 0 }
|
||||
let sessionInflight: Promise<Entry[]> | undefined
|
||||
let sessionAll: Entry[] | undefined
|
||||
|
||||
const sessions = (text: string) => {
|
||||
const query = text.trim()
|
||||
if (!query) {
|
||||
sessionToken.value += 1
|
||||
sessionInflight = undefined
|
||||
sessionAll = undefined
|
||||
return [] as Entry[]
|
||||
}
|
||||
|
||||
if (sessionAll) return sessionAll
|
||||
if (sessionInflight) return sessionInflight
|
||||
|
||||
const current = sessionToken.value
|
||||
const dirs = workspaces()
|
||||
if (dirs.length === 0) return [] as Entry[]
|
||||
|
||||
sessionInflight = Promise.all(
|
||||
dirs.map((directory) => {
|
||||
const description = label(directory)
|
||||
return globalSDK.client.session
|
||||
.list({ directory, roots: true })
|
||||
.then((x) =>
|
||||
(x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title ?? language.t("command.session.new"),
|
||||
description,
|
||||
directory,
|
||||
archived: s.time?.archived,
|
||||
updated: s.time?.updated,
|
||||
})),
|
||||
)
|
||||
.catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
|
||||
}),
|
||||
)
|
||||
.then((results) => {
|
||||
if (sessionToken.value !== current) return [] as Entry[]
|
||||
const seen = new Set<string>()
|
||||
const next = results
|
||||
.flat()
|
||||
.filter((item) => {
|
||||
const key = `${item.directory}:${item.id}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
.map(sessionItem)
|
||||
sessionAll = next
|
||||
return next
|
||||
})
|
||||
.catch(() => [] as Entry[])
|
||||
.finally(() => {
|
||||
sessionInflight = undefined
|
||||
})
|
||||
|
||||
return sessionInflight
|
||||
}
|
||||
const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language })
|
||||
|
||||
const items = async (text: string) => {
|
||||
const query = text.trim()
|
||||
@@ -254,7 +310,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
if (!query && filesOnly()) {
|
||||
const loaded = file.tree.state("")?.loaded
|
||||
const pending = loaded ? Promise.resolve() : file.tree.list("")
|
||||
const next = unique([...recent(), ...root()])
|
||||
const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
|
||||
|
||||
if (loaded || next.length > 0) {
|
||||
void pending
|
||||
@@ -262,19 +318,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
}
|
||||
|
||||
await pending
|
||||
return unique([...recent(), ...root()])
|
||||
return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
|
||||
}
|
||||
|
||||
if (!query) return [...picks(), ...recent()]
|
||||
if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
|
||||
|
||||
if (filesOnly()) {
|
||||
const files = await file.searchFiles(query)
|
||||
return files.map(fileItem)
|
||||
const category = language.t("palette.group.files")
|
||||
return files.map((path) => createFileEntry(path, category))
|
||||
}
|
||||
|
||||
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
|
||||
const entries = files.map(fileItem)
|
||||
return [...list(), ...nextSessions, ...entries]
|
||||
const category = language.t("palette.group.files")
|
||||
const entries = files.map((path) => createFileEntry(path, category))
|
||||
return [...commandEntries.list(), ...nextSessions, ...entries]
|
||||
}
|
||||
|
||||
const handleMove = (item: Entry | undefined) => {
|
||||
@@ -289,9 +347,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
tabs().open(value)
|
||||
file.load(path)
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
layout.fileTree.open()
|
||||
layout.fileTree.setTab("all")
|
||||
props.onOpenFile?.(path)
|
||||
tabs().setActive(value)
|
||||
}
|
||||
|
||||
const handleSelect = (item: Entry | undefined) => {
|
||||
|
||||
@@ -6,6 +6,13 @@ import { List } from "@opencode-ai/ui/list"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
const statusLabels = {
|
||||
connected: "mcp.status.connected",
|
||||
failed: "mcp.status.failed",
|
||||
needs_auth: "mcp.status.needs_auth",
|
||||
disabled: "mcp.status.disabled",
|
||||
} as const
|
||||
|
||||
export const DialogSelectMcp: Component = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
@@ -21,15 +28,19 @@ export const DialogSelectMcp: Component = () => {
|
||||
const toggle = async (name: string) => {
|
||||
if (loading()) return
|
||||
setLoading(name)
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
} else {
|
||||
await sdk.client.mcp.connect({ name })
|
||||
try {
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
} else {
|
||||
await sdk.client.mcp.connect({ name })
|
||||
}
|
||||
|
||||
const result = await sdk.client.mcp.status()
|
||||
if (result.data) sync.set("mcp", result.data)
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
const result = await sdk.client.mcp.status()
|
||||
if (result.data) sync.set("mcp", result.data)
|
||||
setLoading(null)
|
||||
}
|
||||
|
||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||
@@ -54,6 +65,11 @@ export const DialogSelectMcp: Component = () => {
|
||||
{(i) => {
|
||||
const mcpStatus = () => sync.data.mcp[i.name]
|
||||
const status = () => mcpStatus()?.status
|
||||
const statusLabel = () => {
|
||||
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
||||
if (!key) return
|
||||
return language.t(key)
|
||||
}
|
||||
const error = () => {
|
||||
const s = mcpStatus()
|
||||
return s?.status === "failed" ? s.error : undefined
|
||||
@@ -64,17 +80,8 @@ export const DialogSelectMcp: Component = () => {
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate">{i.name}</span>
|
||||
<Show when={status() === "connected"}>
|
||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.connected")}</span>
|
||||
</Show>
|
||||
<Show when={status() === "failed"}>
|
||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.failed")}</span>
|
||||
</Show>
|
||||
<Show when={status() === "needs_auth"}>
|
||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.needs_auth")}</span>
|
||||
</Show>
|
||||
<Show when={status() === "disabled"}>
|
||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.disabled")}</span>
|
||||
<Show when={statusLabel()}>
|
||||
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
||||
</Show>
|
||||
<Show when={loading() === i.name}>
|
||||
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { type Component, onCleanup, onMount, Show } from "solid-js"
|
||||
import { type Component, Show } from "solid-js"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||
@@ -21,24 +21,17 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||
const language = useLanguage()
|
||||
|
||||
let listRef: ListRef | undefined
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") return
|
||||
listRef?.onKeyDown(e)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKey)
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKey)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={language.t("dialog.model.select.title")}
|
||||
class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none"
|
||||
>
|
||||
<div class="flex flex-col gap-3 px-2.5">
|
||||
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
|
||||
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
||||
<List
|
||||
class="[&_[data-slot=list-scroll]]:overflow-visible"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||
import { Component, ComponentProps, createEffect, createMemo, JSX, onCleanup, Show, ValidComponent } from "solid-js"
|
||||
import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -15,6 +15,9 @@ import { DialogManageModels } from "./dialog-manage-models"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
|
||||
const ModelList: Component<{
|
||||
provider?: string
|
||||
class?: string
|
||||
@@ -54,13 +57,7 @@ const ModelList: Component<{
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
latest={item.latest}
|
||||
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
|
||||
/>
|
||||
}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||
>
|
||||
{node}
|
||||
</Tooltip>
|
||||
@@ -75,7 +72,7 @@ const ModelList: Component<{
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
||||
<span class="truncate">{i.name}</span>
|
||||
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
|
||||
<Show when={isFree(i.provider.id, i.cost)}>
|
||||
<Tag>{language.t("model.tag.free")}</Tag>
|
||||
</Show>
|
||||
<Show when={i.latest}>
|
||||
@@ -98,13 +95,9 @@ export function ModelSelectorPopover(props: {
|
||||
const [store, setStore] = createStore<{
|
||||
open: boolean
|
||||
dismiss: "escape" | "outside" | null
|
||||
trigger?: HTMLElement
|
||||
content?: HTMLElement
|
||||
}>({
|
||||
open: false,
|
||||
dismiss: null,
|
||||
trigger: undefined,
|
||||
content: undefined,
|
||||
})
|
||||
const dialog = useDialog()
|
||||
|
||||
@@ -119,54 +112,6 @@ export function ModelSelectorPopover(props: {
|
||||
}
|
||||
const language = useLanguage()
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.open) return
|
||||
|
||||
const inside = (node: Node | null | undefined) => {
|
||||
if (!node) return false
|
||||
const el = store.content
|
||||
if (el && el.contains(node)) return true
|
||||
const anchor = store.trigger
|
||||
if (anchor && anchor.contains(node)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return
|
||||
setStore("dismiss", "escape")
|
||||
setStore("open", false)
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) return
|
||||
if (inside(target)) return
|
||||
setStore("dismiss", "outside")
|
||||
setStore("open", false)
|
||||
}
|
||||
|
||||
const onFocusIn = (event: FocusEvent) => {
|
||||
if (!store.content) return
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) return
|
||||
if (inside(target)) return
|
||||
setStore("dismiss", "outside")
|
||||
setStore("open", false)
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown, true)
|
||||
window.addEventListener("pointerdown", onPointerDown, true)
|
||||
window.addEventListener("focusin", onFocusIn, true)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", onKeyDown, true)
|
||||
window.removeEventListener("pointerdown", onPointerDown, true)
|
||||
window.removeEventListener("focusin", onFocusIn, true)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Kobalte
|
||||
open={store.open}
|
||||
@@ -178,12 +123,11 @@ export function ModelSelectorPopover(props: {
|
||||
placement="top-start"
|
||||
gutter={8}
|
||||
>
|
||||
<Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||
{props.children}
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Content
|
||||
ref={(el) => setStore("content", el)}
|
||||
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"
|
||||
onEscapeKeyDown={(event) => {
|
||||
setStore("dismiss", "escape")
|
||||
|
||||
@@ -24,6 +24,12 @@ export const DialogSelectProvider: Component = () => {
|
||||
|
||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||
const customLabel = () => language.t("settings.providers.tag.custom")
|
||||
const note = (id: string) => {
|
||||
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
|
||||
if (id === "openai") return language.t("dialog.provider.openai.note")
|
||||
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("command.provider.connect")} transition>
|
||||
@@ -34,7 +40,7 @@ export const DialogSelectProvider: Component = () => {
|
||||
key={(x) => x?.id}
|
||||
items={() => {
|
||||
language.locale()
|
||||
return [{ id: CUSTOM_ID, name: "Custom provider" }, ...providers.all()]
|
||||
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()]
|
||||
}}
|
||||
filterKeys={["id", "name"]}
|
||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
||||
@@ -70,15 +76,7 @@ export const DialogSelectProvider: Component = () => {
|
||||
<Show when={i.id === "opencode"}>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={i.id === "anthropic"}>
|
||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
|
||||
</Show>
|
||||
<Show when={i.id === "openai"}>
|
||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.openai.note")}</div>
|
||||
</Show>
|
||||
<Show when={i.id.startsWith("github-copilot")}>
|
||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.copilot.note")}</div>
|
||||
</Show>
|
||||
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
|
||||
@@ -38,6 +38,64 @@ interface EditRowProps {
|
||||
onBlur: () => void
|
||||
}
|
||||
|
||||
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
|
||||
const [defaultUrl, defaultUrlActions] = createResource(
|
||||
async () => {
|
||||
try {
|
||||
const url = await platform.getDefaultServerUrl?.()
|
||||
if (!url) return null
|
||||
return normalizeServerUrl(url) ?? null
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
return null
|
||||
}
|
||||
},
|
||||
{ initialValue: null },
|
||||
)
|
||||
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
||||
const setDefault = async (url: string | null) => {
|
||||
try {
|
||||
await platform.setDefaultServerUrl?.(url)
|
||||
defaultUrlActions.mutate(url)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { defaultUrl, canDefault, setDefault }
|
||||
}
|
||||
|
||||
function useServerPreview(fetcher: typeof fetch) {
|
||||
const looksComplete = (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return false
|
||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||
if (!host) return false
|
||||
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
||||
return host.includes(".") || host.includes(":")
|
||||
}
|
||||
|
||||
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
|
||||
setStatus(undefined)
|
||||
if (!looksComplete(value)) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return
|
||||
const result = await checkServerHealth(normalized, fetcher)
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
return { previewStatus }
|
||||
}
|
||||
|
||||
function AddRow(props: AddRowProps) {
|
||||
return (
|
||||
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
|
||||
@@ -115,6 +173,10 @@ export function DialogSelectServer() {
|
||||
const platform = usePlatform()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const language = useLanguage()
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
||||
const { previewStatus } = useServerPreview(fetcher)
|
||||
let listRoot: HTMLDivElement | undefined
|
||||
const [store, setStore] = createStore({
|
||||
status: {} as Record<string, ServerHealth | undefined>,
|
||||
addServer: {
|
||||
@@ -132,43 +194,6 @@ export function DialogSelectServer() {
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
})
|
||||
const [defaultUrl, defaultUrlActions] = createResource(
|
||||
async () => {
|
||||
try {
|
||||
const url = await platform.getDefaultServerUrl?.()
|
||||
if (!url) return null
|
||||
return normalizeServerUrl(url) ?? null
|
||||
} catch (err) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return null
|
||||
}
|
||||
},
|
||||
{ initialValue: null },
|
||||
)
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
|
||||
const looksComplete = (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return false
|
||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||
if (!host) return false
|
||||
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
||||
return host.includes(".") || host.includes(":")
|
||||
}
|
||||
|
||||
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
|
||||
setStatus(undefined)
|
||||
if (!looksComplete(value)) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return
|
||||
const result = await checkServerHealth(normalized, fetcher)
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
const resetAdd = () => {
|
||||
setStore("addServer", {
|
||||
@@ -263,7 +288,7 @@ export function DialogSelectServer() {
|
||||
}
|
||||
|
||||
const scrollListToBottom = () => {
|
||||
const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]')
|
||||
const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]')
|
||||
if (!scroll) return
|
||||
requestAnimationFrame(() => {
|
||||
scroll.scrollTop = scroll.scrollHeight
|
||||
@@ -363,158 +388,134 @@ export function DialogSelectServer() {
|
||||
return (
|
||||
<Dialog title={language.t("dialog.server.title")}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<List
|
||||
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
|
||||
noInitialSelection
|
||||
emptyMessage={language.t("dialog.server.empty")}
|
||||
items={sortedItems}
|
||||
key={(x) => x}
|
||||
onSelect={(x) => {
|
||||
if (x) select(x)
|
||||
}}
|
||||
onFilter={(value) => {
|
||||
if (value && store.addServer.showForm && !store.addServer.adding) {
|
||||
resetAdd()
|
||||
}
|
||||
}}
|
||||
divider={true}
|
||||
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
|
||||
add={
|
||||
store.addServer.showForm
|
||||
? {
|
||||
render: () => (
|
||||
<AddRow
|
||||
value={store.addServer.url}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
adding={store.addServer.adding}
|
||||
error={store.addServer.error}
|
||||
status={store.addServer.status}
|
||||
onChange={handleAddChange}
|
||||
onKeyDown={handleAddKey}
|
||||
onBlur={blurAdd}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{(i) => {
|
||||
return (
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
||||
<Show
|
||||
when={store.editServer.id !== i}
|
||||
fallback={
|
||||
<EditRow
|
||||
value={store.editServer.value}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
busy={store.editServer.busy}
|
||||
error={store.editServer.error}
|
||||
status={store.editServer.status}
|
||||
onChange={handleEditChange}
|
||||
onKeyDown={(event) => handleEditKey(event, i)}
|
||||
onBlur={() => handleEdit(i, store.editServer.value)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ServerRow
|
||||
url={i}
|
||||
status={store.status[i]}
|
||||
dimmed={store.status[i]?.healthy === false}
|
||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={defaultUrl() === i}>
|
||||
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={store.editServer.id !== i}>
|
||||
<div class="flex items-center justify-center gap-5 pl-4">
|
||||
<Show when={current() === i}>
|
||||
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
||||
</Show>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||
<div ref={(el) => (listRoot = el)}>
|
||||
<List
|
||||
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
|
||||
noInitialSelection
|
||||
emptyMessage={language.t("dialog.server.empty")}
|
||||
items={sortedItems}
|
||||
key={(x) => x}
|
||||
onSelect={(x) => {
|
||||
if (x) select(x)
|
||||
}}
|
||||
onFilter={(value) => {
|
||||
if (value && store.addServer.showForm && !store.addServer.adding) {
|
||||
resetAdd()
|
||||
}
|
||||
}}
|
||||
divider={true}
|
||||
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
|
||||
add={
|
||||
store.addServer.showForm
|
||||
? {
|
||||
render: () => (
|
||||
<AddRow
|
||||
value={store.addServer.url}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
adding={store.addServer.adding}
|
||||
error={store.addServer.error}
|
||||
status={store.addServer.status}
|
||||
onChange={handleAddChange}
|
||||
onKeyDown={handleAddKey}
|
||||
onBlur={blurAdd}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setStore("editServer", {
|
||||
id: i,
|
||||
value: i,
|
||||
error: "",
|
||||
status: store.status[i]?.healthy,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={canDefault() && defaultUrl() !== i}>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{(i) => {
|
||||
return (
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
||||
<Show
|
||||
when={store.editServer.id !== i}
|
||||
fallback={
|
||||
<EditRow
|
||||
value={store.editServer.value}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
busy={store.editServer.busy}
|
||||
error={store.editServer.error}
|
||||
status={store.editServer.status}
|
||||
onChange={handleEditChange}
|
||||
onKeyDown={(event) => handleEditKey(event, i)}
|
||||
onBlur={() => handleEdit(i, store.editServer.value)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ServerRow
|
||||
url={i}
|
||||
status={store.status[i]}
|
||||
dimmed={store.status[i]?.healthy === false}
|
||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={defaultUrl() === i}>
|
||||
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={store.editServer.id !== i}>
|
||||
<div class="flex items-center justify-center gap-5 pl-4">
|
||||
<Show when={current() === i}>
|
||||
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
||||
</Show>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<DropdownMenu.Item
|
||||
onSelect={async () => {
|
||||
try {
|
||||
await platform.setDefaultServerUrl?.(i)
|
||||
defaultUrlActions.mutate(i)
|
||||
} catch (err) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
onSelect={() => {
|
||||
setStore("editServer", {
|
||||
id: i,
|
||||
value: i,
|
||||
error: "",
|
||||
status: store.status[i]?.healthy,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={canDefault() && defaultUrl() === i}>
|
||||
<Show when={canDefault() && defaultUrl() !== i}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(i)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={canDefault() && defaultUrl() === i}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={async () => {
|
||||
try {
|
||||
await platform.setDefaultServerUrl?.(null)
|
||||
defaultUrlActions.mutate(null)
|
||||
} catch (err) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}}
|
||||
onSelect={() => handleRemove(i)}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => handleRemove(i)}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</List>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</List>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-5">
|
||||
<Button
|
||||
|
||||
@@ -67,15 +67,6 @@ export const DialogSettings: Component = () => {
|
||||
<Tabs.Content value="models" class="no-scrollbar">
|
||||
<SettingsModels />
|
||||
</Tabs.Content>
|
||||
{/* <Tabs.Content value="agents" class="no-scrollbar"> */}
|
||||
{/* <SettingsAgents /> */}
|
||||
{/* </Tabs.Content> */}
|
||||
{/* <Tabs.Content value="commands" class="no-scrollbar"> */}
|
||||
{/* <SettingsCommands /> */}
|
||||
{/* </Tabs.Content> */}
|
||||
{/* <Tabs.Content value="mcp" class="no-scrollbar"> */}
|
||||
{/* <SettingsMcp /> */}
|
||||
{/* </Tabs.Content> */}
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Switch,
|
||||
untrack,
|
||||
type ComponentProps,
|
||||
type JSXElement,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
@@ -59,6 +60,189 @@ export function dirsToExpand(input: {
|
||||
return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
|
||||
}
|
||||
|
||||
const kindLabel = (kind: Kind) => {
|
||||
if (kind === "add") return "A"
|
||||
if (kind === "del") return "D"
|
||||
return "M"
|
||||
}
|
||||
|
||||
const kindTextColor = (kind: Kind) => {
|
||||
if (kind === "add") return "color: var(--icon-diff-add-base)"
|
||||
if (kind === "del") return "color: var(--icon-diff-delete-base)"
|
||||
return "color: var(--icon-warning-active)"
|
||||
}
|
||||
|
||||
const kindDotColor = (kind: Kind) => {
|
||||
if (kind === "add") return "background-color: var(--icon-diff-add-base)"
|
||||
if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
|
||||
return "background-color: var(--icon-warning-active)"
|
||||
}
|
||||
|
||||
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
|
||||
const kind = kinds?.get(node.path)
|
||||
if (!kind) return
|
||||
if (!marks?.has(node.path)) return
|
||||
return kind
|
||||
}
|
||||
|
||||
const buildDragImage = (target: HTMLElement) => {
|
||||
const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg")
|
||||
const text = target.querySelector("span")
|
||||
if (!icon || !text) return
|
||||
|
||||
const image = document.createElement("div")
|
||||
image.className =
|
||||
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
|
||||
image.style.position = "absolute"
|
||||
image.style.top = "-1000px"
|
||||
image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
|
||||
return image
|
||||
}
|
||||
|
||||
const withFileDragImage = (event: DragEvent) => {
|
||||
const image = buildDragImage(event.currentTarget as HTMLElement)
|
||||
if (!image) return
|
||||
document.body.appendChild(image)
|
||||
event.dataTransfer?.setDragImage(image, 0, 12)
|
||||
setTimeout(() => document.body.removeChild(image), 0)
|
||||
}
|
||||
|
||||
const FileTreeNode = (
|
||||
p: ParentProps &
|
||||
ComponentProps<"div"> &
|
||||
ComponentProps<"button"> & {
|
||||
node: FileNode
|
||||
level: number
|
||||
active?: string
|
||||
nodeClass?: string
|
||||
draggable: boolean
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
marks?: Set<string>
|
||||
as?: "div" | "button"
|
||||
},
|
||||
) => {
|
||||
const [local, rest] = splitProps(p, [
|
||||
"node",
|
||||
"level",
|
||||
"active",
|
||||
"nodeClass",
|
||||
"draggable",
|
||||
"kinds",
|
||||
"marks",
|
||||
"as",
|
||||
"children",
|
||||
"class",
|
||||
"classList",
|
||||
])
|
||||
const kind = () => visibleKind(local.node, local.kinds, local.marks)
|
||||
const active = () => !!kind() && !local.node.ignored
|
||||
const color = () => {
|
||||
const value = kind()
|
||||
if (!value) return
|
||||
return kindTextColor(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dynamic
|
||||
component={local.as ?? "div"}
|
||||
classList={{
|
||||
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
||||
"bg-surface-base-active": local.node.path === local.active,
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
[local.nodeClass ?? ""]: !!local.nodeClass,
|
||||
}}
|
||||
style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
|
||||
draggable={local.draggable}
|
||||
onDragStart={(event: DragEvent) => {
|
||||
if (!local.draggable) return
|
||||
event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
||||
event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
|
||||
withFileDragImage(event)
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
<span
|
||||
classList={{
|
||||
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
|
||||
"text-text-weaker": local.node.ignored,
|
||||
"text-text-weak": !local.node.ignored && !active(),
|
||||
}}
|
||||
style={active() ? color() : undefined}
|
||||
>
|
||||
{local.node.name}
|
||||
</span>
|
||||
{(() => {
|
||||
const value = kind()
|
||||
if (!value) return null
|
||||
if (local.node.type === "file") {
|
||||
return (
|
||||
<span class="shrink-0 w-4 text-center text-12-medium" style={kindTextColor(value)}>
|
||||
{kindLabel(value)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={kindDotColor(value)} />
|
||||
})()}
|
||||
</Dynamic>
|
||||
)
|
||||
}
|
||||
|
||||
const FileTreeNodeTooltip = (props: { enabled: boolean; node: FileNode; kind?: Kind; children: JSXElement }) => {
|
||||
if (!props.enabled) return props.children
|
||||
|
||||
const parts = props.node.path.split("/")
|
||||
const leaf = parts[parts.length - 1] ?? props.node.path
|
||||
const head = parts.slice(0, -1).join("/")
|
||||
const prefix = head ? `${head}/` : ""
|
||||
const label =
|
||||
props.kind === "add"
|
||||
? "Additions"
|
||||
: props.kind === "del"
|
||||
? "Deletions"
|
||||
: props.kind === "mix"
|
||||
? "Modifications"
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
openDelay={2000}
|
||||
placement="bottom-start"
|
||||
class="w-full"
|
||||
contentStyle={{ "max-width": "480px", width: "fit-content" }}
|
||||
value={
|
||||
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
|
||||
<span
|
||||
class="min-w-0 truncate text-text-invert-base"
|
||||
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
|
||||
>
|
||||
{prefix}
|
||||
</span>
|
||||
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
|
||||
<Show when={label}>
|
||||
{(text) => (
|
||||
<>
|
||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||
<span class="shrink-0 text-text-invert-strong">{text()}</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.node.type === "directory" && props.node.ignored}>
|
||||
<>
|
||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||
<span class="shrink-0 text-text-invert-strong">Ignored</span>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FileTree(props: {
|
||||
path: string
|
||||
class?: string
|
||||
@@ -230,178 +414,13 @@ export default function FileTree(props: {
|
||||
return out
|
||||
})
|
||||
|
||||
const Node = (
|
||||
p: ParentProps &
|
||||
ComponentProps<"div"> &
|
||||
ComponentProps<"button"> & {
|
||||
node: FileNode
|
||||
as?: "div" | "button"
|
||||
},
|
||||
) => {
|
||||
const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"])
|
||||
return (
|
||||
<Dynamic
|
||||
component={local.as ?? "div"}
|
||||
classList={{
|
||||
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
||||
"bg-surface-base-active": local.node.path === props.active,
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
[props.nodeClass ?? ""]: !!props.nodeClass,
|
||||
}}
|
||||
style={`padding-left: ${Math.max(0, 8 + level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
|
||||
draggable={draggable()}
|
||||
onDragStart={(e: DragEvent) => {
|
||||
if (!draggable()) return
|
||||
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
||||
e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
|
||||
|
||||
const dragImage = document.createElement("div")
|
||||
dragImage.className =
|
||||
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
|
||||
dragImage.style.position = "absolute"
|
||||
dragImage.style.top = "-1000px"
|
||||
|
||||
const icon =
|
||||
(e.currentTarget as HTMLElement).querySelector('[data-component="file-icon"]') ??
|
||||
(e.currentTarget as HTMLElement).querySelector("svg")
|
||||
const text = (e.currentTarget as HTMLElement).querySelector("span")
|
||||
if (icon && text) {
|
||||
dragImage.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
|
||||
}
|
||||
|
||||
document.body.appendChild(dragImage)
|
||||
e.dataTransfer?.setDragImage(dragImage, 0, 12)
|
||||
setTimeout(() => document.body.removeChild(dragImage), 0)
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
{(() => {
|
||||
const kind = kinds()?.get(local.node.path)
|
||||
const marked = marks()?.has(local.node.path) ?? false
|
||||
const active = !!kind && marked && !local.node.ignored
|
||||
const color =
|
||||
kind === "add"
|
||||
? "color: var(--icon-diff-add-base)"
|
||||
: kind === "del"
|
||||
? "color: var(--icon-diff-delete-base)"
|
||||
: kind === "mix"
|
||||
? "color: var(--icon-warning-active)"
|
||||
: undefined
|
||||
return (
|
||||
<span
|
||||
classList={{
|
||||
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
|
||||
"text-text-weaker": local.node.ignored,
|
||||
"text-text-weak": !local.node.ignored && !active,
|
||||
}}
|
||||
style={active ? color : undefined}
|
||||
>
|
||||
{local.node.name}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
{(() => {
|
||||
const kind = kinds()?.get(local.node.path)
|
||||
if (!kind) return null
|
||||
if (!marks()?.has(local.node.path)) return null
|
||||
|
||||
if (local.node.type === "file") {
|
||||
const text = kind === "add" ? "A" : kind === "del" ? "D" : "M"
|
||||
const color =
|
||||
kind === "add"
|
||||
? "color: var(--icon-diff-add-base)"
|
||||
: kind === "del"
|
||||
? "color: var(--icon-diff-delete-base)"
|
||||
: "color: var(--icon-warning-active)"
|
||||
|
||||
return (
|
||||
<span class="shrink-0 w-4 text-center text-12-medium" style={color}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (local.node.type === "directory") {
|
||||
const color =
|
||||
kind === "add"
|
||||
? "background-color: var(--icon-diff-add-base)"
|
||||
: kind === "del"
|
||||
? "background-color: var(--icon-diff-delete-base)"
|
||||
: "background-color: var(--icon-warning-active)"
|
||||
|
||||
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={color} />
|
||||
}
|
||||
|
||||
return null
|
||||
})()}
|
||||
</Dynamic>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
|
||||
<For each={nodes()}>
|
||||
{(node) => {
|
||||
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
||||
const deep = () => deeps().get(node.path) ?? -1
|
||||
const Wrapper = (p: ParentProps) => {
|
||||
if (!tooltip()) return p.children
|
||||
|
||||
const parts = node.path.split("/")
|
||||
const leaf = parts[parts.length - 1] ?? node.path
|
||||
const head = parts.slice(0, -1).join("/")
|
||||
const prefix = head ? `${head}/` : ""
|
||||
|
||||
const kind = () => kinds()?.get(node.path)
|
||||
const label = () => {
|
||||
const k = kind()
|
||||
if (!k) return
|
||||
if (k === "add") return "Additions"
|
||||
if (k === "del") return "Deletions"
|
||||
return "Modifications"
|
||||
}
|
||||
|
||||
const ignored = () => node.type === "directory" && node.ignored
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
openDelay={2000}
|
||||
placement="bottom-start"
|
||||
class="w-full"
|
||||
contentStyle={{ "max-width": "480px", width: "fit-content" }}
|
||||
value={
|
||||
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
|
||||
<span
|
||||
class="min-w-0 truncate text-text-invert-base"
|
||||
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
|
||||
>
|
||||
{prefix}
|
||||
</span>
|
||||
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
|
||||
<Show when={label()}>
|
||||
{(t: () => string) => (
|
||||
<>
|
||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||
<span class="shrink-0 text-text-invert-strong">{t()}</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={ignored()}>
|
||||
<>
|
||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||
<span class="shrink-0 text-text-invert-strong">Ignored</span>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{p.children}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
const kind = () => visibleKind(node, kinds(), marks())
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
@@ -415,13 +434,21 @@ export default function FileTree(props: {
|
||||
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
|
||||
>
|
||||
<Collapsible.Trigger>
|
||||
<Wrapper>
|
||||
<Node node={node}>
|
||||
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
|
||||
<FileTreeNode
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
nodeClass={props.nodeClass}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
>
|
||||
<div class="size-4 flex items-center justify-center text-icon-weak">
|
||||
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
|
||||
</div>
|
||||
</Node>
|
||||
</Wrapper>
|
||||
</FileTreeNode>
|
||||
</FileTreeNodeTooltip>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="relative pt-0.5">
|
||||
<div
|
||||
@@ -451,12 +478,23 @@ export default function FileTree(props: {
|
||||
</Collapsible>
|
||||
</Match>
|
||||
<Match when={node.type === "file"}>
|
||||
<Wrapper>
|
||||
<Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
|
||||
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
|
||||
<FileTreeNode
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
nodeClass={props.nodeClass}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => props.onFileClick?.(node)}
|
||||
>
|
||||
<div class="w-4 shrink-0" />
|
||||
<FileIcon node={node} class="text-icon-weak size-4" />
|
||||
</Node>
|
||||
</Wrapper>
|
||||
</FileTreeNode>
|
||||
</FileTreeNodeTooltip>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { ComponentProps, splitProps } from "solid-js"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
|
||||
export interface LinkProps extends ComponentProps<"button"> {
|
||||
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"])
|
||||
const [local, rest] = splitProps(props, ["href", "children", "class"])
|
||||
|
||||
return (
|
||||
<button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}>
|
||||
<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}
|
||||
</button>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,12 @@ import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
|
||||
import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history"
|
||||
import {
|
||||
canNavigateHistoryAtCursor,
|
||||
navigatePromptHistory,
|
||||
prependHistoryEntry,
|
||||
promptLength,
|
||||
} from "./prompt-input/history"
|
||||
import { createPromptSubmit } from "./prompt-input/submit"
|
||||
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
||||
import { PromptContextItems } from "./prompt-input/context-items"
|
||||
@@ -158,14 +163,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
|
||||
if (wantsReview) {
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
layout.fileTree.open()
|
||||
layout.fileTree.setTab("changes")
|
||||
tabs().setActive("review")
|
||||
requestAnimationFrame(() => comments.setFocus(focus))
|
||||
return
|
||||
}
|
||||
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
layout.fileTree.open()
|
||||
layout.fileTree.setTab("all")
|
||||
const tab = files.tab(item.path)
|
||||
tabs().open(tab)
|
||||
@@ -277,6 +281,47 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const isFocused = createFocusSignal(() => editorRef)
|
||||
|
||||
const closePopover = () => setStore("popover", null)
|
||||
|
||||
const resetHistoryNavigation = (force = false) => {
|
||||
if (!force && (store.historyIndex < 0 || store.applyingHistory)) return
|
||||
setStore("historyIndex", -1)
|
||||
setStore("savedPrompt", null)
|
||||
}
|
||||
|
||||
const clearEditor = () => {
|
||||
editorRef.innerHTML = ""
|
||||
}
|
||||
|
||||
const setEditorText = (text: string) => {
|
||||
clearEditor()
|
||||
editorRef.textContent = text
|
||||
}
|
||||
|
||||
const focusEditorEnd = () => {
|
||||
requestAnimationFrame(() => {
|
||||
editorRef.focus()
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()
|
||||
range.selectNodeContents(editorRef)
|
||||
range.collapse(false)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
})
|
||||
}
|
||||
|
||||
const currentCursor = () => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) return null
|
||||
return getCursorPosition(editorRef)
|
||||
}
|
||||
|
||||
const renderEditorWithCursor = (parts: Prompt) => {
|
||||
const cursor = currentCursor()
|
||||
renderEditor(parts)
|
||||
if (cursor !== null) setCursorPosition(editorRef, cursor)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
params.id
|
||||
if (params.id) return
|
||||
@@ -290,7 +335,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
||||
|
||||
createEffect(() => {
|
||||
if (!isFocused()) setStore("popover", null)
|
||||
if (!isFocused()) closePopover()
|
||||
})
|
||||
|
||||
// Safety: reset composing state on focus change to prevent stuck state
|
||||
@@ -304,6 +349,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
|
||||
)
|
||||
const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
|
||||
|
||||
const handleAtSelect = (option: AtOption | undefined) => {
|
||||
if (!option) return
|
||||
@@ -381,26 +427,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
||||
if (!cmd) return
|
||||
setStore("popover", null)
|
||||
closePopover()
|
||||
|
||||
if (cmd.type === "custom") {
|
||||
const text = `/${cmd.trigger} `
|
||||
editorRef.innerHTML = ""
|
||||
editorRef.textContent = text
|
||||
setEditorText(text)
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||
requestAnimationFrame(() => {
|
||||
editorRef.focus()
|
||||
const range = document.createRange()
|
||||
const sel = window.getSelection()
|
||||
range.selectNodeContents(editorRef)
|
||||
range.collapse(false)
|
||||
sel?.removeAllRanges()
|
||||
sel?.addRange(range)
|
||||
})
|
||||
focusEditorEnd()
|
||||
return
|
||||
}
|
||||
|
||||
editorRef.innerHTML = ""
|
||||
clearEditor()
|
||||
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
||||
command.trigger(cmd.id, "slash")
|
||||
}
|
||||
@@ -441,10 +478,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const prev = node.previousSibling
|
||||
const next = node.nextSibling
|
||||
const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
|
||||
const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
|
||||
if (!prevIsBr && !nextIsBr) return false
|
||||
if (nextIsBr && !prevIsBr && prev) return false
|
||||
return true
|
||||
return !!prevIsBr && !next
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return false
|
||||
const el = node as HTMLElement
|
||||
@@ -454,7 +488,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
|
||||
const renderEditor = (parts: Prompt) => {
|
||||
editorRef.innerHTML = ""
|
||||
clearEditor()
|
||||
for (const part of parts) {
|
||||
if (part.type === "text") {
|
||||
editorRef.appendChild(createTextFragment(part.content))
|
||||
@@ -464,6 +498,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
editorRef.appendChild(createPill(part))
|
||||
}
|
||||
}
|
||||
|
||||
const last = editorRef.lastChild
|
||||
if (last?.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR") {
|
||||
editorRef.appendChild(document.createTextNode("\u200B"))
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(
|
||||
@@ -514,34 +553,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
mirror.input = false
|
||||
if (isNormalizedEditor()) return
|
||||
|
||||
const selection = window.getSelection()
|
||||
let cursorPosition: number | null = null
|
||||
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
|
||||
cursorPosition = getCursorPosition(editorRef)
|
||||
}
|
||||
|
||||
renderEditor(inputParts)
|
||||
|
||||
if (cursorPosition !== null) {
|
||||
setCursorPosition(editorRef, cursorPosition)
|
||||
}
|
||||
renderEditorWithCursor(inputParts)
|
||||
return
|
||||
}
|
||||
|
||||
const domParts = parseFromDOM()
|
||||
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
|
||||
|
||||
const selection = window.getSelection()
|
||||
let cursorPosition: number | null = null
|
||||
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
|
||||
cursorPosition = getCursorPosition(editorRef)
|
||||
}
|
||||
|
||||
renderEditor(inputParts)
|
||||
|
||||
if (cursorPosition !== null) {
|
||||
setCursorPosition(editorRef, cursorPosition)
|
||||
}
|
||||
renderEditorWithCursor(inputParts)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -636,11 +655,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
|
||||
|
||||
if (shouldReset) {
|
||||
setStore("popover", null)
|
||||
if (store.historyIndex >= 0 && !store.applyingHistory) {
|
||||
setStore("historyIndex", -1)
|
||||
setStore("savedPrompt", null)
|
||||
}
|
||||
closePopover()
|
||||
resetHistoryNavigation()
|
||||
if (prompt.dirty()) {
|
||||
mirror.input = true
|
||||
prompt.set(DEFAULT_PROMPT, 0)
|
||||
@@ -662,16 +678,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
slashOnInput(slashMatch[1])
|
||||
setStore("popover", "slash")
|
||||
} else {
|
||||
setStore("popover", null)
|
||||
closePopover()
|
||||
}
|
||||
} else {
|
||||
setStore("popover", null)
|
||||
closePopover()
|
||||
}
|
||||
|
||||
if (store.historyIndex >= 0 && !store.applyingHistory) {
|
||||
setStore("historyIndex", -1)
|
||||
setStore("savedPrompt", null)
|
||||
}
|
||||
resetHistoryNavigation()
|
||||
|
||||
mirror.input = true
|
||||
prompt.set([...rawParts, ...images], cursorPosition)
|
||||
@@ -723,7 +736,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
if (last.nodeType !== Node.TEXT_NODE) {
|
||||
range.setStartAfter(last)
|
||||
const isBreak = last.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR"
|
||||
const next = last.nextSibling
|
||||
const emptyText = next?.nodeType === Node.TEXT_NODE && (next.textContent ?? "") === ""
|
||||
if (isBreak && (!next || emptyText)) {
|
||||
const placeholder = next && emptyText ? next : document.createTextNode("\u200B")
|
||||
if (!next) last.parentNode?.insertBefore(placeholder, null)
|
||||
placeholder.textContent = "\u200B"
|
||||
range.setStart(placeholder, 0)
|
||||
} else {
|
||||
range.setStartAfter(last)
|
||||
}
|
||||
}
|
||||
}
|
||||
range.collapse(true)
|
||||
@@ -732,7 +755,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
handleInput()
|
||||
setStore("popover", null)
|
||||
closePopover()
|
||||
}
|
||||
|
||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||
@@ -782,8 +805,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
promptLength,
|
||||
addToHistory,
|
||||
resetHistoryNavigation: () => {
|
||||
setStore("historyIndex", -1)
|
||||
setStore("savedPrompt", null)
|
||||
resetHistoryNavigation(true)
|
||||
},
|
||||
setMode: (mode) => setStore("mode", mode),
|
||||
setPopover: (popover) => setStore("popover", popover),
|
||||
@@ -872,7 +894,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
if (ctrl && event.code === "KeyG") {
|
||||
if (store.popover) {
|
||||
setStore("popover", null)
|
||||
closePopover()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
@@ -894,6 +916,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
.current()
|
||||
.map((part) => ("content" in part ? part.content : ""))
|
||||
.join("")
|
||||
const direction = event.key === "ArrowUp" ? "up" : "down"
|
||||
if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition)) return
|
||||
const isEmpty = textContent.trim() === "" || textLength <= 1
|
||||
const hasNewlines = textContent.includes("\n")
|
||||
const inHistory = store.historyIndex >= 0
|
||||
@@ -902,7 +926,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
|
||||
const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
if (direction === "up") {
|
||||
if (!allowUp) return
|
||||
if (navigateHistory("up")) {
|
||||
event.preventDefault()
|
||||
@@ -923,7 +947,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
if (store.popover) {
|
||||
setStore("popover", null)
|
||||
closePopover()
|
||||
} else if (working()) {
|
||||
abort()
|
||||
}
|
||||
@@ -1033,7 +1057,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
keybind={command.keybind("agent.cycle")}
|
||||
>
|
||||
<Select
|
||||
options={local.agent.list().map((agent) => agent.name)}
|
||||
options={agentNames()}
|
||||
current={local.agent.current()?.name ?? ""}
|
||||
onSelect={local.agent.set}
|
||||
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { onCleanup, onMount } from "solid-js"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import { getCursorPosition } from "./editor-dom"
|
||||
|
||||
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||
@@ -31,7 +32,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const dataUrl = reader.result as string
|
||||
const attachment: ImageAttachmentPart = {
|
||||
type: "image",
|
||||
id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2),
|
||||
id: uuid(),
|
||||
filename: file.name,
|
||||
mime: file.type,
|
||||
dataUrl,
|
||||
@@ -88,6 +89,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
}
|
||||
|
||||
if (!plainText) return
|
||||
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
|
||||
if (inserted) return
|
||||
|
||||
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
||||
}
|
||||
|
||||
|
||||
@@ -20,61 +20,68 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
<Show when={props.items.length > 0}>
|
||||
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<Tooltip
|
||||
value={
|
||||
<span class="flex max-w-[300px]">
|
||||
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
|
||||
{getDirectory(item.path)}
|
||||
{(item) => {
|
||||
const directory = getDirectory(item.path)
|
||||
const filename = getFilename(item.path)
|
||||
const label = getFilenameTruncated(item.path, 14)
|
||||
const selected = props.active(item)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
value={
|
||||
<span class="flex max-w-[300px]">
|
||||
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
|
||||
{directory}
|
||||
</span>
|
||||
<span class="shrink-0">{filename}</span>
|
||||
</span>
|
||||
<span class="shrink-0">{getFilename(item.path)}</span>
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
openDelay={2000}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
||||
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item),
|
||||
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
|
||||
props.active(item),
|
||||
"bg-background-stronger": !props.active(item),
|
||||
}}
|
||||
onClick={() => props.openComment(item)}
|
||||
}
|
||||
placement="top"
|
||||
openDelay={2000}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
|
||||
<Show when={item.selection}>
|
||||
{(sel) => (
|
||||
<span class="text-text-weak whitespace-nowrap shrink-0">
|
||||
{sel().startLine === sel().endLine
|
||||
? `:${sel().startLine}`
|
||||
: `:${sel().startLine}-${sel().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
||||
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !selected,
|
||||
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
|
||||
selected,
|
||||
"bg-background-stronger": !selected,
|
||||
}}
|
||||
onClick={() => props.openComment(item)}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||
<span class="text-text-strong whitespace-nowrap">{label}</span>
|
||||
<Show when={item.selection}>
|
||||
{(sel) => (
|
||||
<span class="text-text-weak whitespace-nowrap shrink-0">
|
||||
{sel().startLine === sel().endLine
|
||||
? `:${sel().startLine}`
|
||||
: `:${sel().startLine}-${sel().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.remove(item)
|
||||
}}
|
||||
aria-label={props.t("prompt.context.removeFile")}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.remove(item)
|
||||
}}
|
||||
aria-label={props.t("prompt.context.removeFile")}
|
||||
/>
|
||||
<Show when={item.comment}>
|
||||
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={item.comment}>
|
||||
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Tooltip>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -6,12 +6,17 @@ type PromptDragOverlayProps = {
|
||||
label: string
|
||||
}
|
||||
|
||||
const kindToIcon = {
|
||||
image: "photo",
|
||||
"@mention": "link",
|
||||
} as const
|
||||
|
||||
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
|
||||
return (
|
||||
<Show when={props.type !== null}>
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
|
||||
<div class="flex flex-col items-center gap-2 text-text-weak">
|
||||
<Icon name={props.type === "@mention" ? "link" : "photo"} class="size-8" />
|
||||
<Icon name={props.type ? kindToIcon[props.type] : kindToIcon.image} class="size-8" />
|
||||
<span class="text-14-regular">{props.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,17 +2,26 @@ import { describe, expect, test } from "bun:test"
|
||||
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
|
||||
|
||||
describe("prompt-input editor dom", () => {
|
||||
test("createTextFragment preserves newlines with br and zero-width placeholders", () => {
|
||||
test("createTextFragment preserves newlines with consecutive br nodes", () => {
|
||||
const fragment = createTextFragment("foo\n\nbar")
|
||||
const container = document.createElement("div")
|
||||
container.appendChild(fragment)
|
||||
|
||||
expect(container.childNodes.length).toBe(5)
|
||||
expect(container.childNodes.length).toBe(4)
|
||||
expect(container.childNodes[0]?.textContent).toBe("foo")
|
||||
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||
expect((container.childNodes[2] as HTMLElement).tagName).toBe("BR")
|
||||
expect(container.childNodes[3]?.textContent).toBe("bar")
|
||||
})
|
||||
|
||||
test("createTextFragment keeps trailing newline as terminal break", () => {
|
||||
const fragment = createTextFragment("foo\n")
|
||||
const container = document.createElement("div")
|
||||
container.appendChild(fragment)
|
||||
|
||||
expect(container.childNodes.length).toBe(2)
|
||||
expect(container.childNodes[0]?.textContent).toBe("foo")
|
||||
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||
expect(container.childNodes[2]?.textContent).toBe("\u200B")
|
||||
expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR")
|
||||
expect(container.childNodes[4]?.textContent).toBe("bar")
|
||||
})
|
||||
|
||||
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
|
||||
@@ -48,4 +57,21 @@ describe("prompt-input editor dom", () => {
|
||||
|
||||
container.remove()
|
||||
})
|
||||
|
||||
test("setCursorPosition and getCursorPosition round-trip across blank lines", () => {
|
||||
const container = document.createElement("div")
|
||||
container.appendChild(document.createTextNode("a"))
|
||||
container.appendChild(document.createElement("br"))
|
||||
container.appendChild(document.createElement("br"))
|
||||
container.appendChild(document.createTextNode("b"))
|
||||
document.body.appendChild(container)
|
||||
|
||||
setCursorPosition(container, 2)
|
||||
expect(getCursorPosition(container)).toBe(2)
|
||||
|
||||
setCursorPosition(container, 3)
|
||||
expect(getCursorPosition(container)).toBe(3)
|
||||
|
||||
container.remove()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,8 +4,6 @@ export function createTextFragment(content: string): DocumentFragment {
|
||||
segments.forEach((segment, index) => {
|
||||
if (segment) {
|
||||
fragment.appendChild(document.createTextNode(segment))
|
||||
} else if (segments.length > 1) {
|
||||
fragment.appendChild(document.createTextNode("\u200B"))
|
||||
}
|
||||
if (index < segments.length - 1) {
|
||||
fragment.appendChild(document.createElement("br"))
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history"
|
||||
import {
|
||||
canNavigateHistoryAtCursor,
|
||||
clonePromptParts,
|
||||
navigatePromptHistory,
|
||||
prependHistoryEntry,
|
||||
promptLength,
|
||||
} from "./history"
|
||||
|
||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
@@ -66,4 +72,20 @@ describe("prompt-input history", () => {
|
||||
if (original[1]?.type !== "file") throw new Error("expected file")
|
||||
expect(original[1].selection?.startLine).toBe(1)
|
||||
})
|
||||
|
||||
test("canNavigateHistoryAtCursor only allows multiline boundaries", () => {
|
||||
const value = "a\nb\nc"
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", value, 2)).toBe(false)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,13 @@ const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
export const MAX_HISTORY = 100
|
||||
|
||||
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number) {
|
||||
if (!text.includes("\n")) return true
|
||||
const position = Math.max(0, Math.min(cursor, text.length))
|
||||
if (direction === "up") return !text.slice(0, position).includes("\n")
|
||||
return !text.slice(position).includes("\n")
|
||||
}
|
||||
|
||||
export function clonePromptParts(prompt: Prompt): Prompt {
|
||||
return prompt.map((part) => {
|
||||
if (part.type === "text") return { ...part }
|
||||
|
||||
@@ -9,6 +9,13 @@ type PromptImageAttachmentsProps = {
|
||||
removeLabel: string
|
||||
}
|
||||
|
||||
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
|
||||
const imageClass =
|
||||
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
||||
const removeClass =
|
||||
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
||||
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
|
||||
|
||||
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
||||
return (
|
||||
<Show when={props.attachments.length > 0}>
|
||||
@@ -19,7 +26,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
||||
<Show
|
||||
when={attachment.mime.startsWith("image/")}
|
||||
fallback={
|
||||
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
|
||||
<div class={fallbackClass}>
|
||||
<Icon name="folder" class="size-6 text-text-weak" />
|
||||
</div>
|
||||
}
|
||||
@@ -27,19 +34,19 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
||||
<img
|
||||
src={attachment.dataUrl}
|
||||
alt={attachment.filename}
|
||||
class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
||||
class={imageClass}
|
||||
onClick={() => props.onOpen(attachment)}
|
||||
/>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onRemove(attachment.id)}
|
||||
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
||||
class={removeClass}
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<Icon name="close" class="size-3 text-text-weak" />
|
||||
</button>
|
||||
<div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
|
||||
<div class={nameClass}>
|
||||
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,47 +52,44 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
|
||||
>
|
||||
<For each={props.atFlat.slice(0, 10)}>
|
||||
{(item) => (
|
||||
<button
|
||||
classList={{
|
||||
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
|
||||
"bg-surface-raised-base-hover": props.atActive === props.atKey(item),
|
||||
}}
|
||||
onClick={() => props.onAtSelect(item)}
|
||||
onMouseEnter={() => props.setAtActive(props.atKey(item))}
|
||||
>
|
||||
<Show
|
||||
when={item.type === "agent"}
|
||||
fallback={
|
||||
<>
|
||||
<FileIcon
|
||||
node={{ path: item.type === "file" ? item.path : "", type: "file" }}
|
||||
class="shrink-0 size-4"
|
||||
/>
|
||||
<div class="flex items-center text-14-regular min-w-0">
|
||||
<span class="text-text-weak whitespace-nowrap truncate min-w-0">
|
||||
{item.type === "file"
|
||||
? item.path.endsWith("/")
|
||||
? item.path
|
||||
: getDirectory(item.path)
|
||||
: ""}
|
||||
</span>
|
||||
<Show when={item.type === "file" && !item.path.endsWith("/")}>
|
||||
<span class="text-text-strong whitespace-nowrap">
|
||||
{item.type === "file" ? getFilename(item.path) : ""}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
{(item) => {
|
||||
const key = props.atKey(item)
|
||||
|
||||
if (item.type === "agent") {
|
||||
return (
|
||||
<button
|
||||
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
|
||||
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
|
||||
onClick={() => props.onAtSelect(item)}
|
||||
onMouseEnter={() => props.setAtActive(key)}
|
||||
>
|
||||
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const isDirectory = item.path.endsWith("/")
|
||||
const directory = isDirectory ? item.path : getDirectory(item.path)
|
||||
const filename = isDirectory ? "" : getFilename(item.path)
|
||||
|
||||
return (
|
||||
<button
|
||||
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
|
||||
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
|
||||
onClick={() => props.onAtSelect(item)}
|
||||
onMouseEnter={() => props.setAtActive(key)}
|
||||
>
|
||||
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">
|
||||
@{item.type === "agent" ? item.name : ""}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular min-w-0">
|
||||
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span>
|
||||
<Show when={!isDirectory}>
|
||||
<span class="text-text-strong whitespace-nowrap">{filename}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
@@ -385,7 +385,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const send = async () => {
|
||||
const ok = await waitForWorktree()
|
||||
if (!ok) return
|
||||
await client.session.prompt({
|
||||
await client.session.promptAsync({
|
||||
sessionID: session.id,
|
||||
agent,
|
||||
model,
|
||||
|
||||
@@ -38,43 +38,45 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
}
|
||||
|
||||
const reply = (answers: QuestionAnswer[]) => {
|
||||
const reply = async (answers: QuestionAnswer[]) => {
|
||||
if (store.sending) return
|
||||
|
||||
setStore("sending", true)
|
||||
sdk.client.question
|
||||
.reply({ requestID: props.request.id, answers })
|
||||
.catch(fail)
|
||||
.finally(() => setStore("sending", false))
|
||||
try {
|
||||
await sdk.client.question.reply({ requestID: props.request.id, answers })
|
||||
} catch (err) {
|
||||
fail(err)
|
||||
} finally {
|
||||
setStore("sending", false)
|
||||
}
|
||||
}
|
||||
|
||||
const reject = () => {
|
||||
const reject = async () => {
|
||||
if (store.sending) return
|
||||
|
||||
setStore("sending", true)
|
||||
sdk.client.question
|
||||
.reject({ requestID: props.request.id })
|
||||
.catch(fail)
|
||||
.finally(() => setStore("sending", false))
|
||||
try {
|
||||
await sdk.client.question.reject({ requestID: props.request.id })
|
||||
} catch (err) {
|
||||
fail(err)
|
||||
} finally {
|
||||
setStore("sending", false)
|
||||
}
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
reply(questions().map((_, i) => store.answers[i] ?? []))
|
||||
void reply(questions().map((_, i) => store.answers[i] ?? []))
|
||||
}
|
||||
|
||||
const pick = (answer: string, custom: boolean = false) => {
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = [answer]
|
||||
setStore("answers", answers)
|
||||
setStore("answers", store.tab, [answer])
|
||||
|
||||
if (custom) {
|
||||
const inputs = [...store.custom]
|
||||
inputs[store.tab] = answer
|
||||
setStore("custom", inputs)
|
||||
setStore("custom", store.tab, answer)
|
||||
}
|
||||
|
||||
if (single()) {
|
||||
reply([[answer]])
|
||||
void reply([[answer]])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -82,15 +84,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
}
|
||||
|
||||
const toggle = (answer: string) => {
|
||||
const existing = store.answers[store.tab] ?? []
|
||||
const next = [...existing]
|
||||
const index = next.indexOf(answer)
|
||||
if (index === -1) next.push(answer)
|
||||
if (index !== -1) next.splice(index, 1)
|
||||
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = next
|
||||
setStore("answers", answers)
|
||||
setStore("answers", store.tab, (current = []) => {
|
||||
if (current.includes(answer)) return current.filter((item) => item !== answer)
|
||||
return [...current, answer]
|
||||
})
|
||||
}
|
||||
|
||||
const selectTab = (index: number) => {
|
||||
@@ -126,13 +123,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
}
|
||||
|
||||
if (multi()) {
|
||||
const existing = store.answers[store.tab] ?? []
|
||||
const next = [...existing]
|
||||
if (!next.includes(value)) next.push(value)
|
||||
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = next
|
||||
setStore("answers", answers)
|
||||
setStore("answers", store.tab, (current = []) => {
|
||||
if (current.includes(value)) return current
|
||||
return [...current, value]
|
||||
})
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
@@ -225,9 +219,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
value={input()}
|
||||
disabled={store.sending}
|
||||
onInput={(e) => {
|
||||
const inputs = [...store.custom]
|
||||
inputs[store.tab] = e.currentTarget.value
|
||||
setStore("custom", inputs)
|
||||
setStore("custom", store.tab, e.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" variant="primary" size="small" disabled={store.sending}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { serverDisplayName } from "@/context/server"
|
||||
import type { ServerHealth } from "@/utils/server-health"
|
||||
|
||||
@@ -17,6 +17,7 @@ export function ServerRow(props: ServerRowProps) {
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
let nameRef: HTMLSpanElement | undefined
|
||||
let versionRef: HTMLSpanElement | undefined
|
||||
const name = createMemo(() => serverDisplayName(props.url))
|
||||
|
||||
const check = () => {
|
||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||
@@ -25,25 +26,24 @@ export function ServerRow(props: ServerRowProps) {
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
name()
|
||||
props.url
|
||||
props.status?.version
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(check)
|
||||
return
|
||||
}
|
||||
check()
|
||||
queueMicrotask(check)
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
check()
|
||||
if (typeof window === "undefined") return
|
||||
window.addEventListener("resize", check)
|
||||
onCleanup(() => window.removeEventListener("resize", check))
|
||||
if (typeof ResizeObserver !== "function") return
|
||||
const observer = new ResizeObserver(check)
|
||||
if (nameRef) observer.observe(nameRef)
|
||||
if (versionRef) observer.observe(versionRef)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
const tooltipValue = () => (
|
||||
<span class="flex items-center gap-2">
|
||||
<span>{serverDisplayName(props.url)}</span>
|
||||
<span>{name()}</span>
|
||||
<Show when={props.status?.version}>
|
||||
<span class="text-text-invert-base">{props.status?.version}</span>
|
||||
</Show>
|
||||
@@ -62,7 +62,7 @@ export function ServerRow(props: ServerRowProps) {
|
||||
}}
|
||||
/>
|
||||
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
|
||||
{serverDisplayName(props.url)}
|
||||
{name()}
|
||||
</span>
|
||||
<Show when={props.status?.version}>
|
||||
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
|
||||
|
||||
@@ -13,6 +13,17 @@ interface SessionContextUsageProps {
|
||||
variant?: "button" | "indicator"
|
||||
}
|
||||
|
||||
function openSessionContext(args: {
|
||||
view: ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
layout: ReturnType<typeof useLayout>
|
||||
tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||
}) {
|
||||
if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open()
|
||||
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
|
||||
args.tabs.open("context")
|
||||
args.tabs.setActive("context")
|
||||
}
|
||||
|
||||
export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const sync = useSync()
|
||||
const params = useParams()
|
||||
@@ -41,11 +52,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
|
||||
const openContext = () => {
|
||||
if (!params.id) return
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
layout.fileTree.open()
|
||||
layout.fileTree.setTab("all")
|
||||
tabs().open("context")
|
||||
tabs().setActive("context")
|
||||
openSessionContext({
|
||||
view: view(),
|
||||
layout,
|
||||
tabs: tabs(),
|
||||
})
|
||||
}
|
||||
|
||||
const circle = () => (
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
|
||||
|
||||
const user = (id: string) => {
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
const assistant = (id: string) => {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
describe("estimateSessionContextBreakdown", () => {
|
||||
test("estimates tokens and keeps remaining tokens as other", () => {
|
||||
const messages = [user("u1"), assistant("a1")]
|
||||
const parts = {
|
||||
u1: [{ type: "text", text: "hello world" }] as unknown as Part[],
|
||||
a1: [{ type: "text", text: "assistant response" }] as unknown as Part[],
|
||||
}
|
||||
|
||||
const output = estimateSessionContextBreakdown({
|
||||
messages,
|
||||
parts,
|
||||
input: 20,
|
||||
systemPrompt: "system prompt",
|
||||
})
|
||||
|
||||
const map = Object.fromEntries(output.map((segment) => [segment.key, segment.tokens]))
|
||||
expect(map.system).toBe(4)
|
||||
expect(map.user).toBe(3)
|
||||
expect(map.assistant).toBe(5)
|
||||
expect(map.other).toBe(8)
|
||||
})
|
||||
|
||||
test("scales segments when estimates exceed input", () => {
|
||||
const messages = [user("u1"), assistant("a1")]
|
||||
const parts = {
|
||||
u1: [{ type: "text", text: "x".repeat(400) }] as unknown as Part[],
|
||||
a1: [{ type: "text", text: "y".repeat(400) }] as unknown as Part[],
|
||||
}
|
||||
|
||||
const output = estimateSessionContextBreakdown({
|
||||
messages,
|
||||
parts,
|
||||
input: 10,
|
||||
systemPrompt: "z".repeat(200),
|
||||
})
|
||||
|
||||
const total = output.reduce((sum, segment) => sum + segment.tokens, 0)
|
||||
expect(total).toBeLessThanOrEqual(10)
|
||||
expect(output.every((segment) => segment.width <= 100)).toBeTrue()
|
||||
})
|
||||
})
|
||||
132
packages/app/src/components/session/session-context-breakdown.ts
Normal file
132
packages/app/src/components/session/session-context-breakdown.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
|
||||
|
||||
export type SessionContextBreakdownSegment = {
|
||||
key: SessionContextBreakdownKey
|
||||
tokens: number
|
||||
width: number
|
||||
percent: number
|
||||
}
|
||||
|
||||
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
|
||||
const toPercent = (tokens: number, input: number) => (tokens / input) * 100
|
||||
const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
|
||||
|
||||
const charsFromUserPart = (part: Part) => {
|
||||
if (part.type === "text") return part.text.length
|
||||
if (part.type === "file") return part.source?.text.value.length ?? 0
|
||||
if (part.type === "agent") return part.source?.value.length ?? 0
|
||||
return 0
|
||||
}
|
||||
|
||||
const charsFromAssistantPart = (part: Part) => {
|
||||
if (part.type === "text") return { assistant: part.text.length, tool: 0 }
|
||||
if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
|
||||
if (part.type !== "tool") return { assistant: 0, tool: 0 }
|
||||
|
||||
const input = Object.keys(part.state.input).length * 16
|
||||
if (part.state.status === "pending") return { assistant: 0, tool: input + part.state.raw.length }
|
||||
if (part.state.status === "completed") return { assistant: 0, tool: input + part.state.output.length }
|
||||
if (part.state.status === "error") return { assistant: 0, tool: input + part.state.error.length }
|
||||
return { assistant: 0, tool: input }
|
||||
}
|
||||
|
||||
const build = (
|
||||
tokens: { system: number; user: number; assistant: number; tool: number; other: number },
|
||||
input: number,
|
||||
) => {
|
||||
return [
|
||||
{
|
||||
key: "system",
|
||||
tokens: tokens.system,
|
||||
},
|
||||
{
|
||||
key: "user",
|
||||
tokens: tokens.user,
|
||||
},
|
||||
{
|
||||
key: "assistant",
|
||||
tokens: tokens.assistant,
|
||||
},
|
||||
{
|
||||
key: "tool",
|
||||
tokens: tokens.tool,
|
||||
},
|
||||
{
|
||||
key: "other",
|
||||
tokens: tokens.other,
|
||||
},
|
||||
]
|
||||
.filter((x) => x.tokens > 0)
|
||||
.map((x) => ({
|
||||
key: x.key,
|
||||
tokens: x.tokens,
|
||||
width: toPercent(x.tokens, input),
|
||||
percent: toPercentLabel(x.tokens, input),
|
||||
})) as SessionContextBreakdownSegment[]
|
||||
}
|
||||
|
||||
export function estimateSessionContextBreakdown(args: {
|
||||
messages: Message[]
|
||||
parts: Record<string, Part[] | undefined>
|
||||
input: number
|
||||
systemPrompt?: string
|
||||
}) {
|
||||
if (!args.input) return []
|
||||
|
||||
const counts = args.messages.reduce(
|
||||
(acc, msg) => {
|
||||
const parts = args.parts[msg.id] ?? []
|
||||
if (msg.role === "user") {
|
||||
const user = parts.reduce((sum, part) => sum + charsFromUserPart(part), 0)
|
||||
return { ...acc, user: acc.user + user }
|
||||
}
|
||||
|
||||
if (msg.role !== "assistant") return acc
|
||||
const assistant = parts.reduce(
|
||||
(sum, part) => {
|
||||
const next = charsFromAssistantPart(part)
|
||||
return {
|
||||
assistant: sum.assistant + next.assistant,
|
||||
tool: sum.tool + next.tool,
|
||||
}
|
||||
},
|
||||
{ assistant: 0, tool: 0 },
|
||||
)
|
||||
return {
|
||||
...acc,
|
||||
assistant: acc.assistant + assistant.assistant,
|
||||
tool: acc.tool + assistant.tool,
|
||||
}
|
||||
},
|
||||
{
|
||||
system: args.systemPrompt?.length ?? 0,
|
||||
user: 0,
|
||||
assistant: 0,
|
||||
tool: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const tokens = {
|
||||
system: estimateTokens(counts.system),
|
||||
user: estimateTokens(counts.user),
|
||||
assistant: estimateTokens(counts.assistant),
|
||||
tool: estimateTokens(counts.tool),
|
||||
}
|
||||
const estimated = tokens.system + tokens.user + tokens.assistant + tokens.tool
|
||||
|
||||
if (estimated <= args.input) {
|
||||
return build({ ...tokens, other: args.input - estimated }, args.input)
|
||||
}
|
||||
|
||||
const scale = args.input / estimated
|
||||
const scaled = {
|
||||
system: Math.floor(tokens.system * scale),
|
||||
user: Math.floor(tokens.user * scale),
|
||||
assistant: Math.floor(tokens.assistant * scale),
|
||||
tool: Math.floor(tokens.tool * scale),
|
||||
}
|
||||
const total = scaled.system + scaled.user + scaled.assistant + scaled.tool
|
||||
return build({ ...scaled, other: Math.max(0, args.input - total) }, args.input)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { DateTime } from "luxon"
|
||||
|
||||
export function createSessionContextFormatter(locale: string) {
|
||||
return {
|
||||
number(value: number | null | undefined) {
|
||||
if (value === undefined) return "—"
|
||||
if (value === null) return "—"
|
||||
return value.toLocaleString(locale)
|
||||
},
|
||||
percent(value: number | null | undefined) {
|
||||
if (value === undefined) return "—"
|
||||
if (value === null) return "—"
|
||||
return value.toLocaleString(locale) + "%"
|
||||
},
|
||||
time(value: number | undefined) {
|
||||
if (!value) return "—"
|
||||
return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -91,4 +91,11 @@ describe("getSessionContextMetrics", () => {
|
||||
expect(two.context?.message.id).toBe("a2")
|
||||
expect(two.totalCost).toBe(1)
|
||||
})
|
||||
|
||||
test("returns empty metrics when inputs are undefined", () => {
|
||||
const metrics = getSessionContextMetrics(undefined, undefined)
|
||||
|
||||
expect(metrics.totalCost).toBe(0)
|
||||
expect(metrics.context).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ const lastAssistantWithTokens = (messages: Message[]) => {
|
||||
}
|
||||
}
|
||||
|
||||
const build = (messages: Message[], providers: Provider[]): Metrics => {
|
||||
const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => {
|
||||
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
|
||||
const message = lastAssistantWithTokens(messages)
|
||||
if (!message) return { totalCost, context: undefined }
|
||||
@@ -77,6 +77,6 @@ const build = (messages: Message[], providers: Provider[]): Metrics => {
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionContextMetrics(messages: Message[], providers: Provider[]) {
|
||||
export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) {
|
||||
return build(messages, providers)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { DateTime } from "luxon"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
@@ -14,6 +13,8 @@ import { Markdown } from "@opencode-ai/ui/markdown"
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||
import { createSessionContextFormatter } from "./session-context-format"
|
||||
|
||||
interface SessionContextTabProps {
|
||||
messages: () => Message[]
|
||||
@@ -22,6 +23,74 @@ interface SessionContextTabProps {
|
||||
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
|
||||
}
|
||||
|
||||
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
|
||||
system: "var(--syntax-info)",
|
||||
user: "var(--syntax-success)",
|
||||
assistant: "var(--syntax-property)",
|
||||
tool: "var(--syntax-warning)",
|
||||
other: "var(--syntax-comment)",
|
||||
}
|
||||
|
||||
function Stat(props: { label: string; value: JSX.Element }) {
|
||||
return (
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-12-regular text-text-weak">{props.label}</div>
|
||||
<div class="text-12-medium text-text-strong">{props.value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) {
|
||||
const file = createMemo(() => {
|
||||
const parts = props.getParts(props.message.id)
|
||||
const contents = JSON.stringify({ message: props.message, parts }, null, 2)
|
||||
return {
|
||||
name: `${props.message.role}-${props.message.id}.json`,
|
||||
contents,
|
||||
cacheKey: checksum(contents),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Code
|
||||
file={file()}
|
||||
overflow="wrap"
|
||||
class="select-text"
|
||||
onRendered={() => requestAnimationFrame(props.onRendered)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RawMessage(props: {
|
||||
message: Message
|
||||
getParts: (id: string) => Part[]
|
||||
onRendered: () => void
|
||||
time: (value: number | undefined) => string
|
||||
}) {
|
||||
return (
|
||||
<Accordion.Item value={props.message.id}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div class="flex items-center justify-between gap-2 w-full">
|
||||
<div class="min-w-0 truncate">
|
||||
{props.message.role} <span class="text-text-base">• {props.message.id}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div>
|
||||
<Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content class="bg-background-base">
|
||||
<div class="p-3">
|
||||
<RawMessageContent message={props.message} getParts={props.getParts} onRendered={props.onRendered} />
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionContextTab(props: SessionContextTabProps) {
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
@@ -37,6 +106,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
|
||||
const ctx = createMemo(() => metrics().context)
|
||||
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
|
||||
|
||||
const cost = createMemo(() => {
|
||||
return usd().format(metrics().totalCost)
|
||||
@@ -62,23 +132,6 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
return trimmed
|
||||
})
|
||||
|
||||
const number = (value: number | null | undefined) => {
|
||||
if (value === undefined) return "—"
|
||||
if (value === null) return "—"
|
||||
return value.toLocaleString(language.locale())
|
||||
}
|
||||
|
||||
const percent = (value: number | null | undefined) => {
|
||||
if (value === undefined) return "—"
|
||||
if (value === null) return "—"
|
||||
return value.toLocaleString(language.locale()) + "%"
|
||||
}
|
||||
|
||||
const time = (value: number | undefined) => {
|
||||
if (!value) return "—"
|
||||
return DateTime.fromMillis(value).setLocale(language.locale()).toLocaleString(DateTime.DATETIME_MED)
|
||||
}
|
||||
|
||||
const providerLabel = createMemo(() => {
|
||||
const c = ctx()
|
||||
if (!c) return "—"
|
||||
@@ -96,197 +149,51 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
|
||||
() => {
|
||||
const c = ctx()
|
||||
if (!c) return []
|
||||
const input = c.input
|
||||
if (!input) return []
|
||||
|
||||
const out = {
|
||||
system: systemPrompt()?.length ?? 0,
|
||||
user: 0,
|
||||
assistant: 0,
|
||||
tool: 0,
|
||||
}
|
||||
|
||||
for (const msg of props.messages()) {
|
||||
const parts = (sync.data.part[msg.id] ?? []) as Part[]
|
||||
|
||||
if (msg.role === "user") {
|
||||
for (const part of parts) {
|
||||
if (part.type === "text") out.user += part.text.length
|
||||
if (part.type === "file") out.user += part.source?.text.value.length ?? 0
|
||||
if (part.type === "agent") out.user += part.source?.value.length ?? 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.role === "assistant") {
|
||||
for (const part of parts) {
|
||||
if (part.type === "text") out.assistant += part.text.length
|
||||
if (part.type === "reasoning") out.assistant += part.text.length
|
||||
if (part.type === "tool") {
|
||||
out.tool += Object.keys(part.state.input).length * 16
|
||||
if (part.state.status === "pending") out.tool += part.state.raw.length
|
||||
if (part.state.status === "completed") out.tool += part.state.output.length
|
||||
if (part.state.status === "error") out.tool += part.state.error.length
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
|
||||
const system = estimateTokens(out.system)
|
||||
const user = estimateTokens(out.user)
|
||||
const assistant = estimateTokens(out.assistant)
|
||||
const tool = estimateTokens(out.tool)
|
||||
const estimated = system + user + assistant + tool
|
||||
|
||||
const pct = (tokens: number) => (tokens / input) * 100
|
||||
const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%"
|
||||
|
||||
const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => {
|
||||
return [
|
||||
{
|
||||
key: "system",
|
||||
label: language.t("context.breakdown.system"),
|
||||
tokens: tokens.system,
|
||||
width: pct(tokens.system),
|
||||
percent: pctLabel(tokens.system),
|
||||
color: "var(--syntax-info)",
|
||||
},
|
||||
{
|
||||
key: "user",
|
||||
label: language.t("context.breakdown.user"),
|
||||
tokens: tokens.user,
|
||||
width: pct(tokens.user),
|
||||
percent: pctLabel(tokens.user),
|
||||
color: "var(--syntax-success)",
|
||||
},
|
||||
{
|
||||
key: "assistant",
|
||||
label: language.t("context.breakdown.assistant"),
|
||||
tokens: tokens.assistant,
|
||||
width: pct(tokens.assistant),
|
||||
percent: pctLabel(tokens.assistant),
|
||||
color: "var(--syntax-property)",
|
||||
},
|
||||
{
|
||||
key: "tool",
|
||||
label: language.t("context.breakdown.tool"),
|
||||
tokens: tokens.tool,
|
||||
width: pct(tokens.tool),
|
||||
percent: pctLabel(tokens.tool),
|
||||
color: "var(--syntax-warning)",
|
||||
},
|
||||
{
|
||||
key: "other",
|
||||
label: language.t("context.breakdown.other"),
|
||||
tokens: tokens.other,
|
||||
width: pct(tokens.other),
|
||||
percent: pctLabel(tokens.other),
|
||||
color: "var(--syntax-comment)",
|
||||
},
|
||||
].filter((x) => x.tokens > 0)
|
||||
}
|
||||
|
||||
if (estimated <= input) {
|
||||
return build({ system, user, assistant, tool, other: input - estimated })
|
||||
}
|
||||
|
||||
const scale = input / estimated
|
||||
const scaled = {
|
||||
system: Math.floor(system * scale),
|
||||
user: Math.floor(user * scale),
|
||||
assistant: Math.floor(assistant * scale),
|
||||
tool: Math.floor(tool * scale),
|
||||
}
|
||||
const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool
|
||||
return build({ ...scaled, other: Math.max(0, input - scaledTotal) })
|
||||
if (!c?.input) return []
|
||||
return estimateSessionContextBreakdown({
|
||||
messages: props.messages(),
|
||||
parts: sync.data.part as Record<string, Part[] | undefined>,
|
||||
input: c.input,
|
||||
systemPrompt: systemPrompt(),
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
function Stat(statProps: { label: string; value: JSX.Element }) {
|
||||
return (
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-12-regular text-text-weak">{statProps.label}</div>
|
||||
<div class="text-12-medium text-text-strong">{statProps.value}</div>
|
||||
</div>
|
||||
)
|
||||
const breakdownLabel = (key: SessionContextBreakdownKey) => {
|
||||
if (key === "system") return language.t("context.breakdown.system")
|
||||
if (key === "user") return language.t("context.breakdown.user")
|
||||
if (key === "assistant") return language.t("context.breakdown.assistant")
|
||||
if (key === "tool") return language.t("context.breakdown.tool")
|
||||
return language.t("context.breakdown.other")
|
||||
}
|
||||
|
||||
const stats = createMemo(() => {
|
||||
const c = ctx()
|
||||
const count = counts()
|
||||
return [
|
||||
{ label: language.t("context.stats.session"), value: props.info()?.title ?? params.id ?? "—" },
|
||||
{ label: language.t("context.stats.messages"), value: count.all.toLocaleString(language.locale()) },
|
||||
{ label: language.t("context.stats.provider"), value: providerLabel() },
|
||||
{ label: language.t("context.stats.model"), value: modelLabel() },
|
||||
{ label: language.t("context.stats.limit"), value: number(c?.limit) },
|
||||
{ label: language.t("context.stats.totalTokens"), value: number(c?.total) },
|
||||
{ label: language.t("context.stats.usage"), value: percent(c?.usage) },
|
||||
{ label: language.t("context.stats.inputTokens"), value: number(c?.input) },
|
||||
{ label: language.t("context.stats.outputTokens"), value: number(c?.output) },
|
||||
{ label: language.t("context.stats.reasoningTokens"), value: number(c?.reasoning) },
|
||||
{
|
||||
label: language.t("context.stats.cacheTokens"),
|
||||
value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}`,
|
||||
},
|
||||
{ label: language.t("context.stats.userMessages"), value: count.user.toLocaleString(language.locale()) },
|
||||
{
|
||||
label: language.t("context.stats.assistantMessages"),
|
||||
value: count.assistant.toLocaleString(language.locale()),
|
||||
},
|
||||
{ label: language.t("context.stats.totalCost"), value: cost() },
|
||||
{ label: language.t("context.stats.sessionCreated"), value: time(props.info()?.time.created) },
|
||||
{ label: language.t("context.stats.lastActivity"), value: time(c?.message.time.created) },
|
||||
] satisfies { label: string; value: JSX.Element }[]
|
||||
})
|
||||
|
||||
function RawMessageContent(msgProps: { message: Message }) {
|
||||
const file = createMemo(() => {
|
||||
const parts = (sync.data.part[msgProps.message.id] ?? []) as Part[]
|
||||
const contents = JSON.stringify({ message: msgProps.message, parts }, null, 2)
|
||||
return {
|
||||
name: `${msgProps.message.role}-${msgProps.message.id}.json`,
|
||||
contents,
|
||||
cacheKey: checksum(contents),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Code file={file()} overflow="wrap" class="select-text" onRendered={() => requestAnimationFrame(restoreScroll)} />
|
||||
)
|
||||
}
|
||||
|
||||
function RawMessage(msgProps: { message: Message }) {
|
||||
return (
|
||||
<Accordion.Item value={msgProps.message.id}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div class="flex items-center justify-between gap-2 w-full">
|
||||
<div class="min-w-0 truncate">
|
||||
{msgProps.message.role} <span class="text-text-base">• {msgProps.message.id}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="shrink-0 text-12-regular text-text-weak">{time(msgProps.message.time.created)}</div>
|
||||
<Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content class="bg-background-base">
|
||||
<div class="p-3">
|
||||
<RawMessageContent message={msgProps.message} />
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}
|
||||
const stats = [
|
||||
{ label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" },
|
||||
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
|
||||
{ label: "context.stats.provider", value: providerLabel },
|
||||
{ label: "context.stats.model", value: modelLabel },
|
||||
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
||||
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
|
||||
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
|
||||
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) },
|
||||
{
|
||||
label: "context.stats.cacheTokens",
|
||||
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
|
||||
},
|
||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
|
||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
|
||||
{ label: "context.stats.totalCost", value: cost },
|
||||
{ label: "context.stats.sessionCreated", value: () => formatter().time(props.info()?.time.created) },
|
||||
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
||||
] satisfies { label: string; value: () => JSX.Element }[]
|
||||
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[]
|
||||
|
||||
const restoreScroll = () => {
|
||||
const el = scroll
|
||||
@@ -343,7 +250,9 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
>
|
||||
<div class="px-6 pt-4 flex flex-col gap-10">
|
||||
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
|
||||
<For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For>
|
||||
<For each={stats}>
|
||||
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={breakdown().length > 0}>
|
||||
@@ -356,7 +265,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
class="h-full"
|
||||
style={{
|
||||
width: `${segment.width}%`,
|
||||
"background-color": segment.color,
|
||||
"background-color": BREAKDOWN_COLOR[segment.key],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -366,9 +275,9 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
<For each={breakdown()}>
|
||||
{(segment) => (
|
||||
<div class="flex items-center gap-1 text-11-regular text-text-weak">
|
||||
<div class="size-2 rounded-sm" style={{ "background-color": segment.color }} />
|
||||
<div>{segment.label}</div>
|
||||
<div class="text-text-weaker">{segment.percent}</div>
|
||||
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
|
||||
<div>{breakdownLabel(segment.key)}</div>
|
||||
<div class="text-text-weaker">{segment.percent.toLocaleString(language.locale())}%</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
@@ -391,7 +300,11 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
||||
<Accordion multiple>
|
||||
<For each={props.messages()}>{(message) => <RawMessage message={message} />}</For>
|
||||
<For each={props.messages()}>
|
||||
{(message) => (
|
||||
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
|
||||
)}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
|
||||
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useParams } from "@solidjs/router"
|
||||
@@ -25,6 +25,164 @@ import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { StatusPopover } from "../status-popover"
|
||||
|
||||
const OPEN_APPS = [
|
||||
"vscode",
|
||||
"cursor",
|
||||
"zed",
|
||||
"textmate",
|
||||
"antigravity",
|
||||
"finder",
|
||||
"terminal",
|
||||
"iterm2",
|
||||
"ghostty",
|
||||
"xcode",
|
||||
"android-studio",
|
||||
"powershell",
|
||||
"sublime-text",
|
||||
] as const
|
||||
|
||||
type OpenApp = (typeof OPEN_APPS)[number]
|
||||
type OS = "macos" | "windows" | "linux" | "unknown"
|
||||
|
||||
const MAC_APPS = [
|
||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
|
||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
|
||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
|
||||
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
|
||||
{ id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
|
||||
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
|
||||
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
|
||||
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
|
||||
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
|
||||
{ id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
|
||||
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||
] as const
|
||||
|
||||
const WINDOWS_APPS = [
|
||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
||||
{ id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
|
||||
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||
] as const
|
||||
|
||||
const LINUX_APPS = [
|
||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
||||
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||
] as const
|
||||
|
||||
type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number]
|
||||
type OpenIcon = OpenApp | "file-explorer"
|
||||
const OPEN_ICON_BASE = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
|
||||
|
||||
const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]")
|
||||
|
||||
const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
|
||||
if (platform.platform === "desktop" && platform.os) return platform.os
|
||||
if (typeof navigator !== "object") return "unknown"
|
||||
const value = navigator.platform || navigator.userAgent
|
||||
if (/Mac/i.test(value)) return "macos"
|
||||
if (/Win/i.test(value)) return "windows"
|
||||
if (/Linux/i.test(value)) return "linux"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
function useSessionShare(args: {
|
||||
globalSDK: ReturnType<typeof useGlobalSDK>
|
||||
currentSession: () =>
|
||||
| {
|
||||
id: string
|
||||
share?: {
|
||||
url?: string
|
||||
}
|
||||
}
|
||||
| undefined
|
||||
projectDirectory: () => string
|
||||
platform: ReturnType<typeof usePlatform>
|
||||
}) {
|
||||
const [state, setState] = createStore({
|
||||
share: false,
|
||||
unshare: false,
|
||||
copied: false,
|
||||
timer: undefined as number | undefined,
|
||||
})
|
||||
const shareUrl = createMemo(() => args.currentSession()?.share?.url)
|
||||
|
||||
createEffect(() => {
|
||||
const url = shareUrl()
|
||||
if (url) return
|
||||
if (state.timer) window.clearTimeout(state.timer)
|
||||
setState({ copied: false, timer: undefined })
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (state.timer) window.clearTimeout(state.timer)
|
||||
})
|
||||
|
||||
const shareSession = () => {
|
||||
const session = args.currentSession()
|
||||
if (!session || state.share) return
|
||||
setState("share", true)
|
||||
args.globalSDK.client.session
|
||||
.share({ sessionID: session.id, directory: args.projectDirectory() })
|
||||
.catch((error) => {
|
||||
console.error("Failed to share session", error)
|
||||
})
|
||||
.finally(() => {
|
||||
setState("share", false)
|
||||
})
|
||||
}
|
||||
|
||||
const unshareSession = () => {
|
||||
const session = args.currentSession()
|
||||
if (!session || state.unshare) return
|
||||
setState("unshare", true)
|
||||
args.globalSDK.client.session
|
||||
.unshare({ sessionID: session.id, directory: args.projectDirectory() })
|
||||
.catch((error) => {
|
||||
console.error("Failed to unshare session", error)
|
||||
})
|
||||
.finally(() => {
|
||||
setState("unshare", false)
|
||||
})
|
||||
}
|
||||
|
||||
const copyLink = (onError: (error: unknown) => void) => {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
if (state.timer) window.clearTimeout(state.timer)
|
||||
setState("copied", true)
|
||||
const timer = window.setTimeout(() => {
|
||||
setState("copied", false)
|
||||
setState("timer", undefined)
|
||||
}, 3000)
|
||||
setState("timer", timer)
|
||||
})
|
||||
.catch(onError)
|
||||
}
|
||||
|
||||
const viewShare = () => {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
args.platform.openLink(url)
|
||||
}
|
||||
|
||||
return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare }
|
||||
}
|
||||
|
||||
export function SessionHeader() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const layout = useLayout()
|
||||
@@ -53,62 +211,7 @@ export function SessionHeader() {
|
||||
const showShare = createMemo(() => shareEnabled() && !!currentSession())
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
|
||||
const OPEN_APPS = [
|
||||
"vscode",
|
||||
"cursor",
|
||||
"zed",
|
||||
"textmate",
|
||||
"antigravity",
|
||||
"finder",
|
||||
"terminal",
|
||||
"iterm2",
|
||||
"ghostty",
|
||||
"xcode",
|
||||
"android-studio",
|
||||
"powershell",
|
||||
"sublime-text",
|
||||
] as const
|
||||
type OpenApp = (typeof OPEN_APPS)[number]
|
||||
|
||||
const MAC_APPS = [
|
||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
|
||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
|
||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
|
||||
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
|
||||
{ id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
|
||||
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
|
||||
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
|
||||
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
|
||||
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
|
||||
{ id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
|
||||
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||
] as const
|
||||
|
||||
const WINDOWS_APPS = [
|
||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
||||
{ id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
|
||||
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||
] as const
|
||||
|
||||
const LINUX_APPS = [
|
||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
||||
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||
] as const
|
||||
|
||||
const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => {
|
||||
if (platform.platform === "desktop" && platform.os) return platform.os
|
||||
if (typeof navigator !== "object") return "unknown"
|
||||
const value = navigator.platform || navigator.userAgent
|
||||
if (/Mac/i.test(value)) return "macos"
|
||||
if (/Win/i.test(value)) return "windows"
|
||||
if (/Linux/i.test(value)) return "linux"
|
||||
return "unknown"
|
||||
})
|
||||
const os = createMemo(() => detectOS(platform))
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true })
|
||||
|
||||
@@ -154,10 +257,6 @@ export function SessionHeader() {
|
||||
] as const
|
||||
})
|
||||
|
||||
type OpenIcon = OpenApp | "file-explorer"
|
||||
const base = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
|
||||
const size = (id: OpenIcon) => (base.has(id) ? "size-4" : "size-[19px]")
|
||||
|
||||
const checksReady = createMemo(() => {
|
||||
if (platform.platform !== "desktop") return true
|
||||
if (!platform.checkAppExists) return true
|
||||
@@ -166,6 +265,7 @@ export function SessionHeader() {
|
||||
})
|
||||
|
||||
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
|
||||
const [menu, setMenu] = createStore({ open: false })
|
||||
|
||||
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
||||
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
||||
@@ -185,13 +285,7 @@ export function SessionHeader() {
|
||||
|
||||
const item = options().find((o) => o.id === app)
|
||||
const openWith = item && "openWith" in item ? item.openWith : undefined
|
||||
Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => showRequestError(language, err))
|
||||
}
|
||||
|
||||
const copyPath = () => {
|
||||
@@ -207,93 +301,24 @@ export function SessionHeader() {
|
||||
description: directory,
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => showRequestError(language, err))
|
||||
}
|
||||
|
||||
const [state, setState] = createStore({
|
||||
share: false,
|
||||
unshare: false,
|
||||
copied: false,
|
||||
timer: undefined as number | undefined,
|
||||
})
|
||||
const shareUrl = createMemo(() => currentSession()?.share?.url)
|
||||
|
||||
createEffect(() => {
|
||||
const url = shareUrl()
|
||||
if (url) return
|
||||
if (state.timer) window.clearTimeout(state.timer)
|
||||
setState({ copied: false, timer: undefined })
|
||||
const share = useSessionShare({
|
||||
globalSDK,
|
||||
currentSession,
|
||||
projectDirectory,
|
||||
platform,
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (state.timer) window.clearTimeout(state.timer)
|
||||
})
|
||||
|
||||
function shareSession() {
|
||||
const session = currentSession()
|
||||
if (!session || state.share) return
|
||||
setState("share", true)
|
||||
globalSDK.client.session
|
||||
.share({ sessionID: session.id, directory: projectDirectory() })
|
||||
.catch((error) => {
|
||||
console.error("Failed to share session", error)
|
||||
})
|
||||
.finally(() => {
|
||||
setState("share", false)
|
||||
})
|
||||
}
|
||||
|
||||
function unshareSession() {
|
||||
const session = currentSession()
|
||||
if (!session || state.unshare) return
|
||||
setState("unshare", true)
|
||||
globalSDK.client.session
|
||||
.unshare({ sessionID: session.id, directory: projectDirectory() })
|
||||
.catch((error) => {
|
||||
console.error("Failed to unshare session", error)
|
||||
})
|
||||
.finally(() => {
|
||||
setState("unshare", false)
|
||||
})
|
||||
}
|
||||
|
||||
function copyLink() {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
if (state.timer) window.clearTimeout(state.timer)
|
||||
setState("copied", true)
|
||||
const timer = window.setTimeout(() => {
|
||||
setState("copied", false)
|
||||
setState("timer", undefined)
|
||||
}, 3000)
|
||||
setState("timer", timer)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to copy share link", error)
|
||||
})
|
||||
}
|
||||
|
||||
function viewShare() {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
platform.openLink(url)
|
||||
}
|
||||
|
||||
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
|
||||
const leftMount = createMemo(
|
||||
() => document.getElementById("opencode-titlebar-left") ?? document.getElementById("opencode-titlebar-center"),
|
||||
)
|
||||
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={centerMount()}>
|
||||
<Show when={leftMount()}>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<button
|
||||
@@ -328,17 +353,19 @@ export function SessionHeader() {
|
||||
<Show
|
||||
when={canOpen()}
|
||||
fallback={
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="rounded-sm h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none"
|
||||
onClick={copyPath}
|
||||
aria-label={language.t("session.header.open.copyPath")}
|
||||
>
|
||||
<Icon name="copy" size="small" class="text-icon-base" />
|
||||
<span class="text-12-regular text-text-strong">
|
||||
{language.t("session.header.open.copyPath")}
|
||||
</span>
|
||||
</Button>
|
||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="rounded-none h-full py-0 pr-3 pl-2 gap-2 border-none shadow-none"
|
||||
onClick={copyPath}
|
||||
aria-label={language.t("session.header.open.copyPath")}
|
||||
>
|
||||
<Icon name="copy" size="small" class="text-icon-base" />
|
||||
<span class="text-12-regular text-text-strong">
|
||||
{language.t("session.header.open.copyPath")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
@@ -355,7 +382,12 @@ export function SessionHeader() {
|
||||
<span class="text-12-regular text-text-strong">Open</span>
|
||||
</Button>
|
||||
<div class="self-stretch w-px bg-border-base/70" />
|
||||
<DropdownMenu gutter={6} placement="bottom-end">
|
||||
<DropdownMenu
|
||||
gutter={6}
|
||||
placement="bottom-end"
|
||||
open={menu.open}
|
||||
onOpenChange={(open) => setMenu("open", open)}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="chevron-down"
|
||||
@@ -374,21 +406,34 @@ export function SessionHeader() {
|
||||
setPrefs("app", value as OpenApp)
|
||||
}}
|
||||
>
|
||||
{options().map((o) => (
|
||||
<DropdownMenu.RadioItem value={o.id} onSelect={() => openDir(o.id)}>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||
<AppIcon id={o.icon} class={size(o.icon)} />
|
||||
</div>
|
||||
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemIndicator>
|
||||
<Icon name="check-small" size="small" class="text-icon-weak" />
|
||||
</DropdownMenu.ItemIndicator>
|
||||
</DropdownMenu.RadioItem>
|
||||
))}
|
||||
<For each={options()}>
|
||||
{(o) => (
|
||||
<DropdownMenu.RadioItem
|
||||
value={o.id}
|
||||
onSelect={() => {
|
||||
setMenu("open", false)
|
||||
openDir(o.id)
|
||||
}}
|
||||
>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||
<AppIcon id={o.icon} class={openIconSize(o.icon)} />
|
||||
</div>
|
||||
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemIndicator>
|
||||
<Icon name="check-small" size="small" class="text-icon-weak" />
|
||||
</DropdownMenu.ItemIndicator>
|
||||
</DropdownMenu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={copyPath}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setMenu("open", false)
|
||||
copyPath()
|
||||
}}
|
||||
>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||
<Icon name="copy" size="small" class="text-icon-weak" />
|
||||
</div>
|
||||
@@ -409,7 +454,7 @@ export function SessionHeader() {
|
||||
<Popover
|
||||
title={language.t("session.share.popover.title")}
|
||||
description={
|
||||
shareUrl()
|
||||
share.shareUrl()
|
||||
? language.t("session.share.popover.description.shared")
|
||||
: language.t("session.share.popover.description.unshared")
|
||||
}
|
||||
@@ -422,24 +467,24 @@ export function SessionHeader() {
|
||||
variant: "ghost",
|
||||
class:
|
||||
"rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
|
||||
classList: { "rounded-r-none": shareUrl() !== undefined },
|
||||
classList: { "rounded-r-none": share.shareUrl() !== undefined },
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={language.t("session.share.action.share")}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Show
|
||||
when={shareUrl()}
|
||||
when={share.shareUrl()}
|
||||
fallback={
|
||||
<div class="flex">
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-1/2"
|
||||
onClick={shareSession}
|
||||
disabled={state.share}
|
||||
onClick={share.shareSession}
|
||||
disabled={share.state.share}
|
||||
>
|
||||
{state.share
|
||||
{share.state.share
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</Button>
|
||||
@@ -448,7 +493,7 @@ export function SessionHeader() {
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TextField
|
||||
value={shareUrl() ?? ""}
|
||||
value={share.shareUrl() ?? ""}
|
||||
readOnly
|
||||
copyable
|
||||
copyKind="link"
|
||||
@@ -460,10 +505,10 @@ export function SessionHeader() {
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={unshareSession}
|
||||
disabled={state.unshare}
|
||||
onClick={share.unshareSession}
|
||||
disabled={share.state.unshare}
|
||||
>
|
||||
{state.unshare
|
||||
{share.state.unshare
|
||||
? language.t("session.share.action.unpublishing")
|
||||
: language.t("session.share.action.unpublish")}
|
||||
</Button>
|
||||
@@ -471,8 +516,8 @@ export function SessionHeader() {
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={viewShare}
|
||||
disabled={state.unshare}
|
||||
onClick={share.viewShare}
|
||||
disabled={share.state.unshare}
|
||||
>
|
||||
{language.t("session.share.action.view")}
|
||||
</Button>
|
||||
@@ -481,10 +526,10 @@ export function SessionHeader() {
|
||||
</Show>
|
||||
</div>
|
||||
</Popover>
|
||||
<Show when={shareUrl()} fallback={<div aria-hidden="true" />}>
|
||||
<Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}>
|
||||
<Tooltip
|
||||
value={
|
||||
state.copied
|
||||
share.state.copied
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("session.share.copy.copyLink")
|
||||
}
|
||||
@@ -492,13 +537,13 @@ export function SessionHeader() {
|
||||
gutter={8}
|
||||
>
|
||||
<IconButton
|
||||
icon={state.copied ? "check" : "link"}
|
||||
icon={share.state.copied ? "check" : "link"}
|
||||
variant="ghost"
|
||||
class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none"
|
||||
onClick={copyLink}
|
||||
disabled={state.unshare}
|
||||
onClick={() => share.copyLink((error) => showRequestError(language, error))}
|
||||
disabled={share.state.unshare}
|
||||
aria-label={
|
||||
state.copied
|
||||
share.state.copied
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("session.share.copy.copyLink")
|
||||
}
|
||||
@@ -507,7 +552,7 @@ export function SessionHeader() {
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="hidden md:flex items-center gap-3 ml-2 shrink-0">
|
||||
<div class="flex items-center gap-3 ml-2 shrink-0">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.terminal.toggle")}
|
||||
keybind={command.keybind("terminal.toggle")}
|
||||
@@ -540,7 +585,7 @@ export function SessionHeader() {
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div class="hidden md:block shrink-0">
|
||||
<div class="hidden lg:block shrink-0">
|
||||
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -570,7 +615,7 @@ export function SessionHeader() {
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div class="hidden md:block shrink-0">
|
||||
<div class="hidden lg:block shrink-0">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.fileTree.toggle")}
|
||||
keybind={command.keybind("fileTree.toggle")}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
const MAIN_WORKTREE = "main"
|
||||
const CREATE_WORKTREE = "create"
|
||||
const ROOT_CLASS =
|
||||
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]"
|
||||
|
||||
interface NewSessionViewProps {
|
||||
worktree: string
|
||||
@@ -47,7 +49,7 @@ export function NewSessionView(props: NewSessionViewProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]">
|
||||
<div class={ROOT_CLASS}>
|
||||
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
|
||||
<div class="flex justify-center items-center gap-3">
|
||||
<Icon name="folder" size="small" />
|
||||
|
||||
@@ -31,8 +31,12 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
||||
const command = useCommand()
|
||||
const sortable = createSortable(props.tab)
|
||||
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||
const content = createMemo(() => {
|
||||
const value = path()
|
||||
if (!value) return
|
||||
return <FileVisual path={value} />
|
||||
})
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
|
||||
<div class="relative h-full">
|
||||
<Tabs.Trigger
|
||||
@@ -55,7 +59,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
||||
hideCloseButton
|
||||
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||
>
|
||||
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
|
||||
<Show when={content()}>{(value) => value()}</Show>
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { JSX } from "solid-js"
|
||||
import { Show } from "solid-js"
|
||||
import { Show, createEffect, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -20,6 +20,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
menuPosition: { x: 0, y: 0 },
|
||||
blurEnabled: false,
|
||||
})
|
||||
let input: HTMLInputElement | undefined
|
||||
let blurFrame: number | undefined
|
||||
|
||||
const isDefaultTitle = () => {
|
||||
const number = props.terminal.titleNumber
|
||||
@@ -77,13 +79,6 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
setStore("blurEnabled", false)
|
||||
setStore("title", props.terminal.title)
|
||||
setStore("editing", true)
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement
|
||||
if (!input) return
|
||||
input.focus()
|
||||
input.select()
|
||||
setTimeout(() => setStore("blurEnabled", true), 100)
|
||||
}, 10)
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
@@ -114,9 +109,25 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
setStore("menuOpen", true)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.editing) return
|
||||
if (!input) return
|
||||
input.focus()
|
||||
input.select()
|
||||
if (blurFrame !== undefined) cancelAnimationFrame(blurFrame)
|
||||
blurFrame = requestAnimationFrame(() => {
|
||||
blurFrame = undefined
|
||||
setStore("blurEnabled", true)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (blurFrame === undefined) return
|
||||
cancelAnimationFrame(blurFrame)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
// @ts-ignore
|
||||
use:sortable
|
||||
class="outline-none focus:outline-none focus-visible:outline-none"
|
||||
classList={{
|
||||
@@ -153,7 +164,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
<Show when={store.editing}>
|
||||
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
|
||||
<input
|
||||
id={`terminal-title-input-${props.terminal.id}`}
|
||||
ref={input}
|
||||
type="text"
|
||||
value={store.title}
|
||||
onInput={(e) => setStore("title", e.currentTarget.value)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export const SettingsAgents: Component = () => {
|
||||
// TODO: Replace this placeholder with full agents settings controls.
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export const SettingsCommands: Component = () => {
|
||||
// TODO: Replace this placeholder with full commands settings controls.
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js"
|
||||
import { Component, Show, createMemo, createResource, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -133,6 +133,288 @@ export const SettingsGeneral: Component = () => {
|
||||
|
||||
const soundOptions = [...SOUND_OPTIONS]
|
||||
|
||||
const soundSelectProps = (current: () => string, set: (id: string) => void) => ({
|
||||
options: soundOptions,
|
||||
current: soundOptions.find((o) => o.id === current()),
|
||||
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.src)
|
||||
},
|
||||
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
if (!option) return
|
||||
set(option.id)
|
||||
playDemoSound(option.src)
|
||||
},
|
||||
variant: "secondary" as const,
|
||||
size: "small" as const,
|
||||
triggerVariant: "settings" as const,
|
||||
})
|
||||
|
||||
const AppearanceSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.language.title")}
|
||||
description={language.t("settings.general.row.language.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-language"
|
||||
options={languageOptions()}
|
||||
current={languageOptions().find((o) => o.value === language.locale())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && language.setLocale(option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.appearance.title")}
|
||||
description={language.t("settings.general.row.appearance.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-color-scheme"
|
||||
options={colorSchemeOptions()}
|
||||
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && theme.setColorScheme(option.value)}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
theme.previewColorScheme(option.value)
|
||||
return () => theme.cancelPreview()
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.theme.title")}
|
||||
description={
|
||||
<>
|
||||
{language.t("settings.general.row.theme.description")}{" "}
|
||||
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-theme"
|
||||
options={themeOptions()}
|
||||
current={themeOptions().find((o) => o.id === theme.themeId())}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.name}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
theme.setTheme(option.id)
|
||||
}}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
theme.previewTheme(option.id)
|
||||
return () => theme.cancelPreview()
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.font.title")}
|
||||
description={language.t("settings.general.row.font.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-font"
|
||||
options={fontOptionsList}
|
||||
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => language.t(o.label)}
|
||||
onSelect={(option) => option && settings.appearance.setFont(option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
|
||||
>
|
||||
{(option) => (
|
||||
<span style={{ "font-family": monoFontFamily(option?.value) }}>
|
||||
{option ? language.t(option.label) : ""}
|
||||
</span>
|
||||
)}
|
||||
</Select>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const NotificationsSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.notifications.agent.title")}
|
||||
description={language.t("settings.general.notifications.agent.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-agent">
|
||||
<Switch
|
||||
checked={settings.notifications.agent()}
|
||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.notifications.permissions.title")}
|
||||
description={language.t("settings.general.notifications.permissions.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-permissions">
|
||||
<Switch
|
||||
checked={settings.notifications.permissions()}
|
||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.notifications.errors.title")}
|
||||
description={language.t("settings.general.notifications.errors.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-errors">
|
||||
<Switch
|
||||
checked={settings.notifications.errors()}
|
||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const SoundsSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.sounds.agent.title")}
|
||||
description={language.t("settings.general.sounds.agent.description")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div data-action="settings-sounds-agent-enabled">
|
||||
<Switch
|
||||
checked={settings.sounds.agentEnabled()}
|
||||
onChange={(checked) => settings.sounds.setAgentEnabled(checked)}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
disabled={!settings.sounds.agentEnabled()}
|
||||
data-action="settings-sounds-agent"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.agent(),
|
||||
(id) => settings.sounds.setAgent(id),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.sounds.permissions.title")}
|
||||
description={language.t("settings.general.sounds.permissions.description")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div data-action="settings-sounds-permissions-enabled">
|
||||
<Switch
|
||||
checked={settings.sounds.permissionsEnabled()}
|
||||
onChange={(checked) => settings.sounds.setPermissionsEnabled(checked)}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
disabled={!settings.sounds.permissionsEnabled()}
|
||||
data-action="settings-sounds-permissions"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.permissions(),
|
||||
(id) => settings.sounds.setPermissions(id),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.sounds.errors.title")}
|
||||
description={language.t("settings.general.sounds.errors.description")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div data-action="settings-sounds-errors-enabled">
|
||||
<Switch
|
||||
checked={settings.sounds.errorsEnabled()}
|
||||
onChange={(checked) => settings.sounds.setErrorsEnabled(checked)}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
disabled={!settings.sounds.errorsEnabled()}
|
||||
data-action="settings-sounds-errors"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.errors(),
|
||||
(id) => settings.sounds.setErrors(id),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const UpdatesSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow
|
||||
title={language.t("settings.updates.row.startup.title")}
|
||||
description={language.t("settings.updates.row.startup.description")}
|
||||
>
|
||||
<div data-action="settings-updates-startup">
|
||||
<Switch
|
||||
checked={settings.updates.startup()}
|
||||
disabled={!platform.checkUpdate}
|
||||
onChange={(checked) => settings.updates.setStartup(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.releaseNotes.title")}
|
||||
description={language.t("settings.general.row.releaseNotes.description")}
|
||||
>
|
||||
<div data-action="settings-release-notes">
|
||||
<Switch
|
||||
checked={settings.general.releaseNotes()}
|
||||
onChange={(checked) => settings.general.setReleaseNotes(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.updates.row.check.title")}
|
||||
description={language.t("settings.updates.row.check.description")}
|
||||
>
|
||||
<Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}>
|
||||
{store.checking
|
||||
? language.t("settings.updates.action.checking")
|
||||
: language.t("settings.updates.action.checkNow")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
@@ -142,278 +424,41 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 w-full">
|
||||
{/* Appearance Section */}
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
||||
<AppearanceSection />
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.language.title")}
|
||||
description={language.t("settings.general.row.language.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-language"
|
||||
options={languageOptions()}
|
||||
current={languageOptions().find((o) => o.value === language.locale())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && language.setLocale(option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<NotificationsSection />
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.appearance.title")}
|
||||
description={language.t("settings.general.row.appearance.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-color-scheme"
|
||||
options={colorSchemeOptions()}
|
||||
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && theme.setColorScheme(option.value)}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
theme.previewColorScheme(option.value)
|
||||
return () => theme.cancelPreview()
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SoundsSection />
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.theme.title")}
|
||||
description={
|
||||
<>
|
||||
{language.t("settings.general.row.theme.description")}{" "}
|
||||
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-theme"
|
||||
options={themeOptions()}
|
||||
current={themeOptions().find((o) => o.id === theme.themeId())}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.name}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
theme.setTheme(option.id)
|
||||
}}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
theme.previewTheme(option.id)
|
||||
return () => theme.cancelPreview()
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
|
||||
{(_) => {
|
||||
const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
|
||||
const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.font.title")}
|
||||
description={language.t("settings.general.row.font.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-font"
|
||||
options={fontOptionsList}
|
||||
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => language.t(o.label)}
|
||||
onSelect={(option) => option && settings.appearance.setFont(option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
|
||||
>
|
||||
{(option) => (
|
||||
<span style={{ "font-family": monoFontFamily(option?.value) }}>
|
||||
{option ? language.t(option.label) : ""}
|
||||
</span>
|
||||
)}
|
||||
</Select>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3>
|
||||
|
||||
{/* System notifications Section */}
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.notifications.agent.title")}
|
||||
description={language.t("settings.general.notifications.agent.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-agent">
|
||||
<Switch
|
||||
checked={settings.notifications.agent()}
|
||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||
/>
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow
|
||||
title={language.t("settings.desktop.wsl.title")}
|
||||
description={language.t("settings.desktop.wsl.description")}
|
||||
>
|
||||
<div data-action="settings-wsl">
|
||||
<Switch
|
||||
checked={enabled() ?? false}
|
||||
disabled={enabledResource.state === "pending"}
|
||||
onChange={(checked) => platform.setWslEnabled?.(checked)?.finally(() => actions.refetch())}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.notifications.permissions.title")}
|
||||
description={language.t("settings.general.notifications.permissions.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-permissions">
|
||||
<Switch
|
||||
checked={settings.notifications.permissions()}
|
||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.notifications.errors.title")}
|
||||
description={language.t("settings.general.notifications.errors.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-errors">
|
||||
<Switch
|
||||
checked={settings.notifications.errors()}
|
||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sound effects Section */}
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.sounds.agent.title")}
|
||||
description={language.t("settings.general.sounds.agent.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-sounds-agent"
|
||||
options={soundOptions}
|
||||
current={soundOptions.find((o) => o.id === settings.sounds.agent())}
|
||||
value={(o) => o.id}
|
||||
label={(o) => language.t(o.label)}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
playDemoSound(option.src)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
settings.sounds.setAgent(option.id)
|
||||
playDemoSound(option.src)
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.sounds.permissions.title")}
|
||||
description={language.t("settings.general.sounds.permissions.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-sounds-permissions"
|
||||
options={soundOptions}
|
||||
current={soundOptions.find((o) => o.id === settings.sounds.permissions())}
|
||||
value={(o) => o.id}
|
||||
label={(o) => language.t(o.label)}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
playDemoSound(option.src)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
settings.sounds.setPermissions(option.id)
|
||||
playDemoSound(option.src)
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.sounds.errors.title")}
|
||||
description={language.t("settings.general.sounds.errors.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-sounds-errors"
|
||||
options={soundOptions}
|
||||
current={soundOptions.find((o) => o.id === settings.sounds.errors())}
|
||||
value={(o) => o.id}
|
||||
label={(o) => language.t(o.label)}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
playDemoSound(option.src)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
settings.sounds.setErrors(option.id)
|
||||
playDemoSound(option.src)
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Updates Section */}
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow
|
||||
title={language.t("settings.updates.row.startup.title")}
|
||||
description={language.t("settings.updates.row.startup.description")}
|
||||
>
|
||||
<div data-action="settings-updates-startup">
|
||||
<Switch
|
||||
checked={settings.updates.startup()}
|
||||
disabled={!platform.checkUpdate}
|
||||
onChange={(checked) => settings.updates.setStartup(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.releaseNotes.title")}
|
||||
description={language.t("settings.general.row.releaseNotes.description")}
|
||||
>
|
||||
<div data-action="settings-release-notes">
|
||||
<Switch
|
||||
checked={settings.general.releaseNotes()}
|
||||
onChange={(checked) => settings.general.setReleaseNotes(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.updates.row.check.title")}
|
||||
description={language.t("settings.updates.row.check.description")}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
disabled={store.checking || !platform.checkUpdate}
|
||||
onClick={check}
|
||||
>
|
||||
{store.checking
|
||||
? language.t("settings.updates.action.checking")
|
||||
: language.t("settings.updates.action.checkNow")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
<UpdatesSection />
|
||||
|
||||
<Show when={linux()}>
|
||||
{(_) => {
|
||||
|
||||
@@ -21,6 +21,9 @@ type KeybindMeta = {
|
||||
group: KeybindGroup
|
||||
}
|
||||
|
||||
type KeybindMap = Record<string, string | undefined>
|
||||
type CommandContext = ReturnType<typeof useCommand>
|
||||
|
||||
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
|
||||
|
||||
type GroupKey =
|
||||
@@ -107,6 +110,150 @@ function signatures(config: string | undefined) {
|
||||
return sigs
|
||||
}
|
||||
|
||||
function keybinds(value: unknown): KeybindMap {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {}
|
||||
return value as KeybindMap
|
||||
}
|
||||
|
||||
function listFor(command: CommandContext, map: KeybindMap, palette: string) {
|
||||
const out = new Map<string, KeybindMeta>()
|
||||
out.set(PALETTE_ID, { title: palette, group: "General" })
|
||||
|
||||
for (const opt of command.catalog) {
|
||||
if (opt.id.startsWith("suggested.")) continue
|
||||
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
|
||||
}
|
||||
|
||||
for (const opt of command.options) {
|
||||
if (opt.id.startsWith("suggested.")) continue
|
||||
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
|
||||
}
|
||||
|
||||
for (const [id, value] of Object.entries(map)) {
|
||||
if (typeof value !== "string") continue
|
||||
if (out.has(id)) continue
|
||||
out.set(id, { title: id, group: groupFor(id) })
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function groupedFor(list: Map<string, KeybindMeta>) {
|
||||
const out = new Map<KeybindGroup, string[]>()
|
||||
for (const group of GROUPS) out.set(group, [])
|
||||
|
||||
for (const [id, item] of list) {
|
||||
const ids = out.get(item.group)
|
||||
if (!ids) continue
|
||||
ids.push(id)
|
||||
}
|
||||
|
||||
for (const group of GROUPS) {
|
||||
const ids = out.get(group)
|
||||
if (!ids) continue
|
||||
ids.sort((a, b) => (list.get(a)?.title ?? "").localeCompare(list.get(b)?.title ?? ""))
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function filteredFor(
|
||||
query: string,
|
||||
list: Map<string, KeybindMeta>,
|
||||
grouped: Map<KeybindGroup, string[]>,
|
||||
keybind: (id: string) => string,
|
||||
) {
|
||||
const value = query.toLowerCase().trim()
|
||||
if (!value) return grouped
|
||||
|
||||
const out = new Map<KeybindGroup, string[]>()
|
||||
for (const group of GROUPS) out.set(group, [])
|
||||
|
||||
const items = Array.from(list.entries()).map(([id, meta]) => ({
|
||||
id,
|
||||
title: meta.title,
|
||||
group: meta.group,
|
||||
keybind: keybind(id),
|
||||
}))
|
||||
|
||||
const results = fuzzysort.go(value, items, {
|
||||
keys: ["title", "keybind"],
|
||||
threshold: -10000,
|
||||
})
|
||||
|
||||
for (const result of results) {
|
||||
const ids = out.get(result.obj.group)
|
||||
if (!ids) continue
|
||||
ids.push(result.obj.id)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function useKeyCapture(input: {
|
||||
active: () => string | null
|
||||
stop: () => void
|
||||
set: (id: string, keybind: string) => void
|
||||
used: () => Map<string, { id: string; title: string }[]>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
onMount(() => {
|
||||
const handle = (event: KeyboardEvent) => {
|
||||
const id = input.active()
|
||||
if (!id) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
|
||||
if (event.key === "Escape") {
|
||||
input.stop()
|
||||
return
|
||||
}
|
||||
|
||||
const clear =
|
||||
(event.key === "Backspace" || event.key === "Delete") &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey
|
||||
if (clear) {
|
||||
input.set(id, "none")
|
||||
input.stop()
|
||||
return
|
||||
}
|
||||
|
||||
const next = recordKeybind(event)
|
||||
if (!next) return
|
||||
|
||||
const conflicts = new Map<string, string>()
|
||||
for (const sig of signatures(next)) {
|
||||
for (const item of input.used().get(sig) ?? []) {
|
||||
if (item.id === id) continue
|
||||
conflicts.set(item.id, item.title)
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.size > 0) {
|
||||
showToast({
|
||||
title: input.language.t("settings.shortcuts.conflict.title"),
|
||||
description: input.language.t("settings.shortcuts.conflict.description", {
|
||||
keybind: formatKeybind(next),
|
||||
titles: [...conflicts.values()].join(", "),
|
||||
}),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
input.set(id, next)
|
||||
input.stop()
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handle, true)
|
||||
onCleanup(() => document.removeEventListener("keydown", handle, true))
|
||||
})
|
||||
}
|
||||
|
||||
export const SettingsKeybinds: Component = () => {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
@@ -135,11 +282,9 @@ export const SettingsKeybinds: Component = () => {
|
||||
command.keybinds(false)
|
||||
}
|
||||
|
||||
const hasOverrides = createMemo(() => {
|
||||
const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
|
||||
if (!keybinds) return false
|
||||
return Object.values(keybinds).some((x) => typeof x === "string")
|
||||
})
|
||||
const map = createMemo(() => keybinds(settings.current.keybinds))
|
||||
|
||||
const hasOverrides = createMemo(() => Object.values(map()).some((x) => typeof x === "string"))
|
||||
|
||||
const resetAll = () => {
|
||||
stop()
|
||||
@@ -152,88 +297,15 @@ export const SettingsKeybinds: Component = () => {
|
||||
|
||||
const list = createMemo(() => {
|
||||
language.locale()
|
||||
const out = new Map<string, KeybindMeta>()
|
||||
out.set(PALETTE_ID, { title: language.t("command.palette"), group: "General" })
|
||||
|
||||
for (const opt of command.catalog) {
|
||||
if (opt.id.startsWith("suggested.")) continue
|
||||
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
|
||||
}
|
||||
|
||||
for (const opt of command.options) {
|
||||
if (opt.id.startsWith("suggested.")) continue
|
||||
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
|
||||
}
|
||||
|
||||
const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
|
||||
if (keybinds) {
|
||||
for (const [id, value] of Object.entries(keybinds)) {
|
||||
if (typeof value !== "string") continue
|
||||
if (out.has(id)) continue
|
||||
out.set(id, { title: id, group: groupFor(id) })
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
return listFor(command, map(), language.t("command.palette"))
|
||||
})
|
||||
|
||||
const title = (id: string) => list().get(id)?.title ?? ""
|
||||
|
||||
const grouped = createMemo(() => {
|
||||
const map = list()
|
||||
const out = new Map<KeybindGroup, string[]>()
|
||||
|
||||
for (const group of GROUPS) out.set(group, [])
|
||||
|
||||
for (const [id, item] of map) {
|
||||
const ids = out.get(item.group)
|
||||
if (!ids) continue
|
||||
ids.push(id)
|
||||
}
|
||||
|
||||
for (const group of GROUPS) {
|
||||
const ids = out.get(group)
|
||||
if (!ids) continue
|
||||
|
||||
ids.sort((a, b) => {
|
||||
const at = map.get(a)?.title ?? ""
|
||||
const bt = map.get(b)?.title ?? ""
|
||||
return at.localeCompare(bt)
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
})
|
||||
const grouped = createMemo(() => groupedFor(list()))
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const query = store.filter.toLowerCase().trim()
|
||||
if (!query) return grouped()
|
||||
|
||||
const map = list()
|
||||
const out = new Map<KeybindGroup, string[]>()
|
||||
|
||||
for (const group of GROUPS) out.set(group, [])
|
||||
|
||||
const items = Array.from(map.entries()).map(([id, meta]) => ({
|
||||
id,
|
||||
title: meta.title,
|
||||
group: meta.group,
|
||||
keybind: command.keybind(id) || "",
|
||||
}))
|
||||
|
||||
const results = fuzzysort.go(query, items, {
|
||||
keys: ["title", "keybind"],
|
||||
threshold: -10000,
|
||||
})
|
||||
|
||||
for (const result of results) {
|
||||
const item = result.obj
|
||||
const ids = out.get(item.group)
|
||||
if (!ids) continue
|
||||
ids.push(item.id)
|
||||
}
|
||||
|
||||
return out
|
||||
return filteredFor(store.filter, list(), grouped(), (id) => command.keybind(id) || "")
|
||||
})
|
||||
|
||||
const hasResults = createMemo(() => {
|
||||
@@ -282,69 +354,14 @@ export const SettingsKeybinds: Component = () => {
|
||||
return map
|
||||
})
|
||||
|
||||
const setKeybind = (id: string, keybind: string) => {
|
||||
settings.keybinds.set(id, keybind)
|
||||
}
|
||||
const setKeybind = (id: string, keybind: string) => settings.keybinds.set(id, keybind)
|
||||
|
||||
onMount(() => {
|
||||
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) {
|
||||
setKeybind(id, "none")
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
const next = recordKeybind(event)
|
||||
if (!next) return
|
||||
|
||||
const map = used()
|
||||
const conflicts = new Map<string, string>()
|
||||
|
||||
for (const sig of signatures(next)) {
|
||||
const list = map.get(sig) ?? []
|
||||
for (const item of list) {
|
||||
if (item.id === id) continue
|
||||
conflicts.set(item.id, item.title)
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.size > 0) {
|
||||
showToast({
|
||||
title: language.t("settings.shortcuts.conflict.title"),
|
||||
description: language.t("settings.shortcuts.conflict.description", {
|
||||
keybind: formatKeybind(next),
|
||||
titles: [...conflicts.values()].join(", "),
|
||||
}),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setKeybind(id, next)
|
||||
stop()
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handle, true)
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handle, true)
|
||||
})
|
||||
useKeyCapture({
|
||||
active: () => store.active,
|
||||
stop,
|
||||
set: setKeybind,
|
||||
used,
|
||||
language,
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export const SettingsMcp: Component = () => {
|
||||
// TODO: Replace this placeholder with full MCP settings controls.
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,6 +12,25 @@ import { popularProviders } from "@/hooks/use-providers"
|
||||
|
||||
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
||||
|
||||
const ListLoadingState: Component<{ label: string }> = (props) => {
|
||||
return (
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<span class="text-14-regular text-text-weak">{props.label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ListEmptyState: Component<{ message: string; filter: string }> = (props) => {
|
||||
return (
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<span class="text-14-regular text-text-weak">{props.message}</span>
|
||||
<Show when={props.filter}>
|
||||
<span class="text-14-regular text-text-strong mt-1">"{props.filter}"</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsModels: Component = () => {
|
||||
const language = useLanguage()
|
||||
const models = useModels()
|
||||
@@ -68,24 +87,12 @@ export const SettingsModels: Component = () => {
|
||||
<Show
|
||||
when={!list.grouped.loading}
|
||||
fallback={
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<span class="text-14-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</span>
|
||||
</div>
|
||||
<ListLoadingState label={`${language.t("common.loading")}${language.t("common.loading.ellipsis")}`} />
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={list.flat().length > 0}
|
||||
fallback={
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<span class="text-14-regular text-text-weak">{language.t("dialog.model.empty")}</span>
|
||||
<Show when={list.filter()}>
|
||||
<span class="text-14-regular text-text-strong mt-1">"{list.filter()}"</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
fallback={<ListEmptyState message={language.t("dialog.model.empty")} filter={list.filter()} />}
|
||||
>
|
||||
<For each={list.grouped.latest}>
|
||||
{(group) => (
|
||||
|
||||
@@ -165,12 +165,14 @@ export const SettingsPermissions: Component = () => {
|
||||
const nextValue =
|
||||
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
|
||||
|
||||
globalSync.set("config", "permission", { ...map, [id]: nextValue })
|
||||
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch((err: unknown) => {
|
||||
const rollback = (err: unknown) => {
|
||||
globalSync.set("config", "permission", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
|
||||
})
|
||||
}
|
||||
|
||||
globalSync.set("config", "permission", { ...map, [id]: nextValue })
|
||||
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,7 +14,17 @@ import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
|
||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||
type ProviderMeta = { source?: ProviderSource }
|
||||
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
||||
|
||||
const PROVIDER_NOTES = [
|
||||
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
|
||||
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
|
||||
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
|
||||
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
|
||||
{ match: (id: string) => id === "google", key: "dialog.provider.google.note" },
|
||||
{ match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" },
|
||||
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
|
||||
] as const
|
||||
|
||||
export const SettingsProviders: Component = () => {
|
||||
const dialog = useDialog()
|
||||
@@ -44,22 +54,28 @@ export const SettingsProviders: Component = () => {
|
||||
return items
|
||||
})
|
||||
|
||||
const source = (item: unknown) => (item as ProviderMeta).source
|
||||
const source = (item: ProviderItem): ProviderSource | undefined => {
|
||||
if (!("source" in item)) return
|
||||
const value = item.source
|
||||
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
|
||||
return
|
||||
}
|
||||
|
||||
const type = (item: unknown) => {
|
||||
const type = (item: ProviderItem) => {
|
||||
const current = source(item)
|
||||
if (current === "env") return language.t("settings.providers.tag.environment")
|
||||
if (current === "api") return language.t("provider.connect.method.apiKey")
|
||||
if (current === "config") {
|
||||
const id = (item as { id?: string }).id
|
||||
if (id && isConfigCustom(id)) return language.t("settings.providers.tag.custom")
|
||||
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
|
||||
return language.t("settings.providers.tag.config")
|
||||
}
|
||||
if (current === "custom") return language.t("settings.providers.tag.custom")
|
||||
return language.t("settings.providers.tag.other")
|
||||
}
|
||||
|
||||
const canDisconnect = (item: unknown) => source(item) !== "env"
|
||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
const isConfigCustom = (providerID: string) => {
|
||||
const provider = globalSync.data.config.provider?.[providerID]
|
||||
@@ -175,40 +191,8 @@ export const SettingsProviders: Component = () => {
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={item.id === "opencode"}>
|
||||
<span class="text-12-regular text-text-weak pl-8">
|
||||
{language.t("dialog.provider.opencode.note")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={item.id === "anthropic"}>
|
||||
<span class="text-12-regular text-text-weak pl-8">
|
||||
{language.t("dialog.provider.anthropic.note")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={item.id.startsWith("github-copilot")}>
|
||||
<span class="text-12-regular text-text-weak pl-8">
|
||||
{language.t("dialog.provider.copilot.note")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={item.id === "openai"}>
|
||||
<span class="text-12-regular text-text-weak pl-8">
|
||||
{language.t("dialog.provider.openai.note")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={item.id === "google"}>
|
||||
<span class="text-12-regular text-text-weak pl-8">
|
||||
{language.t("dialog.provider.google.note")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={item.id === "openrouter"}>
|
||||
<span class="text-12-regular text-text-weak pl-8">
|
||||
{language.t("dialog.provider.openrouter.note")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={item.id === "vercel"}>
|
||||
<span class="text-12-regular text-text-weak pl-8">
|
||||
{language.t("dialog.provider.vercel.note")}
|
||||
</span>
|
||||
<Show when={note(item.id)}>
|
||||
{(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -7,16 +7,151 @@ import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, useServer } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { DialogSelectServer } from "./dialog-select-server"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { ServerRow } from "@/components/server/server-row"
|
||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
|
||||
const pollMs = 10_000
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
if (parts.length === 1) return value
|
||||
return (
|
||||
<>
|
||||
{parts[0]}
|
||||
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
|
||||
{parts.slice(1).join(file)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const listServersByHealth = (
|
||||
list: string[],
|
||||
active: string | undefined,
|
||||
status: Record<string, ServerHealth | undefined>,
|
||||
) => {
|
||||
if (!list.length) return list
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === active) return -1
|
||||
if (b === active) return 1
|
||||
const diff = rank(status[a]) - rank(status[b])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => {
|
||||
const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>)
|
||||
|
||||
createEffect(() => {
|
||||
const list = servers()
|
||||
let dead = false
|
||||
|
||||
const refresh = async () => {
|
||||
const results: Record<string, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
list.map(async (url) => {
|
||||
results[url] = await checkServerHealth(url, fetcher)
|
||||
}),
|
||||
)
|
||||
if (dead) return
|
||||
setStatus(reconcile(results))
|
||||
}
|
||||
|
||||
void refresh()
|
||||
const id = setInterval(() => void refresh(), pollMs)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
clearInterval(id)
|
||||
})
|
||||
})
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
const useDefaultServerUrl = (
|
||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||
) => {
|
||||
const [url, setUrl] = createSignal<string | undefined>()
|
||||
const [tick, setTick] = createSignal(0)
|
||||
|
||||
createEffect(() => {
|
||||
tick()
|
||||
let dead = false
|
||||
const result = get?.()
|
||||
if (!result) {
|
||||
setUrl(undefined)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result instanceof Promise) {
|
||||
void result.then((next) => {
|
||||
if (dead) return
|
||||
setUrl(next ? normalizeServerUrl(next) : undefined)
|
||||
})
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setUrl(normalizeServerUrl(result))
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
})
|
||||
|
||||
return { url, refresh: () => setTick((value) => value + 1) }
|
||||
}
|
||||
|
||||
const useMcpToggle = (input: {
|
||||
sync: ReturnType<typeof useSync>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) => {
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
|
||||
const toggle = async (name: string) => {
|
||||
if (loading()) return
|
||||
setLoading(name)
|
||||
|
||||
try {
|
||||
const status = input.sync.data.mcp[name]
|
||||
await (status?.status === "connected"
|
||||
? input.sdk.client.mcp.disconnect({ name })
|
||||
: input.sdk.client.mcp.connect({ name }))
|
||||
const result = await input.sdk.client.mcp.status()
|
||||
if (result.data) input.sync.set("mcp", result.data)
|
||||
} catch (err) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, toggle }
|
||||
}
|
||||
|
||||
export function StatusPopover() {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
@@ -26,115 +161,35 @@ export function StatusPopover() {
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
status: {} as Record<string, ServerHealth | undefined>,
|
||||
loading: null as string | null,
|
||||
defaultServerUrl: undefined as string | undefined,
|
||||
})
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
|
||||
const servers = createMemo(() => {
|
||||
const current = server.url
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (!list.includes(current)) return [current, ...list]
|
||||
return [current, ...list.filter((x) => x !== current)]
|
||||
return [current, ...list.filter((item) => item !== current)]
|
||||
})
|
||||
|
||||
const sortedServers = createMemo(() => {
|
||||
const list = servers()
|
||||
if (!list.length) return list
|
||||
const active = server.url
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === active) return -1
|
||||
if (b === active) return 1
|
||||
const diff = rank(store.status[a]) - rank(store.status[b])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
async function refreshHealth() {
|
||||
const results: Record<string, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
servers().map(async (url) => {
|
||||
results[url] = await checkServerHealth(url, fetcher)
|
||||
}),
|
||||
)
|
||||
setStore("status", reconcile(results))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
servers()
|
||||
refreshHealth()
|
||||
const interval = setInterval(refreshHealth, 10_000)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
const mcpItems = createMemo(() =>
|
||||
Object.entries(sync.data.mcp ?? {})
|
||||
.map(([name, status]) => ({ name, status: status.status }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
|
||||
const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
|
||||
|
||||
const toggleMcp = async (name: string) => {
|
||||
if (store.loading) return
|
||||
setStore("loading", name)
|
||||
|
||||
try {
|
||||
const status = sync.data.mcp[name]
|
||||
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
||||
const result = await sdk.client.mcp.status()
|
||||
if (result.data) sync.set("mcp", result.data)
|
||||
} catch (err) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
} finally {
|
||||
setStore("loading", null)
|
||||
}
|
||||
}
|
||||
|
||||
const health = useServerHealth(servers, fetcher)
|
||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health))
|
||||
const mcp = useMcpToggle({ sync, sdk, language })
|
||||
const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const plugins = createMemo(() => sync.data.config.plugin ?? [])
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
const overallHealthy = createMemo(() => {
|
||||
const serverHealthy = server.healthy() === true
|
||||
const anyMcpIssue = mcpItems().some((m) => m.status !== "connected" && m.status !== "disabled")
|
||||
const anyMcpIssue = mcpNames().some((name) => {
|
||||
const status = mcpStatus(name)
|
||||
return status !== "connected" && status !== "disabled"
|
||||
})
|
||||
return serverHealthy && !anyMcpIssue
|
||||
})
|
||||
|
||||
const serverCount = createMemo(() => sortedServers().length)
|
||||
|
||||
const refreshDefaultServerUrl = () => {
|
||||
const result = platform.getDefaultServerUrl?.()
|
||||
if (!result) {
|
||||
setStore("defaultServerUrl", undefined)
|
||||
return
|
||||
}
|
||||
if (result instanceof Promise) {
|
||||
result.then((url) => setStore("defaultServerUrl", url ? normalizeServerUrl(url) : undefined))
|
||||
return
|
||||
}
|
||||
setStore("defaultServerUrl", normalizeServerUrl(result))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
refreshDefaultServerUrl()
|
||||
})
|
||||
|
||||
return (
|
||||
<Popover
|
||||
triggerAs={Button}
|
||||
@@ -173,7 +228,7 @@ export function StatusPopover() {
|
||||
>
|
||||
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||
{serverCount() > 0 ? `${serverCount()} ` : ""}
|
||||
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
||||
@@ -195,11 +250,7 @@ export function StatusPopover() {
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<For each={sortedServers()}>
|
||||
{(url) => {
|
||||
const isActive = () => url === server.url
|
||||
const isDefault = () => url === store.defaultServerUrl
|
||||
const status = () => store.status[url]
|
||||
const isBlocked = () => status()?.healthy === false
|
||||
|
||||
const isBlocked = () => health[url]?.healthy === false
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -217,13 +268,13 @@ export function StatusPopover() {
|
||||
>
|
||||
<ServerRow
|
||||
url={url}
|
||||
status={status()}
|
||||
status={health[url]}
|
||||
dimmed={isBlocked()}
|
||||
class="flex items-center gap-2 w-full min-w-0"
|
||||
nameClass="text-14-regular text-text-base truncate"
|
||||
versionClass="text-12-regular text-text-weak truncate"
|
||||
badge={
|
||||
<Show when={isDefault()}>
|
||||
<Show when={url === defaultServer.url()}>
|
||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||
{language.t("common.default")}
|
||||
</span>
|
||||
@@ -231,7 +282,7 @@ export function StatusPopover() {
|
||||
}
|
||||
>
|
||||
<div class="flex-1" />
|
||||
<Show when={isActive()}>
|
||||
<Show when={url === server.url}>
|
||||
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
||||
</Show>
|
||||
</ServerRow>
|
||||
@@ -243,7 +294,7 @@ export function StatusPopover() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />, refreshDefaultServerUrl)}
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
|
||||
>
|
||||
{language.t("status.popover.action.manageServers")}
|
||||
</Button>
|
||||
@@ -255,39 +306,40 @@ export function StatusPopover() {
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={mcpItems().length > 0}
|
||||
when={mcpNames().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">
|
||||
{language.t("dialog.mcp.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={mcpItems()}>
|
||||
{(item) => {
|
||||
const enabled = () => item.status === "connected"
|
||||
<For each={mcpNames()}>
|
||||
{(name) => {
|
||||
const status = () => mcpStatus(name)
|
||||
const enabled = () => status() === "connected"
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
onClick={() => toggleMcp(item.name)}
|
||||
disabled={store.loading === item.name}
|
||||
onClick={() => mcp.toggle(name)}
|
||||
disabled={mcp.loading() === name}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": item.status === "connected",
|
||||
"bg-icon-critical-base": item.status === "failed",
|
||||
"bg-border-weak-base": item.status === "disabled",
|
||||
"bg-icon-success-base": status() === "connected",
|
||||
"bg-icon-critical-base": status() === "failed",
|
||||
"bg-border-weak-base": status() === "disabled",
|
||||
"bg-icon-warning-base":
|
||||
item.status === "needs_auth" || item.status === "needs_client_registration",
|
||||
status() === "needs_auth" || status() === "needs_client_registration",
|
||||
}}
|
||||
/>
|
||||
<span class="text-14-regular text-text-base truncate flex-1">{item.name}</span>
|
||||
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
|
||||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={store.loading === item.name}
|
||||
onChange={() => toggleMcp(item.name)}
|
||||
disabled={mcp.loading() === name}
|
||||
onChange={() => mcp.toggle(name)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
@@ -334,23 +386,7 @@ export function StatusPopover() {
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={plugins().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">
|
||||
{(() => {
|
||||
const value = language.t("dialog.plugins.empty")
|
||||
const file = "opencode.json"
|
||||
const parts = value.split(file)
|
||||
if (parts.length === 1) return value
|
||||
return (
|
||||
<>
|
||||
{parts[0]}
|
||||
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
|
||||
{parts.slice(1).join(file)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
}
|
||||
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
||||
>
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@openco
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||
import { terminalWriter } from "@/utils/terminal-writer"
|
||||
|
||||
const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
||||
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||
@@ -56,6 +57,91 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
|
||||
},
|
||||
}
|
||||
|
||||
const debugTerminal = (...values: unknown[]) => {
|
||||
if (!import.meta.env.DEV) return
|
||||
console.debug("[terminal]", ...values)
|
||||
}
|
||||
|
||||
const useTerminalUiBindings = (input: {
|
||||
container: HTMLDivElement
|
||||
term: Term
|
||||
cleanups: VoidFunction[]
|
||||
handlePointerDown: () => void
|
||||
handleLinkClick: (event: MouseEvent) => void
|
||||
}) => {
|
||||
const handleCopy = (event: ClipboardEvent) => {
|
||||
const selection = input.term.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const clipboard = event.clipboardData
|
||||
if (!clipboard) return
|
||||
|
||||
event.preventDefault()
|
||||
clipboard.setData("text/plain", selection)
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const clipboard = event.clipboardData
|
||||
const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? ""
|
||||
if (!text) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
input.term.paste(text)
|
||||
}
|
||||
|
||||
const handleTextareaFocus = () => {
|
||||
input.term.options.cursorBlink = true
|
||||
}
|
||||
const handleTextareaBlur = () => {
|
||||
input.term.options.cursorBlink = false
|
||||
}
|
||||
|
||||
input.container.addEventListener("copy", handleCopy, true)
|
||||
input.cleanups.push(() => input.container.removeEventListener("copy", handleCopy, true))
|
||||
|
||||
input.container.addEventListener("paste", handlePaste, true)
|
||||
input.cleanups.push(() => input.container.removeEventListener("paste", handlePaste, true))
|
||||
|
||||
input.container.addEventListener("pointerdown", input.handlePointerDown)
|
||||
input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown))
|
||||
|
||||
input.container.addEventListener("click", input.handleLinkClick, { capture: true })
|
||||
input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true }))
|
||||
|
||||
input.term.textarea?.addEventListener("focus", handleTextareaFocus)
|
||||
input.term.textarea?.addEventListener("blur", handleTextareaBlur)
|
||||
input.cleanups.push(() => input.term.textarea?.removeEventListener("focus", handleTextareaFocus))
|
||||
input.cleanups.push(() => input.term.textarea?.removeEventListener("blur", handleTextareaBlur))
|
||||
}
|
||||
|
||||
const persistTerminal = (input: {
|
||||
term: Term | undefined
|
||||
addon: SerializeAddon | undefined
|
||||
cursor: number
|
||||
pty: LocalPTY
|
||||
onCleanup?: (pty: LocalPTY) => void
|
||||
}) => {
|
||||
if (!input.addon || !input.onCleanup || !input.term) return
|
||||
const buffer = (() => {
|
||||
try {
|
||||
return input.addon.serialize()
|
||||
} catch {
|
||||
debugTerminal("failed to serialize terminal buffer")
|
||||
return ""
|
||||
}
|
||||
})()
|
||||
|
||||
input.onCleanup({
|
||||
...input.pty,
|
||||
buffer,
|
||||
cursor: input.cursor,
|
||||
rows: input.term.rows,
|
||||
cols: input.term.cols,
|
||||
scrollY: input.term.getViewportY(),
|
||||
})
|
||||
}
|
||||
|
||||
export const Terminal = (props: TerminalProps) => {
|
||||
const platform = usePlatform()
|
||||
const sdk = useSDK()
|
||||
@@ -70,13 +156,16 @@ export const Terminal = (props: TerminalProps) => {
|
||||
let serializeAddon: SerializeAddon
|
||||
let fitAddon: FitAddon
|
||||
let handleResize: () => void
|
||||
let handleTextareaFocus: () => void
|
||||
let handleTextareaBlur: () => void
|
||||
let fitFrame: number | undefined
|
||||
let sizeTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let pendingSize: { cols: number; rows: number } | undefined
|
||||
let lastSize: { cols: number; rows: number } | undefined
|
||||
let disposed = false
|
||||
const cleanups: VoidFunction[] = []
|
||||
const start =
|
||||
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
|
||||
let cursor = start ?? 0
|
||||
let output: ReturnType<typeof terminalWriter> | undefined
|
||||
|
||||
const cleanup = () => {
|
||||
if (!cleanups.length) return
|
||||
@@ -84,14 +173,25 @@ export const Terminal = (props: TerminalProps) => {
|
||||
for (const fn of fns) {
|
||||
try {
|
||||
fn()
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err) {
|
||||
debugTerminal("cleanup failed", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pushSize = (cols: number, rows: number) => {
|
||||
return sdk.client.pty
|
||||
.update({
|
||||
ptyID: local.pty.id,
|
||||
size: { cols, rows },
|
||||
})
|
||||
.catch((err) => {
|
||||
debugTerminal("failed to sync terminal size", err)
|
||||
})
|
||||
}
|
||||
|
||||
const getTerminalColors = (): TerminalColors => {
|
||||
const mode = theme.mode()
|
||||
const mode = theme.mode() === "dark" ? "dark" : "light"
|
||||
const fallback = DEFAULT_TERMINAL_COLORS[mode]
|
||||
const currentTheme = theme.themes()[theme.themeId()]
|
||||
if (!currentTheme) return fallback
|
||||
@@ -113,6 +213,43 @@ export const Terminal = (props: TerminalProps) => {
|
||||
|
||||
const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
|
||||
|
||||
const scheduleFit = () => {
|
||||
if (disposed) return
|
||||
if (!fitAddon) return
|
||||
if (fitFrame !== undefined) return
|
||||
|
||||
fitFrame = requestAnimationFrame(() => {
|
||||
fitFrame = undefined
|
||||
if (disposed) return
|
||||
fitAddon.fit()
|
||||
})
|
||||
}
|
||||
|
||||
const scheduleSize = (cols: number, rows: number) => {
|
||||
if (disposed) return
|
||||
if (lastSize?.cols === cols && lastSize?.rows === rows) return
|
||||
|
||||
pendingSize = { cols, rows }
|
||||
|
||||
if (!lastSize) {
|
||||
lastSize = pendingSize
|
||||
void pushSize(cols, rows)
|
||||
return
|
||||
}
|
||||
|
||||
if (sizeTimer !== undefined) return
|
||||
sizeTimer = setTimeout(() => {
|
||||
sizeTimer = undefined
|
||||
const next = pendingSize
|
||||
if (!next) return
|
||||
pendingSize = undefined
|
||||
if (disposed) return
|
||||
if (lastSize?.cols === next.cols && lastSize?.rows === next.rows) return
|
||||
lastSize = next
|
||||
void pushSize(next.cols, next.rows)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const colors = getTerminalColors()
|
||||
setTerminalColors(colors)
|
||||
@@ -124,17 +261,28 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const font = monoFontFamily(settings.appearance.font())
|
||||
if (!term) return
|
||||
setOptionIfSupported(term, "fontFamily", font)
|
||||
scheduleFit()
|
||||
})
|
||||
|
||||
let zoom = platform.webviewZoom?.()
|
||||
createEffect(() => {
|
||||
const next = platform.webviewZoom?.()
|
||||
if (next === undefined) return
|
||||
if (next === zoom) return
|
||||
zoom = next
|
||||
scheduleFit()
|
||||
})
|
||||
|
||||
const focusTerminal = () => {
|
||||
const t = term
|
||||
if (!t) return
|
||||
t.focus()
|
||||
t.textarea?.focus()
|
||||
setTimeout(() => t.textarea?.focus(), 0)
|
||||
}
|
||||
const handlePointerDown = () => {
|
||||
const activeElement = document.activeElement
|
||||
if (activeElement instanceof HTMLElement && activeElement !== container) {
|
||||
if (activeElement instanceof HTMLElement && activeElement !== container && !container.contains(activeElement)) {
|
||||
activeElement.blur()
|
||||
}
|
||||
focusTerminal()
|
||||
@@ -166,32 +314,27 @@ export const Terminal = (props: TerminalProps) => {
|
||||
|
||||
const once = { value: false }
|
||||
|
||||
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
|
||||
url.searchParams.set("directory", sdk.directory)
|
||||
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
if (window.__OPENCODE__?.serverPassword) {
|
||||
url.username = "opencode"
|
||||
url.password = window.__OPENCODE__?.serverPassword
|
||||
}
|
||||
const socket = new WebSocket(url)
|
||||
socket.binaryType = "arraybuffer"
|
||||
cleanups.push(() => {
|
||||
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
|
||||
})
|
||||
if (disposed) {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
ws = socket
|
||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||
const restoreSize =
|
||||
restore &&
|
||||
typeof local.pty.cols === "number" &&
|
||||
Number.isSafeInteger(local.pty.cols) &&
|
||||
local.pty.cols > 0 &&
|
||||
typeof local.pty.rows === "number" &&
|
||||
Number.isSafeInteger(local.pty.rows) &&
|
||||
local.pty.rows > 0
|
||||
? { cols: local.pty.cols, rows: local.pty.rows }
|
||||
: undefined
|
||||
|
||||
const t = new mod.Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
cols: restoreSize?.cols,
|
||||
rows: restoreSize?.rows,
|
||||
fontSize: 14,
|
||||
fontFamily: monoFontFamily(settings.appearance.font()),
|
||||
allowTransparency: false,
|
||||
convertEol: true,
|
||||
convertEol: false,
|
||||
theme: terminalColors(),
|
||||
scrollback: 10_000,
|
||||
ghostty: g,
|
||||
@@ -203,45 +346,13 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
ghostty = g
|
||||
term = t
|
||||
|
||||
const copy = () => {
|
||||
const selection = t.getSelection()
|
||||
if (!selection) return false
|
||||
|
||||
const body = document.body
|
||||
if (body) {
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = selection
|
||||
textarea.setAttribute("readonly", "")
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.opacity = "0"
|
||||
body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const copied = document.execCommand("copy")
|
||||
body.removeChild(textarea)
|
||||
if (copied) return true
|
||||
}
|
||||
|
||||
const clipboard = navigator.clipboard
|
||||
if (clipboard?.writeText) {
|
||||
clipboard.writeText(selection).catch(() => {})
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
output = terminalWriter((data) => t.write(data))
|
||||
|
||||
t.attachCustomKeyEventHandler((event) => {
|
||||
const key = event.key.toLowerCase()
|
||||
|
||||
if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") {
|
||||
copy()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.metaKey && !event.ctrlKey && !event.altKey && key === "c") {
|
||||
if (!t.hasSelection()) return true
|
||||
copy()
|
||||
document.execCommand("copy")
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -261,58 +372,20 @@ export const Terminal = (props: TerminalProps) => {
|
||||
serializeAddon = serializer
|
||||
|
||||
t.open(container)
|
||||
|
||||
container.addEventListener("pointerdown", handlePointerDown)
|
||||
cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
|
||||
|
||||
container.addEventListener("click", handleLinkClick, { capture: true })
|
||||
cleanups.push(() => container.removeEventListener("click", handleLinkClick, { capture: true }))
|
||||
|
||||
handleTextareaFocus = () => {
|
||||
t.options.cursorBlink = true
|
||||
}
|
||||
handleTextareaBlur = () => {
|
||||
t.options.cursorBlink = false
|
||||
}
|
||||
|
||||
t.textarea?.addEventListener("focus", handleTextareaFocus)
|
||||
t.textarea?.addEventListener("blur", handleTextareaBlur)
|
||||
cleanups.push(() => t.textarea?.removeEventListener("focus", handleTextareaFocus))
|
||||
cleanups.push(() => t.textarea?.removeEventListener("blur", handleTextareaBlur))
|
||||
useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick })
|
||||
|
||||
focusTerminal()
|
||||
|
||||
fit.fit()
|
||||
|
||||
if (local.pty.buffer) {
|
||||
t.write(local.pty.buffer, () => {
|
||||
if (local.pty.scrollY) t.scrollToLine(local.pty.scrollY)
|
||||
})
|
||||
if (typeof document !== "undefined" && document.fonts) {
|
||||
document.fonts.ready.then(scheduleFit)
|
||||
}
|
||||
|
||||
fit.observeResize()
|
||||
handleResize = () => fit.fit()
|
||||
window.addEventListener("resize", handleResize)
|
||||
cleanups.push(() => window.removeEventListener("resize", handleResize))
|
||||
|
||||
const onResize = t.onResize(async (size) => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
await sdk.client.pty
|
||||
.update({
|
||||
ptyID: local.pty.id,
|
||||
size: {
|
||||
cols: size.cols,
|
||||
rows: size.rows,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
const onResize = t.onResize((size) => {
|
||||
scheduleSize(size.cols, size.rows)
|
||||
})
|
||||
cleanups.push(() => disposeIfDisposable(onResize))
|
||||
const onData = t.onData((data) => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(data)
|
||||
}
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(data)
|
||||
})
|
||||
cleanups.push(() => disposeIfDisposable(onData))
|
||||
const onKey = t.onKey((key) => {
|
||||
@@ -321,25 +394,64 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
})
|
||||
cleanups.push(() => disposeIfDisposable(onKey))
|
||||
|
||||
const startResize = () => {
|
||||
fit.observeResize()
|
||||
handleResize = scheduleFit
|
||||
window.addEventListener("resize", handleResize)
|
||||
cleanups.push(() => window.removeEventListener("resize", handleResize))
|
||||
}
|
||||
|
||||
if (restore && restoreSize) {
|
||||
t.write(restore, () => {
|
||||
fit.fit()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
|
||||
startResize()
|
||||
})
|
||||
} else {
|
||||
fit.fit()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
if (restore) {
|
||||
t.write(restore, () => {
|
||||
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
|
||||
})
|
||||
}
|
||||
startResize()
|
||||
}
|
||||
|
||||
// t.onScroll((ydisp) => {
|
||||
// console.log("Scroll position:", ydisp)
|
||||
// })
|
||||
|
||||
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
|
||||
url.searchParams.set("directory", sdk.directory)
|
||||
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
if (window.__OPENCODE__?.serverPassword) {
|
||||
url.username = "opencode"
|
||||
url.password = window.__OPENCODE__?.serverPassword
|
||||
}
|
||||
const socket = new WebSocket(url)
|
||||
socket.binaryType = "arraybuffer"
|
||||
ws = socket
|
||||
cleanups.push(() => {
|
||||
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
|
||||
})
|
||||
if (disposed) {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
const handleOpen = () => {
|
||||
local.onConnect?.()
|
||||
sdk.client.pty
|
||||
.update({
|
||||
ptyID: local.pty.id,
|
||||
size: {
|
||||
cols: t.cols,
|
||||
rows: t.rows,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
scheduleSize(t.cols, t.rows)
|
||||
}
|
||||
socket.addEventListener("open", handleOpen)
|
||||
cleanups.push(() => socket.removeEventListener("open", handleOpen))
|
||||
|
||||
if (socket.readyState === WebSocket.OPEN) handleOpen()
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
@@ -355,15 +467,15 @@ export const Terminal = (props: TerminalProps) => {
|
||||
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
|
||||
cursor = next
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err) {
|
||||
debugTerminal("invalid websocket control frame", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const data = typeof event.data === "string" ? event.data : ""
|
||||
if (!data) return
|
||||
t.write(data)
|
||||
output?.push(data)
|
||||
cursor += data.length
|
||||
}
|
||||
socket.addEventListener("message", handleMessage)
|
||||
@@ -406,25 +518,10 @@ export const Terminal = (props: TerminalProps) => {
|
||||
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
const t = term
|
||||
if (serializeAddon && props.onCleanup && t) {
|
||||
const buffer = (() => {
|
||||
try {
|
||||
return serializeAddon.serialize()
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
})()
|
||||
props.onCleanup({
|
||||
...local.pty,
|
||||
buffer,
|
||||
cursor,
|
||||
rows: t.rows,
|
||||
cols: t.cols,
|
||||
scrollY: t.getViewportY(),
|
||||
})
|
||||
}
|
||||
|
||||
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
|
||||
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
|
||||
output?.flush()
|
||||
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
|
||||
cleanup()
|
||||
})
|
||||
|
||||
@@ -438,7 +535,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
"select-text": true,
|
||||
"size-full px-6 py-3 font-mono": true,
|
||||
"size-full px-6 py-3 font-mono relative overflow-hidden": true,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
{...others}
|
||||
|
||||
@@ -13,6 +13,28 @@ import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
startDragging?: () => Promise<void>
|
||||
toggleMaximize?: () => Promise<void>
|
||||
}
|
||||
|
||||
type TauriThemeWindow = {
|
||||
setTheme?: (theme?: "light" | "dark" | null) => Promise<void>
|
||||
}
|
||||
|
||||
type TauriApi = {
|
||||
window?: {
|
||||
getCurrentWindow?: () => TauriDesktopWindow
|
||||
}
|
||||
webviewWindow?: {
|
||||
getCurrentWebviewWindow?: () => TauriThemeWindow
|
||||
}
|
||||
}
|
||||
|
||||
const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
|
||||
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
|
||||
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
|
||||
|
||||
export function Titlebar() {
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
@@ -82,22 +104,7 @@ export function Titlebar() {
|
||||
|
||||
const getWin = () => {
|
||||
if (platform.platform !== "desktop") return
|
||||
|
||||
const tauri = (
|
||||
window as unknown as {
|
||||
__TAURI__?: {
|
||||
window?: {
|
||||
getCurrentWindow?: () => {
|
||||
startDragging?: () => Promise<void>
|
||||
toggleMaximize?: () => Promise<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
).__TAURI__
|
||||
if (!tauri?.window?.getCurrentWindow) return
|
||||
|
||||
return tauri.window.getCurrentWindow()
|
||||
return currentDesktopWindow()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -106,13 +113,8 @@ export function Titlebar() {
|
||||
const scheme = theme.colorScheme()
|
||||
const value = scheme === "system" ? null : scheme
|
||||
|
||||
const tauri = (window as unknown as { __TAURI__?: { webviewWindow?: { getCurrentWebviewWindow?: () => unknown } } })
|
||||
.__TAURI__
|
||||
const get = tauri?.webviewWindow?.getCurrentWebviewWindow
|
||||
if (!get) return
|
||||
|
||||
const win = get() as { setTheme?: (theme?: "light" | "dark" | null) => Promise<void> }
|
||||
if (!win.setTheme) return
|
||||
const win = currentThemeWindow()
|
||||
if (!win?.setTheme) return
|
||||
|
||||
void win.setTheme(value).catch(() => undefined)
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na
|
||||
const PALETTE_ID = "command.palette"
|
||||
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
||||
const SUGGESTED_PREFIX = "suggested."
|
||||
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new"])
|
||||
|
||||
function actionId(id: string) {
|
||||
if (!id.startsWith(SUGGESTED_PREFIX)) return id
|
||||
@@ -33,6 +34,11 @@ function signatureFromEvent(event: KeyboardEvent) {
|
||||
return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
|
||||
}
|
||||
|
||||
function isAllowedEditableKeybind(id: string | undefined) {
|
||||
if (!id) return false
|
||||
return EDITABLE_KEYBIND_IDS.has(actionId(id))
|
||||
}
|
||||
|
||||
export type KeybindConfig = string
|
||||
|
||||
export interface Keybind {
|
||||
@@ -56,6 +62,8 @@ export interface CommandOption {
|
||||
onHighlight?: () => (() => void) | void
|
||||
}
|
||||
|
||||
type CommandSource = "palette" | "keybind" | "slash"
|
||||
|
||||
export type CommandCatalogItem = {
|
||||
title: string
|
||||
description?: string
|
||||
@@ -169,6 +177,14 @@ export function formatKeybind(config: string): string {
|
||||
return IS_MAC ? parts.join("") : parts.join("+")
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (target.isContentEditable) return true
|
||||
if (target.closest("[contenteditable='true']")) return true
|
||||
if (target.closest("input, textarea, select")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
|
||||
name: "Command",
|
||||
init: () => {
|
||||
@@ -275,13 +291,18 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
return map
|
||||
})
|
||||
|
||||
const run = (id: string, source?: "palette" | "keybind" | "slash") => {
|
||||
const optionMap = createMemo(() => {
|
||||
const map = new Map<string, CommandOption>()
|
||||
for (const option of options()) {
|
||||
if (option.id === id || option.id === "suggested." + id) {
|
||||
option.onSelect?.(source)
|
||||
return
|
||||
}
|
||||
map.set(option.id, option)
|
||||
map.set(actionId(option.id), option)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const run = (id: string, source?: CommandSource) => {
|
||||
const option = optionMap().get(id)
|
||||
option?.onSelect?.(source)
|
||||
}
|
||||
|
||||
const showPalette = () => {
|
||||
@@ -292,14 +313,18 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
if (suspended() || dialog.active) return
|
||||
|
||||
const sig = signatureFromEvent(event)
|
||||
const isPalette = palette().has(sig)
|
||||
const option = keymap().get(sig)
|
||||
const modified = event.ctrlKey || event.metaKey || event.altKey
|
||||
|
||||
if (palette().has(sig)) {
|
||||
if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified) return
|
||||
|
||||
if (isPalette) {
|
||||
event.preventDefault()
|
||||
showPalette()
|
||||
return
|
||||
}
|
||||
|
||||
const option = keymap().get(sig)
|
||||
if (!option) return
|
||||
event.preventDefault()
|
||||
option.onSelect?.("keybind")
|
||||
@@ -332,7 +357,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
|
||||
return {
|
||||
register,
|
||||
trigger(id: string, source?: "palette" | "keybind" | "slash") {
|
||||
trigger(id: string, source?: CommandSource) {
|
||||
run(id, source)
|
||||
},
|
||||
keybind(id: string) {
|
||||
@@ -351,7 +376,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
},
|
||||
show: showPalette,
|
||||
keybinds(enabled: boolean) {
|
||||
setStore("suspendCount", (count) => count + (enabled ? -1 : 1))
|
||||
setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))
|
||||
},
|
||||
suspended,
|
||||
get catalog() {
|
||||
|
||||
@@ -109,4 +109,45 @@ describe("comments session indexing", () => {
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("remove keeps focus when same comment id exists in another file", () => {
|
||||
createRoot((dispose) => {
|
||||
const comments = createCommentSessionForTest({
|
||||
"a.ts": [line("a.ts", "shared", 10)],
|
||||
"b.ts": [line("b.ts", "shared", 20)],
|
||||
})
|
||||
|
||||
comments.setFocus({ file: "b.ts", id: "shared" })
|
||||
comments.remove("a.ts", "shared")
|
||||
|
||||
expect(comments.focus()).toEqual({ file: "b.ts", id: "shared" })
|
||||
expect(comments.list("a.ts")).toEqual([])
|
||||
expect(comments.list("b.ts").map((item) => item.id)).toEqual(["shared"])
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("setFocus and setActive updater callbacks receive current state", () => {
|
||||
createRoot((dispose) => {
|
||||
const comments = createCommentSessionForTest()
|
||||
|
||||
comments.setFocus({ file: "a.ts", id: "a1" })
|
||||
comments.setFocus((current) => {
|
||||
expect(current).toEqual({ file: "a.ts", id: "a1" })
|
||||
return { file: "b.ts", id: "b1" }
|
||||
})
|
||||
|
||||
comments.setActive({ file: "c.ts", id: "c1" })
|
||||
comments.setActive((current) => {
|
||||
expect(current).toEqual({ file: "c.ts", id: "c1" })
|
||||
return null
|
||||
})
|
||||
|
||||
expect(comments.focus()).toEqual({ file: "b.ts", id: "b1" })
|
||||
expect(comments.active()).toBeNull()
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { createScopedCache } from "@/utils/scoped-cache"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import type { SelectedLineRange } from "@/context/file"
|
||||
|
||||
export type LineComment = {
|
||||
@@ -19,6 +20,19 @@ type CommentFocus = { file: string; id: string }
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
const MAX_COMMENT_SESSIONS = 20
|
||||
|
||||
function sessionKey(dir: string, id: string | undefined) {
|
||||
return `${dir}\n${id ?? WORKSPACE_KEY}`
|
||||
}
|
||||
|
||||
function decodeSessionKey(key: string) {
|
||||
const split = key.lastIndexOf("\n")
|
||||
if (split < 0) return { dir: key, id: WORKSPACE_KEY }
|
||||
return {
|
||||
dir: key.slice(0, split),
|
||||
id: key.slice(split + 1),
|
||||
}
|
||||
}
|
||||
|
||||
type CommentStore = {
|
||||
comments: Record<string, LineComment[]>
|
||||
}
|
||||
@@ -30,37 +44,36 @@ function aggregate(comments: Record<string, LineComment[]>) {
|
||||
.sort((a, b) => a.time - b.time)
|
||||
}
|
||||
|
||||
function insert(items: LineComment[], next: LineComment) {
|
||||
const index = items.findIndex((item) => item.time > next.time)
|
||||
if (index < 0) return [...items, next]
|
||||
return [...items.slice(0, index), next, ...items.slice(index)]
|
||||
}
|
||||
|
||||
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
|
||||
const [state, setState] = createStore({
|
||||
focus: null as CommentFocus | null,
|
||||
active: null as CommentFocus | null,
|
||||
all: aggregate(store.comments),
|
||||
})
|
||||
|
||||
const all = () => aggregate(store.comments)
|
||||
|
||||
const setRef = (
|
||||
key: "focus" | "active",
|
||||
value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null),
|
||||
) => setState(key, value)
|
||||
|
||||
const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
|
||||
setState("focus", value)
|
||||
setRef("focus", value)
|
||||
|
||||
const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
|
||||
setState("active", value)
|
||||
setRef("active", value)
|
||||
|
||||
const list = (file: string) => store.comments[file] ?? []
|
||||
|
||||
const add = (input: Omit<LineComment, "id" | "time">) => {
|
||||
const next: LineComment = {
|
||||
id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2),
|
||||
id: uuid(),
|
||||
time: Date.now(),
|
||||
...input,
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
setStore("comments", input.file, (items) => [...(items ?? []), next])
|
||||
setState("all", (items) => insert(items, next))
|
||||
setFocus({ file: input.file, id: next.id })
|
||||
})
|
||||
|
||||
@@ -70,15 +83,13 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
|
||||
const remove = (file: string, id: string) => {
|
||||
batch(() => {
|
||||
setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id))
|
||||
setState("all", (items) => items.filter((item) => !(item.file === file && item.id === id)))
|
||||
setFocus((current) => (current?.id === id ? null : current))
|
||||
setFocus((current) => (current?.file === file && current.id === id ? null : current))
|
||||
})
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
batch(() => {
|
||||
setStore("comments", reconcile({}))
|
||||
setState("all", [])
|
||||
setFocus(null)
|
||||
setActive(null)
|
||||
})
|
||||
@@ -86,17 +97,16 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
|
||||
|
||||
return {
|
||||
list,
|
||||
all: () => state.all,
|
||||
all,
|
||||
add,
|
||||
remove,
|
||||
clear,
|
||||
focus: () => state.focus,
|
||||
setFocus,
|
||||
clearFocus: () => setFocus(null),
|
||||
clearFocus: () => setRef("focus", null),
|
||||
active: () => state.active,
|
||||
setActive,
|
||||
clearActive: () => setActive(null),
|
||||
reindex: () => setState("all", aggregate(store.comments)),
|
||||
clearActive: () => setRef("active", null),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,11 +126,6 @@ function createCommentSession(dir: string, id: string | undefined) {
|
||||
)
|
||||
const session = createCommentSessionState(store, setStore)
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
session.reindex()
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
list: session.list,
|
||||
@@ -144,11 +149,9 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
||||
const params = useParams()
|
||||
const cache = createScopedCache(
|
||||
(key) => {
|
||||
const split = key.lastIndexOf("\n")
|
||||
const dir = split >= 0 ? key.slice(0, split) : key
|
||||
const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
|
||||
const decoded = decodeSessionKey(key)
|
||||
return createRoot((dispose) => ({
|
||||
value: createCommentSession(dir, id === WORKSPACE_KEY ? undefined : id),
|
||||
value: createCommentSession(decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id),
|
||||
dispose,
|
||||
}))
|
||||
},
|
||||
@@ -161,7 +164,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
||||
onCleanup(() => cache.clear())
|
||||
|
||||
const load = (dir: string, id: string | undefined) => {
|
||||
const key = `${dir}\n${id ?? WORKSPACE_KEY}`
|
||||
const key = sessionKey(dir, id)
|
||||
return cache.get(key).value
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,12 @@ export {
|
||||
touchFileContent,
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
if (typeof error === "string" && error) return error
|
||||
return "Unknown error"
|
||||
}
|
||||
|
||||
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
name: "File",
|
||||
gate: false,
|
||||
@@ -110,6 +116,45 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
setStore("file", file, { path: file, name: getFilename(file) })
|
||||
}
|
||||
|
||||
const setLoading = (file: string) => {
|
||||
setStore(
|
||||
"file",
|
||||
file,
|
||||
produce((draft) => {
|
||||
draft.loading = true
|
||||
draft.error = undefined
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const setLoaded = (file: string, content: FileState["content"]) => {
|
||||
setStore(
|
||||
"file",
|
||||
file,
|
||||
produce((draft) => {
|
||||
draft.loaded = true
|
||||
draft.loading = false
|
||||
draft.content = content
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const setLoadError = (file: string, message: string) => {
|
||||
setStore(
|
||||
"file",
|
||||
file,
|
||||
produce((draft) => {
|
||||
draft.loading = false
|
||||
draft.error = message
|
||||
}),
|
||||
)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.file.loadFailed.title"),
|
||||
description: message,
|
||||
})
|
||||
}
|
||||
|
||||
const load = (input: string, options?: { force?: boolean }) => {
|
||||
const file = path.normalize(input)
|
||||
if (!file) return Promise.resolve()
|
||||
@@ -124,29 +169,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
const pending = inflight.get(key)
|
||||
if (pending) return pending
|
||||
|
||||
setStore(
|
||||
"file",
|
||||
file,
|
||||
produce((draft) => {
|
||||
draft.loading = true
|
||||
draft.error = undefined
|
||||
}),
|
||||
)
|
||||
setLoading(file)
|
||||
|
||||
const promise = sdk.client.file
|
||||
.read({ path: file })
|
||||
.then((x) => {
|
||||
if (scope() !== directory) return
|
||||
const content = x.data
|
||||
setStore(
|
||||
"file",
|
||||
file,
|
||||
produce((draft) => {
|
||||
draft.loaded = true
|
||||
draft.loading = false
|
||||
draft.content = content
|
||||
}),
|
||||
)
|
||||
setLoaded(file, content)
|
||||
|
||||
if (!content) return
|
||||
touchFileContent(file, approxBytes(content))
|
||||
@@ -154,19 +184,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
})
|
||||
.catch((e) => {
|
||||
if (scope() !== directory) return
|
||||
setStore(
|
||||
"file",
|
||||
file,
|
||||
produce((draft) => {
|
||||
draft.loading = false
|
||||
draft.error = e.message
|
||||
}),
|
||||
)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.file.loadFailed.title"),
|
||||
description: e.message,
|
||||
})
|
||||
setLoadError(file, errorMessage(e))
|
||||
})
|
||||
.finally(() => {
|
||||
inflight.delete(key)
|
||||
@@ -211,21 +229,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
return state
|
||||
}
|
||||
|
||||
const scrollTop = (input: string) => view().scrollTop(path.normalize(input))
|
||||
const scrollLeft = (input: string) => view().scrollLeft(path.normalize(input))
|
||||
const selectedLines = (input: string) => view().selectedLines(path.normalize(input))
|
||||
|
||||
const setScrollTop = (input: string, top: number) => {
|
||||
view().setScrollTop(path.normalize(input), top)
|
||||
}
|
||||
|
||||
const setScrollLeft = (input: string, left: number) => {
|
||||
view().setScrollLeft(path.normalize(input), left)
|
||||
}
|
||||
|
||||
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
|
||||
view().setSelectedLines(path.normalize(input), range)
|
||||
function withPath(input: string, action: (file: string) => unknown) {
|
||||
return action(path.normalize(input))
|
||||
}
|
||||
const scrollTop = (input: string) => withPath(input, (file) => view().scrollTop(file))
|
||||
const scrollLeft = (input: string) => withPath(input, (file) => view().scrollLeft(file))
|
||||
const selectedLines = (input: string) => withPath(input, (file) => view().selectedLines(file))
|
||||
const setScrollTop = (input: string, top: number) => withPath(input, (file) => view().setScrollTop(file, top))
|
||||
const setScrollLeft = (input: string, left: number) => withPath(input, (file) => view().setScrollLeft(file, left))
|
||||
const setSelectedLines = (input: string, range: SelectedLineRange | null) =>
|
||||
withPath(input, (file) => view().setSelectedLines(file, range))
|
||||
|
||||
onCleanup(() => {
|
||||
stop()
|
||||
|
||||
@@ -23,6 +23,16 @@ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
|
||||
}
|
||||
}
|
||||
|
||||
function equalSelectedLines(a: SelectedLineRange | null | undefined, b: SelectedLineRange | null | undefined) {
|
||||
if (!a && !b) return true
|
||||
if (!a || !b) return false
|
||||
const left = normalizeSelectedLines(a)
|
||||
const right = normalizeSelectedLines(b)
|
||||
return (
|
||||
left.start === right.start && left.end === right.end && left.side === right.side && left.endSide === right.endSide
|
||||
)
|
||||
}
|
||||
|
||||
function createViewSession(dir: string, id: string | undefined) {
|
||||
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
|
||||
|
||||
@@ -65,36 +75,36 @@ function createViewSession(dir: string, id: string | undefined) {
|
||||
const selectedLines = (path: string) => view.file[path]?.selectedLines
|
||||
|
||||
const setScrollTop = (path: string, top: number) => {
|
||||
setView("file", path, (current) => {
|
||||
if (current?.scrollTop === top) return current
|
||||
return {
|
||||
...(current ?? {}),
|
||||
scrollTop: top,
|
||||
}
|
||||
})
|
||||
setView(
|
||||
produce((draft) => {
|
||||
const file = draft.file[path] ?? (draft.file[path] = {})
|
||||
if (file.scrollTop === top) return
|
||||
file.scrollTop = top
|
||||
}),
|
||||
)
|
||||
pruneView(path)
|
||||
}
|
||||
|
||||
const setScrollLeft = (path: string, left: number) => {
|
||||
setView("file", path, (current) => {
|
||||
if (current?.scrollLeft === left) return current
|
||||
return {
|
||||
...(current ?? {}),
|
||||
scrollLeft: left,
|
||||
}
|
||||
})
|
||||
setView(
|
||||
produce((draft) => {
|
||||
const file = draft.file[path] ?? (draft.file[path] = {})
|
||||
if (file.scrollLeft === left) return
|
||||
file.scrollLeft = left
|
||||
}),
|
||||
)
|
||||
pruneView(path)
|
||||
}
|
||||
|
||||
const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
|
||||
const next = range ? normalizeSelectedLines(range) : null
|
||||
setView("file", path, (current) => {
|
||||
if (current?.selectedLines === next) return current
|
||||
return {
|
||||
...(current ?? {}),
|
||||
selectedLines: next,
|
||||
}
|
||||
})
|
||||
setView(
|
||||
produce((draft) => {
|
||||
const file = draft.file[path] ?? (draft.file[path] = {})
|
||||
if (equalSelectedLines(file.selectedLines, next)) return
|
||||
file.selectedLines = next
|
||||
}),
|
||||
)
|
||||
pruneView(path)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,28 +12,44 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
const platform = usePlatform()
|
||||
const abort = new AbortController()
|
||||
|
||||
const password = typeof window === "undefined" ? undefined : window.__OPENCODE__?.serverPassword
|
||||
|
||||
const auth = (() => {
|
||||
if (typeof window === "undefined") return
|
||||
const password = window.__OPENCODE__?.serverPassword
|
||||
if (!password) return
|
||||
if (!server.isLocal()) return
|
||||
return {
|
||||
Authorization: `Basic ${btoa(`opencode:${password}`)}`,
|
||||
}
|
||||
})()
|
||||
|
||||
const eventFetch = (() => {
|
||||
if (!platform.fetch) return
|
||||
try {
|
||||
const url = new URL(server.url)
|
||||
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
|
||||
if (url.protocol === "http:" && !loopback) return platform.fetch
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
})()
|
||||
|
||||
const eventSdk = createOpencodeClient({
|
||||
baseUrl: server.url,
|
||||
signal: abort.signal,
|
||||
headers: auth,
|
||||
fetch: eventFetch,
|
||||
headers: eventFetch ? undefined : auth,
|
||||
})
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: Event
|
||||
}>()
|
||||
|
||||
type Queued = { directory: string; payload: Event }
|
||||
const FLUSH_FRAME_MS = 16
|
||||
const STREAM_YIELD_MS = 8
|
||||
const RECONNECT_DELAY_MS = 250
|
||||
|
||||
let queue: Array<Queued | undefined> = []
|
||||
let buffer: Array<Queued | undefined> = []
|
||||
let queue: Queued[] = []
|
||||
let buffer: Queued[] = []
|
||||
const coalesced = new Map<string, number>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
@@ -62,7 +78,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
last = Date.now()
|
||||
batch(() => {
|
||||
for (const event of events) {
|
||||
if (!event) continue
|
||||
emitter.emit(event.directory, event.payload)
|
||||
}
|
||||
})
|
||||
@@ -73,33 +88,62 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
const schedule = () => {
|
||||
if (timer) return
|
||||
const elapsed = Date.now() - last
|
||||
timer = setTimeout(flush, Math.max(0, 16 - elapsed))
|
||||
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const events = await eventSdk.global.event()
|
||||
let yielded = Date.now()
|
||||
for await (const event of events.stream) {
|
||||
const directory = event.directory ?? "global"
|
||||
const payload = event.payload
|
||||
const k = key(directory, payload)
|
||||
if (k) {
|
||||
const i = coalesced.get(k)
|
||||
if (i !== undefined) {
|
||||
queue[i] = undefined
|
||||
}
|
||||
coalesced.set(k, queue.length)
|
||||
}
|
||||
queue.push({ directory, payload })
|
||||
schedule()
|
||||
let streamErrorLogged = false
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
if (Date.now() - yielded < 8) continue
|
||||
yielded = Date.now()
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
void (async () => {
|
||||
while (!abort.signal.aborted) {
|
||||
try {
|
||||
const events = await eventSdk.global.event({
|
||||
onSseError: (error) => {
|
||||
if (streamErrorLogged) return
|
||||
streamErrorLogged = true
|
||||
console.error("[global-sdk] event stream error", {
|
||||
url: server.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
error,
|
||||
})
|
||||
},
|
||||
})
|
||||
let yielded = Date.now()
|
||||
for await (const event of events.stream) {
|
||||
streamErrorLogged = false
|
||||
const directory = event.directory ?? "global"
|
||||
const payload = event.payload
|
||||
const k = key(directory, payload)
|
||||
if (k) {
|
||||
const i = coalesced.get(k)
|
||||
if (i !== undefined) {
|
||||
queue[i] = { directory, payload }
|
||||
continue
|
||||
}
|
||||
coalesced.set(k, queue.length)
|
||||
}
|
||||
queue.push({ directory, payload })
|
||||
schedule()
|
||||
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
yielded = Date.now()
|
||||
await wait(0)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!streamErrorLogged) {
|
||||
streamErrorLogged = true
|
||||
console.error("[global-sdk] event stream failed", {
|
||||
url: server.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (abort.signal.aborted) return
|
||||
await wait(RECONNECT_DELAY_MS)
|
||||
}
|
||||
})()
|
||||
.finally(flush)
|
||||
.catch(() => undefined)
|
||||
})().finally(flush)
|
||||
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
|
||||
@@ -47,6 +47,20 @@ type GlobalStore = {
|
||||
reload: undefined | "pending" | "complete"
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
if (typeof error === "string" && error) return error
|
||||
return "Unknown error"
|
||||
}
|
||||
|
||||
function setDevStats(value: {
|
||||
activeDirectoryStores: number
|
||||
evictions: number
|
||||
loadSessionsFullFetchFallback: number
|
||||
}) {
|
||||
;(globalThis as { __OPENCODE_GLOBAL_SYNC_STATS?: typeof value }).__OPENCODE_GLOBAL_SYNC_STATS = value
|
||||
}
|
||||
|
||||
function createGlobalSync() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
@@ -81,19 +95,11 @@ function createGlobalSync() {
|
||||
|
||||
const updateStats = (activeDirectoryStores: number) => {
|
||||
if (!import.meta.env.DEV) return
|
||||
;(
|
||||
globalThis as {
|
||||
__OPENCODE_GLOBAL_SYNC_STATS?: {
|
||||
activeDirectoryStores: number
|
||||
evictions: number
|
||||
loadSessionsFullFetchFallback: number
|
||||
}
|
||||
}
|
||||
).__OPENCODE_GLOBAL_SYNC_STATS = {
|
||||
setDevStats({
|
||||
activeDirectoryStores,
|
||||
evictions: stats.evictions,
|
||||
loadSessionsFullFetchFallback: stats.loadSessionsFallback,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const paused = () => untrack(() => globalStore.reload) !== undefined
|
||||
@@ -204,7 +210,10 @@ function createGlobalSync() {
|
||||
.catch((err) => {
|
||||
console.error("Failed to load sessions", err)
|
||||
const project = getFilename(directory)
|
||||
showToast({ title: language.t("toast.session.listFailed.title", { project }), description: err.message })
|
||||
showToast({
|
||||
title: language.t("toast.session.listFailed.title", { project }),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
})
|
||||
|
||||
sessionLoads.set(directory, promise)
|
||||
@@ -307,12 +316,28 @@ function createGlobalSync() {
|
||||
void bootstrap()
|
||||
})
|
||||
|
||||
function projectMeta(directory: string, patch: ProjectMeta) {
|
||||
children.projectMeta(directory, patch)
|
||||
const projectApi = {
|
||||
loadSessions,
|
||||
meta(directory: string, patch: ProjectMeta) {
|
||||
children.projectMeta(directory, patch)
|
||||
},
|
||||
icon(directory: string, value: string | undefined) {
|
||||
children.projectIcon(directory, value)
|
||||
},
|
||||
}
|
||||
|
||||
function projectIcon(directory: string, value: string | undefined) {
|
||||
children.projectIcon(directory, value)
|
||||
const updateConfig = async (config: Config) => {
|
||||
setGlobalStore("reload", "pending")
|
||||
return globalSDK.client.global.config
|
||||
.update({ config })
|
||||
.then(bootstrap)
|
||||
.then(() => {
|
||||
setGlobalStore("reload", "complete")
|
||||
})
|
||||
.catch((error) => {
|
||||
setGlobalStore("reload", undefined)
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -326,19 +351,8 @@ function createGlobalSync() {
|
||||
},
|
||||
child: children.child,
|
||||
bootstrap,
|
||||
updateConfig: (config: Config) => {
|
||||
setGlobalStore("reload", "pending")
|
||||
return globalSDK.client.global.config.update({ config }).finally(() => {
|
||||
setTimeout(() => {
|
||||
setGlobalStore("reload", "complete")
|
||||
}, 1000)
|
||||
})
|
||||
},
|
||||
project: {
|
||||
loadSessions,
|
||||
meta: projectMeta,
|
||||
icon: projectIcon,
|
||||
},
|
||||
updateConfig,
|
||||
project: projectApi,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
39
packages/app/src/context/global-sync/child-store.test.ts
Normal file
39
packages/app/src/context/global-sync/child-store.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot, getOwner } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { State } from "./types"
|
||||
import { createChildStoreManager } from "./child-store"
|
||||
|
||||
const child = () => createStore({} as State)
|
||||
|
||||
describe("createChildStoreManager", () => {
|
||||
test("does not evict the active directory during mark", () => {
|
||||
const owner = createRoot((dispose) => {
|
||||
const current = getOwner()
|
||||
dispose()
|
||||
return current
|
||||
})
|
||||
if (!owner) throw new Error("owner required")
|
||||
|
||||
const manager = createChildStoreManager({
|
||||
owner,
|
||||
markStats() {},
|
||||
incrementEvictions() {},
|
||||
isBooting: () => false,
|
||||
isLoadingSessions: () => false,
|
||||
onBootstrap() {},
|
||||
onDispose() {},
|
||||
})
|
||||
|
||||
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
|
||||
manager.children[directory] = child()
|
||||
manager.pin(directory)
|
||||
})
|
||||
|
||||
const directory = "/active"
|
||||
manager.children[directory] = child()
|
||||
manager.mark(directory)
|
||||
|
||||
expect(manager.children[directory]).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -36,7 +36,7 @@ export function createChildStoreManager(input: {
|
||||
const mark = (directory: string) => {
|
||||
if (!directory) return
|
||||
lifecycle.set(directory, { lastAccessAt: Date.now() })
|
||||
runEviction()
|
||||
runEviction(directory)
|
||||
}
|
||||
|
||||
const pin = (directory: string) => {
|
||||
@@ -106,7 +106,7 @@ export function createChildStoreManager(input: {
|
||||
return true
|
||||
}
|
||||
|
||||
function runEviction() {
|
||||
function runEviction(skip?: string) {
|
||||
const stores = Object.keys(children)
|
||||
if (stores.length === 0) return
|
||||
const list = pickDirectoriesToEvict({
|
||||
@@ -116,7 +116,7 @@ export function createChildStoreManager(input: {
|
||||
max: MAX_DIR_STORES,
|
||||
ttl: DIR_IDLE_TTL_MS,
|
||||
now: Date.now(),
|
||||
})
|
||||
}).filter((directory) => directory !== skip)
|
||||
if (list.length === 0) return
|
||||
for (const directory of list) {
|
||||
if (!disposeDirectory(directory)) continue
|
||||
|
||||
@@ -233,6 +233,7 @@ export function applyDirectoryEvent(input: {
|
||||
}
|
||||
case "vcs.branch.updated": {
|
||||
const props = event.properties as { branch: string }
|
||||
if (input.store.vcs?.branch === props.branch) break
|
||||
const next = { branch: props.branch }
|
||||
input.setStore("vcs", next)
|
||||
if (input.vcsCache) input.vcsCache.setStore("value", next)
|
||||
|
||||
@@ -119,9 +119,7 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p
|
||||
const highlights = releases.slice(start, end).flatMap((release) => release.highlights)
|
||||
const seen = new Set<string>()
|
||||
const unique = highlights.filter((highlight) => {
|
||||
const key = [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join(
|
||||
"\n",
|
||||
)
|
||||
const key = dedupeKey(highlight)
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
@@ -129,6 +127,16 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p
|
||||
return unique.slice(0, 5)
|
||||
}
|
||||
|
||||
function dedupeKey(highlight: Highlight) {
|
||||
return [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join("\n")
|
||||
}
|
||||
|
||||
function loadReleaseHighlights(value: unknown, current?: string, previous?: string) {
|
||||
const releases = parseChangelog(value)
|
||||
if (!releases?.length) return []
|
||||
return sliceHighlights({ releases, current, previous })
|
||||
}
|
||||
|
||||
export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({
|
||||
name: "Highlights",
|
||||
gate: false,
|
||||
@@ -140,14 +148,57 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
||||
|
||||
const [from, setFrom] = createSignal<string | undefined>(undefined)
|
||||
const [to, setTo] = createSignal<string | undefined>(undefined)
|
||||
const [timer, setTimer] = createSignal<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const state = { started: false }
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer === undefined) return
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
}
|
||||
|
||||
const markSeen = () => {
|
||||
if (!platform.version) return
|
||||
setStore("version", platform.version)
|
||||
}
|
||||
|
||||
const start = (previous: string) => {
|
||||
if (!settings.general.releaseNotes()) {
|
||||
markSeen()
|
||||
return
|
||||
}
|
||||
|
||||
const fetcher = platform.fetch ?? fetch
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => {
|
||||
controller.abort()
|
||||
clearTimer()
|
||||
})
|
||||
|
||||
fetcher(CHANGELOG_URL, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
.then((response) => (response.ok ? (response.json() as Promise<unknown>) : undefined))
|
||||
.then((json) => {
|
||||
if (!json) return
|
||||
const highlights = loadReleaseHighlights(json, platform.version, previous)
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
if (highlights.length === 0) {
|
||||
markSeen()
|
||||
return
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined
|
||||
markSeen()
|
||||
dialog.show(() => <DialogReleaseNotes highlights={highlights} />)
|
||||
}, 500)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (state.started) return
|
||||
if (!ready()) return
|
||||
@@ -165,51 +216,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
||||
|
||||
setFrom(previous)
|
||||
setTo(platform.version)
|
||||
|
||||
if (!settings.general.releaseNotes()) {
|
||||
markSeen()
|
||||
return
|
||||
}
|
||||
|
||||
const fetcher = platform.fetch ?? fetch
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => {
|
||||
controller.abort()
|
||||
const id = timer()
|
||||
if (id === undefined) return
|
||||
clearTimeout(id)
|
||||
})
|
||||
|
||||
fetcher(CHANGELOG_URL, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
.then((response) => (response.ok ? (response.json() as Promise<unknown>) : undefined))
|
||||
.then((json) => {
|
||||
if (!json) return
|
||||
const releases = parseChangelog(json)
|
||||
if (!releases) return
|
||||
if (releases.length === 0) return
|
||||
const highlights = sliceHighlights({
|
||||
releases,
|
||||
current: platform.version,
|
||||
previous,
|
||||
})
|
||||
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
if (highlights.length === 0) {
|
||||
markSeen()
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
markSeen()
|
||||
dialog.show(() => <DialogReleaseNotes highlights={highlights} />)
|
||||
}, 500)
|
||||
setTimer(timer)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
start(previous)
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -57,6 +57,10 @@ export type Locale =
|
||||
type RawDictionary = typeof en & typeof uiEn
|
||||
type Dictionary = i18n.Flatten<RawDictionary>
|
||||
|
||||
function cookie(locale: Locale) {
|
||||
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
|
||||
}
|
||||
|
||||
const LOCALES: readonly Locale[] = [
|
||||
"en",
|
||||
"zh",
|
||||
@@ -76,6 +80,66 @@ const LOCALES: readonly Locale[] = [
|
||||
"th",
|
||||
]
|
||||
|
||||
const LABEL_KEY: Record<Locale, keyof Dictionary> = {
|
||||
en: "language.en",
|
||||
zh: "language.zh",
|
||||
zht: "language.zht",
|
||||
ko: "language.ko",
|
||||
de: "language.de",
|
||||
es: "language.es",
|
||||
fr: "language.fr",
|
||||
da: "language.da",
|
||||
ja: "language.ja",
|
||||
pl: "language.pl",
|
||||
ru: "language.ru",
|
||||
ar: "language.ar",
|
||||
no: "language.no",
|
||||
br: "language.br",
|
||||
th: "language.th",
|
||||
bs: "language.bs",
|
||||
}
|
||||
|
||||
const base = i18n.flatten({ ...en, ...uiEn })
|
||||
const DICT: Record<Locale, Dictionary> = {
|
||||
en: base,
|
||||
zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) },
|
||||
zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) },
|
||||
ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
|
||||
de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) },
|
||||
es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) },
|
||||
fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) },
|
||||
da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) },
|
||||
ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) },
|
||||
pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) },
|
||||
ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) },
|
||||
ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) },
|
||||
no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) },
|
||||
br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) },
|
||||
th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) },
|
||||
bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) },
|
||||
}
|
||||
|
||||
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
||||
{ locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
|
||||
{ locale: "zh", match: (language) => language.startsWith("zh") },
|
||||
{ locale: "ko", match: (language) => language.startsWith("ko") },
|
||||
{ locale: "de", match: (language) => language.startsWith("de") },
|
||||
{ locale: "es", match: (language) => language.startsWith("es") },
|
||||
{ locale: "fr", match: (language) => language.startsWith("fr") },
|
||||
{ locale: "da", match: (language) => language.startsWith("da") },
|
||||
{ locale: "ja", match: (language) => language.startsWith("ja") },
|
||||
{ locale: "pl", match: (language) => language.startsWith("pl") },
|
||||
{ locale: "ru", match: (language) => language.startsWith("ru") },
|
||||
{ locale: "ar", match: (language) => language.startsWith("ar") },
|
||||
{
|
||||
locale: "no",
|
||||
match: (language) => language.startsWith("no") || language.startsWith("nb") || language.startsWith("nn"),
|
||||
},
|
||||
{ locale: "br", match: (language) => language.startsWith("pt") },
|
||||
{ locale: "th", match: (language) => language.startsWith("th") },
|
||||
{ locale: "bs", match: (language) => language.startsWith("bs") },
|
||||
]
|
||||
|
||||
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
|
||||
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
|
||||
zh,
|
||||
@@ -102,28 +166,9 @@ function detectLocale(): Locale {
|
||||
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
||||
for (const language of languages) {
|
||||
if (!language) continue
|
||||
if (language.toLowerCase().startsWith("zh")) {
|
||||
if (language.toLowerCase().includes("hant")) return "zht"
|
||||
return "zh"
|
||||
}
|
||||
if (language.toLowerCase().startsWith("ko")) return "ko"
|
||||
if (language.toLowerCase().startsWith("de")) return "de"
|
||||
if (language.toLowerCase().startsWith("es")) return "es"
|
||||
if (language.toLowerCase().startsWith("fr")) return "fr"
|
||||
if (language.toLowerCase().startsWith("da")) return "da"
|
||||
if (language.toLowerCase().startsWith("ja")) return "ja"
|
||||
if (language.toLowerCase().startsWith("pl")) return "pl"
|
||||
if (language.toLowerCase().startsWith("ru")) return "ru"
|
||||
if (language.toLowerCase().startsWith("ar")) return "ar"
|
||||
if (
|
||||
language.toLowerCase().startsWith("no") ||
|
||||
language.toLowerCase().startsWith("nb") ||
|
||||
language.toLowerCase().startsWith("nn")
|
||||
)
|
||||
return "no"
|
||||
if (language.toLowerCase().startsWith("pt")) return "br"
|
||||
if (language.toLowerCase().startsWith("th")) return "th"
|
||||
if (language.toLowerCase().startsWith("bs")) return "bs"
|
||||
const normalized = language.toLowerCase()
|
||||
const match = localeMatchers.find((entry) => entry.match(normalized))
|
||||
if (match) return match.locale
|
||||
}
|
||||
|
||||
return "en"
|
||||
@@ -139,24 +184,9 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
}),
|
||||
)
|
||||
|
||||
const locale = createMemo<Locale>(() => {
|
||||
if (store.locale === "zh") return "zh"
|
||||
if (store.locale === "zht") return "zht"
|
||||
if (store.locale === "ko") return "ko"
|
||||
if (store.locale === "de") return "de"
|
||||
if (store.locale === "es") return "es"
|
||||
if (store.locale === "fr") return "fr"
|
||||
if (store.locale === "da") return "da"
|
||||
if (store.locale === "ja") return "ja"
|
||||
if (store.locale === "pl") return "pl"
|
||||
if (store.locale === "ru") return "ru"
|
||||
if (store.locale === "ar") return "ar"
|
||||
if (store.locale === "no") return "no"
|
||||
if (store.locale === "br") return "br"
|
||||
if (store.locale === "th") return "th"
|
||||
if (store.locale === "bs") return "bs"
|
||||
return "en"
|
||||
})
|
||||
const locale = createMemo<Locale>(() =>
|
||||
LOCALES.includes(store.locale as Locale) ? (store.locale as Locale) : "en",
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const current = locale()
|
||||
@@ -164,52 +194,16 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
setStore("locale", current)
|
||||
})
|
||||
|
||||
const base = i18n.flatten({ ...en, ...uiEn })
|
||||
const dict = createMemo<Dictionary>(() => {
|
||||
if (locale() === "en") return base
|
||||
if (locale() === "zh") return { ...base, ...i18n.flatten({ ...zh, ...uiZh }) }
|
||||
if (locale() === "zht") return { ...base, ...i18n.flatten({ ...zht, ...uiZht }) }
|
||||
if (locale() === "de") return { ...base, ...i18n.flatten({ ...de, ...uiDe }) }
|
||||
if (locale() === "es") return { ...base, ...i18n.flatten({ ...es, ...uiEs }) }
|
||||
if (locale() === "fr") return { ...base, ...i18n.flatten({ ...fr, ...uiFr }) }
|
||||
if (locale() === "da") return { ...base, ...i18n.flatten({ ...da, ...uiDa }) }
|
||||
if (locale() === "ja") return { ...base, ...i18n.flatten({ ...ja, ...uiJa }) }
|
||||
if (locale() === "pl") return { ...base, ...i18n.flatten({ ...pl, ...uiPl }) }
|
||||
if (locale() === "ru") return { ...base, ...i18n.flatten({ ...ru, ...uiRu }) }
|
||||
if (locale() === "ar") return { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }
|
||||
if (locale() === "no") return { ...base, ...i18n.flatten({ ...no, ...uiNo }) }
|
||||
if (locale() === "br") return { ...base, ...i18n.flatten({ ...br, ...uiBr }) }
|
||||
if (locale() === "th") return { ...base, ...i18n.flatten({ ...th, ...uiTh }) }
|
||||
if (locale() === "bs") return { ...base, ...i18n.flatten({ ...bs, ...uiBs }) }
|
||||
return { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }
|
||||
})
|
||||
const dict = createMemo<Dictionary>(() => DICT[locale()])
|
||||
|
||||
const t = i18n.translator(dict, i18n.resolveTemplate)
|
||||
|
||||
const labelKey: Record<Locale, keyof Dictionary> = {
|
||||
en: "language.en",
|
||||
zh: "language.zh",
|
||||
zht: "language.zht",
|
||||
ko: "language.ko",
|
||||
de: "language.de",
|
||||
es: "language.es",
|
||||
fr: "language.fr",
|
||||
da: "language.da",
|
||||
ja: "language.ja",
|
||||
pl: "language.pl",
|
||||
ru: "language.ru",
|
||||
ar: "language.ar",
|
||||
no: "language.no",
|
||||
br: "language.br",
|
||||
th: "language.th",
|
||||
bs: "language.bs",
|
||||
}
|
||||
|
||||
const label = (value: Locale) => t(labelKey[value])
|
||||
const label = (value: Locale) => t(LABEL_KEY[value])
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document !== "object") return
|
||||
document.documentElement.lang = locale()
|
||||
document.cookie = cookie(locale())
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,12 +4,16 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { useServer } from "./server"
|
||||
import { usePlatform } from "./platform"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { same } from "@/utils/same"
|
||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||
|
||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||
const DEFAULT_PANEL_WIDTH = 344
|
||||
const DEFAULT_SESSION_WIDTH = 600
|
||||
const DEFAULT_TERMINAL_HEIGHT = 280
|
||||
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
||||
|
||||
export function getAvatarColors(key?: string) {
|
||||
@@ -84,12 +88,21 @@ export function pruneSessionKeys(input: {
|
||||
.slice(input.max)
|
||||
}
|
||||
|
||||
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
|
||||
const all = current?.all ?? []
|
||||
if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab }
|
||||
if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab }
|
||||
if (!all.includes(tab)) return { all: [...all, tab], active: tab }
|
||||
return { all, active: tab }
|
||||
}
|
||||
|
||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||
name: "Layout",
|
||||
init: () => {
|
||||
const globalSdk = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
@@ -114,11 +127,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
if (!isRecord(fileTree)) return fileTree
|
||||
if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
|
||||
|
||||
const width = typeof fileTree.width === "number" ? fileTree.width : 344
|
||||
const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_PANEL_WIDTH
|
||||
return {
|
||||
...fileTree,
|
||||
opened: true,
|
||||
width: width === 260 ? 344 : width,
|
||||
width: width === 260 ? DEFAULT_PANEL_WIDTH : width,
|
||||
tab: "changes",
|
||||
}
|
||||
})()
|
||||
@@ -149,12 +162,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
createStore({
|
||||
sidebar: {
|
||||
opened: false,
|
||||
width: 344,
|
||||
width: DEFAULT_PANEL_WIDTH,
|
||||
workspaces: {} as Record<string, boolean>,
|
||||
workspacesDefault: false,
|
||||
},
|
||||
terminal: {
|
||||
height: 280,
|
||||
height: DEFAULT_TERMINAL_HEIGHT,
|
||||
opened: false,
|
||||
},
|
||||
review: {
|
||||
@@ -163,11 +176,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
fileTree: {
|
||||
opened: true,
|
||||
width: 344,
|
||||
width: DEFAULT_PANEL_WIDTH,
|
||||
tab: "changes" as "changes" | "all",
|
||||
},
|
||||
session: {
|
||||
width: 600,
|
||||
width: DEFAULT_SESSION_WIDTH,
|
||||
},
|
||||
mobileSidebar: {
|
||||
opened: false,
|
||||
@@ -182,8 +195,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
|
||||
const MAX_SESSION_KEYS = 50
|
||||
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
|
||||
const meta = { active: undefined as string | undefined, pruned: false }
|
||||
const used = new Map<string, number>()
|
||||
const usage = {
|
||||
active: undefined as string | undefined,
|
||||
pruned: false,
|
||||
used: new Map<string, number>(),
|
||||
}
|
||||
|
||||
const SESSION_STATE_KEYS = [
|
||||
{ key: "prompt", legacy: "prompt", version: "v2" },
|
||||
@@ -200,10 +216,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
|
||||
for (const entry of SESSION_STATE_KEYS) {
|
||||
const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
|
||||
void removePersisted(target)
|
||||
void removePersisted(target, platform)
|
||||
|
||||
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
|
||||
void removePersisted({ key: legacyKey })
|
||||
void removePersisted({ key: legacyKey }, platform)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,7 +228,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const drop = pruneSessionKeys({
|
||||
keep,
|
||||
max: MAX_SESSION_KEYS,
|
||||
used,
|
||||
used: usage.used,
|
||||
view: Object.keys(store.sessionView),
|
||||
tabs: Object.keys(store.sessionTabs),
|
||||
})
|
||||
@@ -231,18 +247,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
dropSessionState(drop)
|
||||
|
||||
for (const key of drop) {
|
||||
used.delete(key)
|
||||
usage.used.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
function touch(sessionKey: string) {
|
||||
meta.active = sessionKey
|
||||
used.set(sessionKey, Date.now())
|
||||
usage.active = sessionKey
|
||||
usage.used.set(sessionKey, Date.now())
|
||||
|
||||
if (!ready()) return
|
||||
if (meta.pruned) return
|
||||
if (usage.pruned) return
|
||||
|
||||
meta.pruned = true
|
||||
usage.pruned = true
|
||||
prune(sessionKey)
|
||||
}
|
||||
|
||||
@@ -251,7 +267,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
|
||||
onFlush: (sessionKey, next) => {
|
||||
const current = store.sessionView[sessionKey]
|
||||
const keep = meta.active ?? sessionKey
|
||||
const keep = usage.active ?? sessionKey
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, { scroll: next })
|
||||
prune(keep)
|
||||
@@ -267,10 +283,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (meta.pruned) return
|
||||
const active = meta.active
|
||||
if (usage.pruned) return
|
||||
const active = usage.active
|
||||
if (!active) return
|
||||
meta.pruned = true
|
||||
usage.pruned = true
|
||||
prune(active)
|
||||
})
|
||||
|
||||
@@ -544,32 +560,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
fileTree: {
|
||||
opened: createMemo(() => store.fileTree?.opened ?? true),
|
||||
width: createMemo(() => store.fileTree?.width ?? 344),
|
||||
width: createMemo(() => store.fileTree?.width ?? DEFAULT_PANEL_WIDTH),
|
||||
tab: createMemo(() => store.fileTree?.tab ?? "changes"),
|
||||
setTab(tab: "changes" | "all") {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: true, width: 344, tab })
|
||||
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "tab", tab)
|
||||
},
|
||||
open() {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: true, width: 344, tab: "changes" })
|
||||
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "opened", true)
|
||||
},
|
||||
close() {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: false, width: 344, tab: "changes" })
|
||||
setStore("fileTree", { opened: false, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "opened", false)
|
||||
},
|
||||
toggle() {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: true, width: 344, tab: "changes" })
|
||||
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "opened", (x) => !x)
|
||||
@@ -583,7 +599,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
},
|
||||
session: {
|
||||
width: createMemo(() => store.session?.width ?? 600),
|
||||
width: createMemo(() => store.session?.width ?? DEFAULT_SESSION_WIDTH),
|
||||
resize(width: number) {
|
||||
if (!store.session) {
|
||||
setStore("session", { width })
|
||||
@@ -615,7 +631,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
pendingMessage: messageID,
|
||||
pendingMessageAt: at,
|
||||
})
|
||||
prune(meta.active ?? sessionKey)
|
||||
prune(usage.active ?? sessionKey)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -656,7 +672,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
function setTerminalOpened(next: boolean) {
|
||||
const current = store.terminal
|
||||
if (!current) {
|
||||
setStore("terminal", { height: 280, opened: next })
|
||||
setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -753,43 +769,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
async open(tab: string) {
|
||||
const session = key()
|
||||
const current = store.sessionTabs[session] ?? { all: [] }
|
||||
|
||||
if (tab === "review") {
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: current.all.filter((x) => x !== "review"), active: tab })
|
||||
return
|
||||
}
|
||||
setStore("sessionTabs", session, "active", tab)
|
||||
return
|
||||
}
|
||||
|
||||
if (tab === "context") {
|
||||
const all = [tab, ...current.all.filter((x) => x !== tab)]
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all, active: tab })
|
||||
return
|
||||
}
|
||||
setStore("sessionTabs", session, "all", all)
|
||||
setStore("sessionTabs", session, "active", tab)
|
||||
return
|
||||
}
|
||||
|
||||
if (!current.all.includes(tab)) {
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: [tab], active: tab })
|
||||
return
|
||||
}
|
||||
setStore("sessionTabs", session, "all", [...current.all, tab])
|
||||
setStore("sessionTabs", session, "active", tab)
|
||||
return
|
||||
}
|
||||
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: current.all, active: tab })
|
||||
return
|
||||
}
|
||||
setStore("sessionTabs", session, "active", tab)
|
||||
const next = nextSessionTabsForOpen(store.sessionTabs[session], tab)
|
||||
setStore("sessionTabs", session, next)
|
||||
},
|
||||
close(tab: string) {
|
||||
const session = key()
|
||||
|
||||
@@ -16,16 +16,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const providers = useProviders()
|
||||
const connected = createMemo(() => new Set(providers.connected().map((provider) => provider.id)))
|
||||
|
||||
function isModelValid(model: ModelKey) {
|
||||
const provider = providers.all().find((x) => x.id === model.providerID)
|
||||
return (
|
||||
!!provider?.models[model.modelID] &&
|
||||
providers
|
||||
.connected()
|
||||
.map((p) => p.id)
|
||||
.includes(model.providerID)
|
||||
)
|
||||
return !!provider?.models[model.modelID] && connected().has(model.providerID)
|
||||
}
|
||||
|
||||
function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
|
||||
@@ -36,6 +31,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
let setModel: (model: ModelKey | undefined, options?: { recent?: boolean }) => void = () => undefined
|
||||
|
||||
const agent = (() => {
|
||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
||||
const [store, setStore] = createStore<{
|
||||
@@ -75,7 +72,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
if (!value) return
|
||||
setStore("current", value.name)
|
||||
if (value.model)
|
||||
model.set({
|
||||
setModel({
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
@@ -92,38 +89,37 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
model: {},
|
||||
})
|
||||
|
||||
const fallbackModel = createMemo<ModelKey | undefined>(() => {
|
||||
if (sync.data.config.model) {
|
||||
const [providerID, modelID] = sync.data.config.model.split("/")
|
||||
if (isModelValid({ providerID, modelID })) {
|
||||
return {
|
||||
providerID,
|
||||
modelID,
|
||||
}
|
||||
}
|
||||
}
|
||||
const resolveConfigured = () => {
|
||||
if (!sync.data.config.model) return
|
||||
const [providerID, modelID] = sync.data.config.model.split("/")
|
||||
const key = { providerID, modelID }
|
||||
if (isModelValid(key)) return key
|
||||
}
|
||||
|
||||
const resolveRecent = () => {
|
||||
for (const item of models.recent.list()) {
|
||||
if (isModelValid(item)) {
|
||||
return item
|
||||
}
|
||||
if (isModelValid(item)) return item
|
||||
}
|
||||
}
|
||||
|
||||
const resolveDefault = () => {
|
||||
const defaults = providers.default()
|
||||
for (const p of providers.connected()) {
|
||||
const configured = defaults[p.id]
|
||||
for (const provider of providers.connected()) {
|
||||
const configured = defaults[provider.id]
|
||||
if (configured) {
|
||||
const key = { providerID: p.id, modelID: configured }
|
||||
const key = { providerID: provider.id, modelID: configured }
|
||||
if (isModelValid(key)) return key
|
||||
}
|
||||
|
||||
const first = Object.values(p.models)[0]
|
||||
const first = Object.values(provider.models)[0]
|
||||
if (!first) continue
|
||||
const key = { providerID: p.id, modelID: first.id }
|
||||
const key = { providerID: provider.id, modelID: first.id }
|
||||
if (isModelValid(key)) return key
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
const fallbackModel = createMemo<ModelKey | undefined>(() => {
|
||||
return resolveConfigured() ?? resolveRecent() ?? resolveDefault()
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
@@ -163,21 +159,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})
|
||||
}
|
||||
|
||||
const set = (model: ModelKey | undefined, options?: { recent?: boolean }) => {
|
||||
batch(() => {
|
||||
const currentAgent = agent.current()
|
||||
const next = model ?? fallbackModel()
|
||||
if (currentAgent) setEphemeral("model", currentAgent.name, next)
|
||||
if (model) models.setVisibility(model, true)
|
||||
if (options?.recent && model) models.recent.push(model)
|
||||
})
|
||||
}
|
||||
|
||||
setModel = set
|
||||
|
||||
return {
|
||||
ready: models.ready,
|
||||
current,
|
||||
recent,
|
||||
list: models.list,
|
||||
cycle,
|
||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
const currentAgent = agent.current()
|
||||
const next = model ?? fallbackModel()
|
||||
if (currentAgent) setEphemeral("model", currentAgent.name, next)
|
||||
if (model) models.setVisibility(model, true)
|
||||
if (options?.recent && model) models.recent.push(model)
|
||||
})
|
||||
},
|
||||
set,
|
||||
visible(model: ModelKey) {
|
||||
return models.visible(model)
|
||||
},
|
||||
|
||||
@@ -16,6 +16,12 @@ type Store = {
|
||||
variant?: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
const RECENT_LIMIT = 5
|
||||
|
||||
function modelKey(model: ModelKey) {
|
||||
return `${model.providerID}:${model.modelID}`
|
||||
}
|
||||
|
||||
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
|
||||
name: "Models",
|
||||
init: () => {
|
||||
@@ -39,10 +45,27 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||
),
|
||||
)
|
||||
|
||||
const release = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
available().map((model) => {
|
||||
const parsed = DateTime.fromISO(model.release_date)
|
||||
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const latest = createMemo(() =>
|
||||
pipe(
|
||||
available(),
|
||||
filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6),
|
||||
filter(
|
||||
(x) =>
|
||||
Math.abs(
|
||||
(release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
|
||||
.diffNow()
|
||||
.as("months"),
|
||||
) < 6,
|
||||
),
|
||||
groupBy((x) => x.provider.id),
|
||||
mapValues((models) =>
|
||||
pipe(
|
||||
@@ -61,7 +84,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||
),
|
||||
)
|
||||
|
||||
const latestSet = createMemo(() => new Set(latest().map((x) => `${x.providerID}:${x.modelID}`)))
|
||||
const latestSet = createMemo(() => new Set(latest().map((x) => modelKey(x))))
|
||||
|
||||
const visibility = createMemo(() => {
|
||||
const map = new Map<string, Visibility>()
|
||||
@@ -82,20 +105,20 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||
function update(model: ModelKey, state: Visibility) {
|
||||
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
|
||||
if (index >= 0) {
|
||||
setStore("user", index, { visibility: state })
|
||||
setStore("user", index, (current) => ({ ...current, visibility: state }))
|
||||
return
|
||||
}
|
||||
setStore("user", store.user.length, { ...model, visibility: state })
|
||||
}
|
||||
|
||||
const visible = (model: ModelKey) => {
|
||||
const key = `${model.providerID}:${model.modelID}`
|
||||
const key = modelKey(model)
|
||||
const state = visibility().get(key)
|
||||
if (state === "hide") return false
|
||||
if (state === "show") return true
|
||||
if (latestSet().has(key)) return true
|
||||
const m = find(model)
|
||||
if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true
|
||||
const date = release().get(key)
|
||||
if (!date?.isValid) return true
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -104,8 +127,8 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
const push = (model: ModelKey) => {
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||
if (uniq.length > 5) uniq.pop()
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => `${x.providerID}:${x.modelID}`)
|
||||
if (uniq.length > RECENT_LIMIT) uniq.pop()
|
||||
setStore("recent", uniq)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
@@ -13,12 +13,11 @@ import { decode64 } from "@/utils/base64"
|
||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { playSound, soundSrc } from "@/utils/sound"
|
||||
import { buildNotificationIndex } from "./notification-index"
|
||||
|
||||
type NotificationBase = {
|
||||
directory?: string
|
||||
session?: string
|
||||
metadata?: any
|
||||
metadata?: unknown
|
||||
time: number
|
||||
viewed: boolean
|
||||
}
|
||||
@@ -34,6 +33,21 @@ type ErrorNotification = NotificationBase & {
|
||||
|
||||
export type Notification = TurnCompleteNotification | ErrorNotification
|
||||
|
||||
type NotificationIndex = {
|
||||
session: {
|
||||
all: Record<string, Notification[]>
|
||||
unseen: Record<string, Notification[]>
|
||||
unseenCount: Record<string, number>
|
||||
unseenHasError: Record<string, boolean>
|
||||
}
|
||||
project: {
|
||||
all: Record<string, Notification[]>
|
||||
unseen: Record<string, Notification[]>
|
||||
unseenCount: Record<string, number>
|
||||
unseenHasError: Record<string, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_NOTIFICATIONS = 500
|
||||
const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
|
||||
|
||||
@@ -44,6 +58,53 @@ function pruneNotifications(list: Notification[]) {
|
||||
return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
|
||||
}
|
||||
|
||||
function createNotificationIndex(): NotificationIndex {
|
||||
return {
|
||||
session: {
|
||||
all: {},
|
||||
unseen: {},
|
||||
unseenCount: {},
|
||||
unseenHasError: {},
|
||||
},
|
||||
project: {
|
||||
all: {},
|
||||
unseen: {},
|
||||
unseenCount: {},
|
||||
unseenHasError: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildNotificationIndex(list: Notification[]) {
|
||||
const index = createNotificationIndex()
|
||||
|
||||
list.forEach((notification) => {
|
||||
if (notification.session) {
|
||||
const all = index.session.all[notification.session] ?? []
|
||||
index.session.all[notification.session] = [...all, notification]
|
||||
if (!notification.viewed) {
|
||||
const unseen = index.session.unseen[notification.session] ?? []
|
||||
index.session.unseen[notification.session] = [...unseen, notification]
|
||||
index.session.unseenCount[notification.session] = unseen.length + 1
|
||||
if (notification.type === "error") index.session.unseenHasError[notification.session] = true
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.directory) {
|
||||
const all = index.project.all[notification.directory] ?? []
|
||||
index.project.all[notification.directory] = [...all, notification]
|
||||
if (!notification.viewed) {
|
||||
const unseen = index.project.unseen[notification.directory] ?? []
|
||||
index.project.unseen[notification.directory] = [...unseen, notification]
|
||||
index.project.unseenCount[notification.directory] = unseen.length + 1
|
||||
if (notification.type === "error") index.project.unseenHasError[notification.directory] = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||
name: "Notification",
|
||||
init: () => {
|
||||
@@ -68,21 +129,161 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
list: [] as Notification[],
|
||||
}),
|
||||
)
|
||||
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
|
||||
|
||||
const meta = { pruned: false }
|
||||
const meta = { pruned: false, disposed: false }
|
||||
|
||||
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
|
||||
setIndex(scope, "unseen", key, unseen)
|
||||
setIndex(scope, "unseenCount", key, unseen.length)
|
||||
setIndex(
|
||||
scope,
|
||||
"unseenHasError",
|
||||
key,
|
||||
unseen.some((notification) => notification.type === "error"),
|
||||
)
|
||||
}
|
||||
|
||||
const appendToIndex = (notification: Notification) => {
|
||||
if (notification.session) {
|
||||
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
|
||||
if (!notification.viewed) {
|
||||
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
|
||||
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
|
||||
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.directory) {
|
||||
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
|
||||
if (!notification.viewed) {
|
||||
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
|
||||
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
|
||||
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeFromIndex = (notification: Notification) => {
|
||||
if (notification.session) {
|
||||
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
|
||||
if (!notification.viewed) {
|
||||
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
|
||||
updateUnseen("session", notification.session, unseen)
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.directory) {
|
||||
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
|
||||
if (!notification.viewed) {
|
||||
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
|
||||
updateUnseen("project", notification.directory, unseen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (meta.pruned) return
|
||||
meta.pruned = true
|
||||
setStore("list", pruneNotifications(store.list))
|
||||
const list = pruneNotifications(store.list)
|
||||
batch(() => {
|
||||
setStore("list", list)
|
||||
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
|
||||
})
|
||||
})
|
||||
|
||||
const append = (notification: Notification) => {
|
||||
setStore("list", (list) => pruneNotifications([...list, notification]))
|
||||
const list = pruneNotifications([...store.list, notification])
|
||||
const keep = new Set(list)
|
||||
const removed = store.list.filter((n) => !keep.has(n))
|
||||
|
||||
batch(() => {
|
||||
if (keep.has(notification)) appendToIndex(notification)
|
||||
removed.forEach((n) => removeFromIndex(n))
|
||||
setStore("list", list)
|
||||
})
|
||||
}
|
||||
|
||||
const index = createMemo(() => buildNotificationIndex(store.list))
|
||||
const lookup = async (directory: string, sessionID?: string) => {
|
||||
if (!sessionID) return undefined
|
||||
const [syncStore] = globalSync.child(directory, { bootstrap: false })
|
||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||
if (match.found) return syncStore.session[match.index]
|
||||
return globalSDK.client.session
|
||||
.get({ directory, sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
|
||||
const activeDirectory = currentDirectory()
|
||||
const activeSession = currentSession()
|
||||
if (!activeDirectory) return false
|
||||
if (!activeSession) return false
|
||||
if (!sessionID) return false
|
||||
if (directory !== activeDirectory) return false
|
||||
return sessionID === activeSession
|
||||
}
|
||||
|
||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (!session) return
|
||||
if (session.parentID) return
|
||||
|
||||
if (settings.sounds.agentEnabled()) {
|
||||
playSound(soundSrc(settings.sounds.agent()))
|
||||
}
|
||||
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewedInCurrentSession(directory, sessionID),
|
||||
type: "turn-complete",
|
||||
session: sessionID,
|
||||
})
|
||||
|
||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSessionError = (
|
||||
directory: string,
|
||||
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
|
||||
time: number,
|
||||
) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (session?.parentID) return
|
||||
|
||||
if (settings.sounds.errorsEnabled()) {
|
||||
playSound(soundSrc(settings.sounds.errors()))
|
||||
}
|
||||
|
||||
const error = "error" in event.properties ? event.properties.error : undefined
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewedInCurrentSession(directory, sessionID),
|
||||
type: "error",
|
||||
session: sessionID ?? "global",
|
||||
error,
|
||||
})
|
||||
const description =
|
||||
session?.title ??
|
||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||
if (settings.notifications.errors()) {
|
||||
void platform.notify(language.t("notification.session.error.title"), description, href)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = globalSDK.event.listen((e) => {
|
||||
const event = e.details
|
||||
@@ -90,108 +291,81 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
|
||||
const directory = e.name
|
||||
const time = Date.now()
|
||||
const viewed = (sessionID?: string) => {
|
||||
const activeDirectory = currentDirectory()
|
||||
const activeSession = currentSession()
|
||||
if (!activeDirectory) return false
|
||||
if (!activeSession) return false
|
||||
if (!sessionID) return false
|
||||
if (directory !== activeDirectory) return false
|
||||
return sessionID === activeSession
|
||||
}
|
||||
switch (event.type) {
|
||||
case "session.idle": {
|
||||
const sessionID = event.properties.sessionID
|
||||
const [syncStore] = globalSync.child(directory, { bootstrap: false })
|
||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||
const session = match.found ? syncStore.session[match.index] : undefined
|
||||
if (session?.parentID) break
|
||||
|
||||
playSound(soundSrc(settings.sounds.agent()))
|
||||
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewed(sessionID),
|
||||
type: "turn-complete",
|
||||
session: sessionID,
|
||||
})
|
||||
|
||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify(
|
||||
language.t("notification.session.responseReady.title"),
|
||||
session?.title ?? sessionID,
|
||||
href,
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "session.error": {
|
||||
const sessionID = event.properties.sessionID
|
||||
const [syncStore] = globalSync.child(directory, { bootstrap: false })
|
||||
const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined
|
||||
const session = sessionID && match?.found ? syncStore.session[match.index] : undefined
|
||||
if (session?.parentID) break
|
||||
|
||||
playSound(soundSrc(settings.sounds.errors()))
|
||||
|
||||
const error = "error" in event.properties ? event.properties.error : undefined
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewed(sessionID),
|
||||
type: "error",
|
||||
session: sessionID ?? "global",
|
||||
error,
|
||||
})
|
||||
const description =
|
||||
session?.title ??
|
||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||
if (settings.notifications.errors()) {
|
||||
void platform.notify(language.t("notification.session.error.title"), description, href)
|
||||
}
|
||||
break
|
||||
}
|
||||
if (event.type === "session.idle") {
|
||||
handleSessionIdle(directory, event, time)
|
||||
return
|
||||
}
|
||||
handleSessionError(directory, event, time)
|
||||
})
|
||||
onCleanup(() => {
|
||||
meta.disposed = true
|
||||
unsub()
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
return {
|
||||
ready,
|
||||
session: {
|
||||
all(session: string) {
|
||||
return index().session.all.get(session) ?? empty
|
||||
return index.session.all[session] ?? empty
|
||||
},
|
||||
unseen(session: string) {
|
||||
return index().session.unseen.get(session) ?? empty
|
||||
return index.session.unseen[session] ?? empty
|
||||
},
|
||||
unseenCount(session: string) {
|
||||
return index().session.unseenCount.get(session) ?? 0
|
||||
return index.session.unseenCount[session] ?? 0
|
||||
},
|
||||
unseenHasError(session: string) {
|
||||
return index().session.unseenHasError.get(session) ?? false
|
||||
return index.session.unseenHasError[session] ?? false
|
||||
},
|
||||
markViewed(session: string) {
|
||||
setStore("list", (n) => n.session === session, "viewed", true)
|
||||
const unseen = index.session.unseen[session] ?? empty
|
||||
if (!unseen.length) return
|
||||
|
||||
const projects = [
|
||||
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
|
||||
]
|
||||
batch(() => {
|
||||
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
|
||||
updateUnseen("session", session, [])
|
||||
projects.forEach((directory) => {
|
||||
const next = (index.project.unseen[directory] ?? empty).filter(
|
||||
(notification) => notification.session !== session,
|
||||
)
|
||||
updateUnseen("project", directory, next)
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
project: {
|
||||
all(directory: string) {
|
||||
return index().project.all.get(directory) ?? empty
|
||||
return index.project.all[directory] ?? empty
|
||||
},
|
||||
unseen(directory: string) {
|
||||
return index().project.unseen.get(directory) ?? empty
|
||||
return index.project.unseen[directory] ?? empty
|
||||
},
|
||||
unseenCount(directory: string) {
|
||||
return index().project.unseenCount.get(directory) ?? 0
|
||||
return index.project.unseenCount[directory] ?? 0
|
||||
},
|
||||
unseenHasError(directory: string) {
|
||||
return index().project.unseenHasError.get(directory) ?? false
|
||||
return index.project.unseenHasError[directory] ?? false
|
||||
},
|
||||
markViewed(directory: string) {
|
||||
setStore("list", (n) => n.directory === directory, "viewed", true)
|
||||
const unseen = index.project.unseen[directory] ?? empty
|
||||
if (!unseen.length) return
|
||||
|
||||
const sessions = [
|
||||
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
|
||||
]
|
||||
batch(() => {
|
||||
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
|
||||
updateUnseen("project", directory, [])
|
||||
sessions.forEach((session) => {
|
||||
const next = (index.session.unseen[session] ?? empty).filter(
|
||||
(notification) => notification.directory !== directory,
|
||||
)
|
||||
updateUnseen("session", session, next)
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ function isNonAllowRule(rule: unknown) {
|
||||
return false
|
||||
}
|
||||
|
||||
function hasAutoAcceptPermissionConfig(permission: unknown) {
|
||||
function hasPermissionPromptRules(permission: unknown) {
|
||||
if (!permission) return false
|
||||
if (typeof permission === "string") return permission !== "allow"
|
||||
if (typeof permission !== "object") return false
|
||||
@@ -57,7 +57,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
const directory = decode64(params.dir)
|
||||
if (!directory) return false
|
||||
const [store] = globalSync.child(directory)
|
||||
return hasAutoAcceptPermissionConfig(store.config.permission)
|
||||
return hasPermissionPromptRules(store.config.permission)
|
||||
})
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
@@ -70,6 +70,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
const MAX_RESPONDED = 1000
|
||||
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
||||
const responded = new Map<string, number>()
|
||||
const enableVersion = new Map<string, number>()
|
||||
|
||||
function pruneResponded(now: number) {
|
||||
for (const [id, ts] of responded) {
|
||||
@@ -114,6 +115,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
|
||||
}
|
||||
|
||||
function bumpEnableVersion(sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const next = (enableVersion.get(key) ?? 0) + 1
|
||||
enableVersion.set(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const unsubscribe = globalSDK.event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event?.type !== "permission.asked") return
|
||||
@@ -128,6 +136,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
|
||||
function enable(sessionID: string, directory: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const version = bumpEnableVersion(sessionID, directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAcceptEdits[key] = true
|
||||
@@ -138,6 +147,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
globalSDK.client.permission
|
||||
.list({ directory })
|
||||
.then((x) => {
|
||||
if (enableVersion.get(key) !== version) return
|
||||
if (!isAutoAccepting(sessionID, directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
if (perm.sessionID !== sessionID) continue
|
||||
@@ -149,6 +160,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
}
|
||||
|
||||
function disable(sessionID: string, directory?: string) {
|
||||
bumpEnableVersion(sessionID, directory)
|
||||
const key = directory ? acceptKey(sessionID, directory) : undefined
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
|
||||
@@ -2,6 +2,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||
import type { Accessor } from "solid-js"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
type OpenFilePickerOptions = { title?: string; multiple?: boolean }
|
||||
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
||||
type UpdateInfo = { updateAvailable: boolean; version?: string }
|
||||
|
||||
export type Platform = {
|
||||
/** Platform discriminator */
|
||||
platform: "web" | "desktop"
|
||||
@@ -31,19 +37,19 @@ export type Platform = {
|
||||
notify(title: string, description?: string, href?: string): Promise<void>
|
||||
|
||||
/** Open directory picker dialog (native on Tauri, server-backed on web) */
|
||||
openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
|
||||
openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
|
||||
|
||||
/** Open native file picker dialog (Tauri only) */
|
||||
openFilePickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
|
||||
openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise<PickerPaths>
|
||||
|
||||
/** Save file picker dialog (Tauri only) */
|
||||
saveFilePickerDialog?(opts?: { title?: string; defaultPath?: string }): Promise<string | null>
|
||||
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
|
||||
|
||||
/** Storage mechanism, defaults to localStorage */
|
||||
storage?: (name?: string) => SyncStorage | AsyncStorage
|
||||
|
||||
/** Check for updates (Tauri only) */
|
||||
checkUpdate?(): Promise<{ updateAvailable: boolean; version?: string }>
|
||||
checkUpdate?(): Promise<UpdateInfo>
|
||||
|
||||
/** Install updates (Tauri only) */
|
||||
update?(): Promise<void>
|
||||
@@ -57,6 +63,12 @@ export type Platform = {
|
||||
/** Set the default server URL to use on app startup (platform-specific) */
|
||||
setDefaultServerUrl?(url: string | null): Promise<void> | void
|
||||
|
||||
/** Get the configured WSL integration (desktop only) */
|
||||
getWslEnabled?(): Promise<boolean>
|
||||
|
||||
/** Set the configured WSL integration (desktop only) */
|
||||
setWslEnabled?(config: boolean): Promise<void> | void
|
||||
|
||||
/** Get the preferred display backend (desktop only) */
|
||||
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user