Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05a0df5680 | ||
|
|
5fdc32c5b3 | ||
|
|
d6dbdb0b40 | ||
|
|
0beb8a0bf6 | ||
|
|
457c246f10 | ||
|
|
413f808dc0 | ||
|
|
0860300660 | ||
|
|
4658da0128 | ||
|
|
ac255c8553 | ||
|
|
e3b78fc03a | ||
|
|
8abcd13e9d | ||
|
|
97fd10be5d | ||
|
|
ecbda741d9 | ||
|
|
0296ab2cee | ||
|
|
b6b3867325 | ||
|
|
0983ee5b58 | ||
|
|
6535556203 | ||
|
|
5a6c23d484 | ||
|
|
26bb424f1d | ||
|
|
9f47fa7f97 | ||
|
|
cffdf3320b | ||
|
|
2610fec62b | ||
|
|
1d8f764114 | ||
|
|
ae7e07dcb4 | ||
|
|
0ed60110bd |
+26
-66
@@ -8,29 +8,7 @@ on:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
test:
|
||||
name: test (${{ matrix.settings.name }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- name: linux
|
||||
host: blacksmith-4vcpu-ubuntu-2404
|
||||
playwright: bunx playwright install --with-deps
|
||||
workdir: .
|
||||
command: |
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
git config --global user.name "opencode"
|
||||
bun turbo typecheck
|
||||
bun turbo test
|
||||
- name: windows
|
||||
host: windows-latest
|
||||
playwright: bunx playwright install
|
||||
workdir: packages/app
|
||||
command: bun test:e2e
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -42,63 +20,42 @@ jobs:
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: packages/app
|
||||
run: ${{ matrix.settings.playwright }}
|
||||
|
||||
- name: Set OS-specific paths
|
||||
run: |
|
||||
if [ "${{ runner.os }}" = "Windows" ]; then
|
||||
printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}\\opencode-e2e" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}\\opencode-e2e\\home" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}\\opencode-e2e\\share" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}\\opencode-e2e\\cache" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}\\opencode-e2e\\config" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}\\opencode-e2e\\state" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "MODELS_DEV_API_JSON=${{ github.workspace }}\\packages\\opencode\\test\\tool\\fixtures\\models-api.json" >> "$GITHUB_ENV"
|
||||
else
|
||||
printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}/opencode-e2e" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}/opencode-e2e/home" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}/opencode-e2e/share" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}/opencode-e2e/cache" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}/opencode-e2e/config" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}/opencode-e2e/state" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "MODELS_DEV_API_JSON=${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json" >> "$GITHUB_ENV"
|
||||
fi
|
||||
run: bunx playwright install --with-deps
|
||||
|
||||
- name: Seed opencode data
|
||||
working-directory: packages/opencode
|
||||
run: bun script/seed-e2e.ts
|
||||
env:
|
||||
MODELS_DEV_API_JSON: ${{ env.MODELS_DEV_API_JSON }}
|
||||
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "true"
|
||||
OPENCODE_DISABLE_SHARE: "true"
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
||||
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
|
||||
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
|
||||
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
|
||||
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
|
||||
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
|
||||
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
|
||||
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
|
||||
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
|
||||
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
|
||||
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
|
||||
OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }}
|
||||
OPENCODE_E2E_SESSION_TITLE: "E2E Session"
|
||||
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e"
|
||||
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano"
|
||||
|
||||
- name: Run opencode server
|
||||
working-directory: packages/opencode
|
||||
run: bun dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 &
|
||||
run: bun run dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 &
|
||||
env:
|
||||
MODELS_DEV_API_JSON: ${{ env.MODELS_DEV_API_JSON }}
|
||||
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "true"
|
||||
OPENCODE_DISABLE_SHARE: "true"
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
||||
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
|
||||
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
|
||||
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
|
||||
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
|
||||
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
|
||||
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
|
||||
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
|
||||
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
|
||||
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
|
||||
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
|
||||
OPENCODE_CLIENT: "app"
|
||||
|
||||
- name: Wait for opencode server
|
||||
@@ -110,21 +67,24 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: run
|
||||
working-directory: ${{ matrix.settings.workdir }}
|
||||
run: ${{ matrix.settings.command }}
|
||||
run: |
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
git config --global user.name "opencode"
|
||||
bun turbo typecheck
|
||||
bun turbo test
|
||||
env:
|
||||
CI: true
|
||||
MODELS_DEV_API_JSON: ${{ env.MODELS_DEV_API_JSON }}
|
||||
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "true"
|
||||
OPENCODE_DISABLE_SHARE: "true"
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
||||
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
|
||||
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
|
||||
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
|
||||
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
|
||||
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
|
||||
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
|
||||
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
|
||||
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
|
||||
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
|
||||
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
|
||||
PLAYWRIGHT_SERVER_HOST: "localhost"
|
||||
PLAYWRIGHT_SERVER_PORT: "4096"
|
||||
VITE_OPENCODE_SERVER_HOST: "localhost"
|
||||
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
echo "Changes detected:"
|
||||
echo "$STATUS"
|
||||
git add "${FILES[@]}"
|
||||
git commit -m "chore: update nix node_modules hashes"
|
||||
git commit -m "Update $TITLE"
|
||||
|
||||
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
|
||||
git pull --rebase --autostash origin "$BRANCH"
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
name: bun-file-io
|
||||
description: Use this when you are working on file operations like reading, writing, scanning, or deleting files. It summarizes the preferred file APIs and patterns used in this repo. It also notes when to use filesystem helpers for directories.
|
||||
---
|
||||
|
||||
## Use this when
|
||||
|
||||
- Editing file I/O or scans in `packages/opencode`
|
||||
- Handling directory operations or external tools
|
||||
|
||||
## Bun file APIs (from Bun docs)
|
||||
|
||||
- `Bun.file(path)` is lazy; call `text`, `json`, `stream`, `arrayBuffer`, `bytes`, `exists` to read.
|
||||
- Metadata: `file.size`, `file.type`, `file.name`.
|
||||
- `Bun.write(dest, input)` writes strings, buffers, Blobs, Responses, or files.
|
||||
- `Bun.file(...).delete()` deletes a file.
|
||||
- `file.writer()` returns a FileSink for incremental writes.
|
||||
- `Bun.Glob` + `Array.fromAsync(glob.scan({ cwd, absolute, onlyFiles, dot }))` for scans.
|
||||
- Use `Bun.which` to find a binary, then `Bun.spawn` to run it.
|
||||
- `Bun.readableStreamToText/Bytes/JSON` for stream output.
|
||||
|
||||
## When to use node:fs
|
||||
|
||||
- Use `node:fs/promises` for directories (`mkdir`, `readdir`, recursive operations).
|
||||
|
||||
## Repo patterns
|
||||
|
||||
- Prefer Bun APIs over Node `fs` for file access.
|
||||
- Check `Bun.file(...).exists()` before reading.
|
||||
- For binary/large files use `arrayBuffer()` and MIME checks via `file.type`.
|
||||
- Use `Bun.Glob` + `Array.fromAsync` for scans.
|
||||
- Decode tool stderr with `Bun.readableStreamToText`.
|
||||
- For large writes, use `Bun.write(Bun.file(path), text)`.
|
||||
|
||||
## Quick checklist
|
||||
|
||||
- Use Bun APIs first.
|
||||
- Use `path.join`/`path.resolve` for paths.
|
||||
- Prefer promise `.catch(...)` over `try/catch` when possible.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: test-skill
|
||||
description: use this when asked to test skill
|
||||
---
|
||||
|
||||
woah this is a test skill
|
||||
@@ -24,7 +24,6 @@ Server mode is opt-in only. When enabled, set `OPENCODE_SERVER_PASSWORD` to requ
|
||||
| **Sandbox escapes** | The permission system is not a sandbox (see above) |
|
||||
| **LLM provider data handling** | Data sent to your configured LLM provider is governed by their policies |
|
||||
| **MCP server behavior** | External MCP servers you configure are outside our trust boundary |
|
||||
| **Malicious config files** | Users control their own config; modifying it is not an attack vector |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -205,4 +205,3 @@
|
||||
| 2026-01-17 | 4,389,558 (+268,008) | 1,805,315 (+50,897) | 6,194,873 (+318,905) |
|
||||
| 2026-01-18 | 4,627,623 (+238,065) | 1,839,171 (+33,856) | 6,466,794 (+271,921) |
|
||||
| 2026-01-19 | 4,861,108 (+233,485) | 1,863,112 (+23,941) | 6,724,220 (+257,426) |
|
||||
| 2026-01-20 | 5,128,999 (+267,891) | 1,903,665 (+40,553) | 7,032,664 (+308,444) |
|
||||
|
||||
@@ -16,14 +16,13 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"husky": "9.1.7",
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "3.17.23",
|
||||
"turbo": "2.5.6",
|
||||
},
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -72,7 +71,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -106,7 +105,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -133,7 +132,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -157,7 +156,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -181,7 +180,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -210,7 +209,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -239,7 +238,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -255,7 +254,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -359,7 +358,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -375,12 +374,11 @@
|
||||
"name": "@opencode-ai/script",
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.4",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -391,7 +389,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -404,7 +402,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -445,7 +443,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -456,7 +454,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -516,7 +514,6 @@
|
||||
"@types/bun": "1.3.5",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "22.13.9",
|
||||
"@types/semver": "7.7.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"ai": "5.0.119",
|
||||
"diff": "8.0.2",
|
||||
@@ -1852,8 +1849,6 @@
|
||||
|
||||
"@types/scheduler": ["@types/scheduler@0.26.0", "", {}, "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA=="],
|
||||
|
||||
"@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
|
||||
|
||||
"@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
|
||||
|
||||
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-xVA4r7Qugw0TSx5wiTI5al93FI4D5LlvQo2ab3cUlmE=",
|
||||
"aarch64-linux": "sha256-EV0U/mXlrnEyCryL9rLlOZvMn6U0+BSgPhTIudVeqTo=",
|
||||
"aarch64-darwin": "sha256-zQvdRyNEHrpJsQMj8PZH0Ub21EREmDetVaJ0yBCgDlE=",
|
||||
"x86_64-darwin": "sha256-Tt5k5KBnrsNVIqPET7OFzClerjdR68XYstyCj3KpvdI="
|
||||
"x86_64-linux": "sha256-80+b7FwUy4mRWTzEjPrBWuR5Um67I1Rn4U/n/s/lBjs=",
|
||||
"aarch64-linux": "sha256-xH/Grwh3b+HWsUkKN8LMcyMaMcmnIJYlgp38WJCat5E=",
|
||||
"aarch64-darwin": "sha256-Izv6PE9gNaeYYfcqDwjTU/WYtD1y+j65annwvLzkMD8=",
|
||||
"x86_64-darwin": "sha256-EG1Z0uAeyFiOeVsv0Sz1sa8/mdXuw/uvbYYrkFR3EAg="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "22.13.9",
|
||||
"@types/semver": "7.7.1",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
@@ -67,7 +66,6 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"husky": "9.1.7",
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "3.17.23",
|
||||
"turbo": "2.5.6"
|
||||
},
|
||||
|
||||
+2
-11
@@ -1,5 +1,7 @@
|
||||
## Debugging
|
||||
|
||||
- To test the opencode app, use the playwright MCP server, the app is already
|
||||
running at http://localhost:3000
|
||||
- NEVER try to restart the app, or the server process, EVER.
|
||||
|
||||
## SolidJS
|
||||
@@ -9,14 +11,3 @@
|
||||
## Tool Calling
|
||||
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||
|
||||
## Browser Automation
|
||||
|
||||
Use `agent-browser` for web automation. Run `agent-browser --help` for all commands.
|
||||
|
||||
Core workflow:
|
||||
|
||||
1. `agent-browser open <url>` - Navigate to page
|
||||
2. `agent-browser snapshot -i` - Get interactive elements with refs (@e1, @e2)
|
||||
3. `agent-browser click @e1` / `fill @e2 "text"` - Interact using refs
|
||||
4. Re-snapshot after page changes
|
||||
|
||||
@@ -31,20 +31,18 @@ Your app is ready to be deployed!
|
||||
|
||||
## E2E Testing
|
||||
|
||||
Playwright starts the Vite dev server automatically via `webServer`, and UI tests need an opencode backend (defaults to `localhost:4096`).
|
||||
Use the local runner to create a temp sandbox, seed data, and run the tests.
|
||||
The Playwright runner expects the app already running at `http://localhost:3000`.
|
||||
|
||||
```bash
|
||||
bun add -D @playwright/test
|
||||
bunx playwright install
|
||||
bun run test:e2e:local
|
||||
bun run test:e2e:local -- --grep "settings"
|
||||
bun run test:e2e
|
||||
```
|
||||
|
||||
Environment options:
|
||||
|
||||
- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (backend address, default: `localhost:4096`)
|
||||
- `PLAYWRIGHT_PORT` (Vite dev server port, default: `3000`)
|
||||
- `PLAYWRIGHT_BASE_URL` (override base URL, default: `http://localhost:<PLAYWRIGHT_PORT>`)
|
||||
- `PLAYWRIGHT_BASE_URL` (default: `http://localhost:3000`)
|
||||
- `PLAYWRIGHT_PORT` (default: `3000`)
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { test, expect } from "./fixtures"
|
||||
import { promptSelector } from "./utils"
|
||||
|
||||
function sessionIDFromUrl(url: string) {
|
||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||
return match?.[1]
|
||||
}
|
||||
|
||||
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const pageErrors: string[] = []
|
||||
const onPageError = (err: Error) => {
|
||||
pageErrors.push(err.message)
|
||||
}
|
||||
page.on("pageerror", onPageError)
|
||||
|
||||
await gotoSession()
|
||||
|
||||
const token = `E2E_OK_${Date.now()}`
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
|
||||
const sessionID = (() => {
|
||||
const id = sessionIDFromUrl(page.url())
|
||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
return id
|
||||
})()
|
||||
|
||||
try {
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if (pageErrors.length > 0) {
|
||||
throw new Error(`Page error(s):\n${pageErrors.join("\n")}`)
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -15,7 +15,6 @@
|
||||
"serve": "vite preview",
|
||||
"test": "playwright test",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:local": "bun script/e2e-local.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report e2e/playwright-report"
|
||||
},
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import fs from "node:fs/promises"
|
||||
import net from "node:net"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
async function freePort() {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.once("error", reject)
|
||||
server.listen(0, () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to acquire a free port")))
|
||||
return
|
||||
}
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve(address.port)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForHealth(url: string) {
|
||||
const timeout = Date.now() + 60_000
|
||||
while (Date.now() < timeout) {
|
||||
const ok = await fetch(url)
|
||||
.then((r) => r.ok)
|
||||
.catch(() => false)
|
||||
if (ok) return
|
||||
await new Promise((r) => setTimeout(r, 250))
|
||||
}
|
||||
throw new Error(`Timed out waiting for server health: ${url}`)
|
||||
}
|
||||
|
||||
const appDir = process.cwd()
|
||||
const repoDir = path.resolve(appDir, "../..")
|
||||
const opencodeDir = path.join(repoDir, "packages", "opencode")
|
||||
const modelsJson = path.join(opencodeDir, "test", "tool", "fixtures", "models-api.json")
|
||||
|
||||
const extraArgs = (() => {
|
||||
const args = process.argv.slice(2)
|
||||
if (args[0] === "--") return args.slice(1)
|
||||
return args
|
||||
})()
|
||||
|
||||
const [serverPort, webPort] = await Promise.all([freePort(), freePort()])
|
||||
|
||||
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
|
||||
|
||||
const serverEnv = {
|
||||
...process.env,
|
||||
MODELS_DEV_API_JSON: modelsJson,
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "true",
|
||||
OPENCODE_DISABLE_SHARE: "true",
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
||||
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
|
||||
XDG_DATA_HOME: path.join(sandbox, "share"),
|
||||
XDG_CACHE_HOME: path.join(sandbox, "cache"),
|
||||
XDG_CONFIG_HOME: path.join(sandbox, "config"),
|
||||
XDG_STATE_HOME: path.join(sandbox, "state"),
|
||||
OPENCODE_E2E_PROJECT_DIR: repoDir,
|
||||
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
|
||||
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
|
||||
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
|
||||
OPENCODE_CLIENT: "app",
|
||||
} satisfies Record<string, string>
|
||||
|
||||
const runnerEnv = {
|
||||
...process.env,
|
||||
PLAYWRIGHT_SERVER_HOST: "localhost",
|
||||
PLAYWRIGHT_SERVER_PORT: String(serverPort),
|
||||
VITE_OPENCODE_SERVER_HOST: "localhost",
|
||||
VITE_OPENCODE_SERVER_PORT: String(serverPort),
|
||||
PLAYWRIGHT_PORT: String(webPort),
|
||||
} satisfies Record<string, string>
|
||||
|
||||
const seed = Bun.spawn(["bun", "script/seed-e2e.ts"], {
|
||||
cwd: opencodeDir,
|
||||
env: serverEnv,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
|
||||
const seedExit = await seed.exited
|
||||
if (seedExit !== 0) {
|
||||
process.exit(seedExit)
|
||||
}
|
||||
|
||||
const server = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
"dev",
|
||||
"--",
|
||||
"--print-logs",
|
||||
"--log-level",
|
||||
"WARN",
|
||||
"serve",
|
||||
"--port",
|
||||
String(serverPort),
|
||||
"--hostname",
|
||||
"127.0.0.1",
|
||||
],
|
||||
{
|
||||
cwd: opencodeDir,
|
||||
env: serverEnv,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
await waitForHealth(`http://localhost:${serverPort}/global/health`)
|
||||
|
||||
const runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], {
|
||||
cwd: appDir,
|
||||
env: runnerEnv,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
|
||||
process.exitCode = await runner.exited
|
||||
} finally {
|
||||
server.kill()
|
||||
}
|
||||
+19
-24
@@ -14,7 +14,6 @@ import { PermissionProvider } from "@/context/permission"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||
import { ServerProvider, useServer } from "@/context/server"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { FileProvider } from "@/context/file"
|
||||
@@ -83,17 +82,15 @@ export function AppInterface(props: { defaultUrl?: string }) {
|
||||
<GlobalSyncProvider>
|
||||
<Router
|
||||
root={(props) => (
|
||||
<SettingsProvider>
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<CommandProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</CommandProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
</SettingsProvider>
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<CommandProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</CommandProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
)}
|
||||
>
|
||||
<Route
|
||||
@@ -108,18 +105,16 @@ export function AppInterface(props: { defaultUrl?: string }) {
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route
|
||||
path="/session/:id?"
|
||||
component={(p) => (
|
||||
<Show when={p.params.id ?? "new"} keyed>
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Session />
|
||||
</Suspense>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
</Show>
|
||||
component={() => (
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Session />
|
||||
</Suspense>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
)}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
@@ -3,15 +3,20 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
import { ProjectAvatar, isValidImageFile } from "@/components/project-avatar"
|
||||
|
||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||
|
||||
function getFilename(input: string) {
|
||||
const parts = input.split("/")
|
||||
return parts[parts.length - 1] || input
|
||||
}
|
||||
|
||||
export function DialogEditProject(props: { project: LocalProject }) {
|
||||
const dialog = useDialog()
|
||||
const globalSDK = useGlobalSDK()
|
||||
@@ -30,7 +35,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
const [iconHover, setIconHover] = createSignal(false)
|
||||
|
||||
function handleFileSelect(file: File) {
|
||||
if (!file.type.startsWith("image/")) return
|
||||
if (!isValidImageFile(file)) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
setStore("iconUrl", e.target?.result as string)
|
||||
@@ -82,7 +87,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
|
||||
return (
|
||||
<Dialog title="Edit project" class="w-full max-w-[480px] mx-auto">
|
||||
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
|
||||
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
@@ -98,7 +103,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
<div class="flex gap-3 items-start">
|
||||
<div class="relative" onMouseEnter={() => setIconHover(true)} onMouseLeave={() => setIconHover(false)}>
|
||||
<div
|
||||
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
||||
class="size-16 rounded-md overflow-hidden border border-dashed transition-colors cursor-pointer"
|
||||
classList={{
|
||||
"border-text-interactive-base bg-surface-info-base/20": dragOver(),
|
||||
"border-border-base hover:border-border-strong": !dragOver(),
|
||||
@@ -115,21 +120,13 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={store.iconUrl}
|
||||
fallback={
|
||||
<div class="size-full flex items-center justify-center">
|
||||
<Avatar
|
||||
fallback={store.name || defaultName()}
|
||||
{...getAvatarColors(store.color)}
|
||||
class="size-full"
|
||||
style={{ "font-size": "32px" }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img src={store.iconUrl} alt="Project icon" class="size-full object-cover" />
|
||||
</Show>
|
||||
<ProjectAvatar
|
||||
name={store.name || defaultName()}
|
||||
projectId={props.project.id}
|
||||
iconUrl={store.iconUrl}
|
||||
iconColor={store.color}
|
||||
class="size-full"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -34,14 +34,7 @@ export function DialogSelectFile() {
|
||||
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 common = ["session.new", "session.previous", "session.next", "terminal.toggle", "review.toggle"]
|
||||
const limit = 5
|
||||
|
||||
const allowed = createMemo(() =>
|
||||
|
||||
@@ -73,7 +73,7 @@ export const ModelSelectorPopover: Component<{
|
||||
const [open, setOpen] = createSignal(false)
|
||||
|
||||
return (
|
||||
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
|
||||
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={12}>
|
||||
<Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden">
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { SettingsGeneral } from "./settings-general"
|
||||
import { SettingsKeybinds } from "./settings-keybinds"
|
||||
import { SettingsPermissions } from "./settings-permissions"
|
||||
import { SettingsProviders } from "./settings-providers"
|
||||
import { SettingsModels } from "./settings-models"
|
||||
import { SettingsAgents } from "./settings-agents"
|
||||
import { SettingsCommands } from "./settings-commands"
|
||||
import { SettingsMcp } from "./settings-mcp"
|
||||
|
||||
export const DialogSettings: Component = () => {
|
||||
return (
|
||||
<Dialog size="x-large">
|
||||
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
|
||||
<Tabs.List>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"flex-direction": "column",
|
||||
gap: "12px",
|
||||
width: "100%",
|
||||
"padding-top": "12px",
|
||||
"padding-bottom": "12px",
|
||||
}}
|
||||
>
|
||||
<Tabs.SectionTitle>Desktop</Tabs.SectionTitle>
|
||||
<div style={{ display: "flex", "flex-direction": "column", gap: "6px", width: "100%" }}>
|
||||
<Tabs.Trigger value="general">
|
||||
<Icon name="sliders" />
|
||||
General
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="shortcuts">
|
||||
<Icon name="keyboard" />
|
||||
Shortcuts
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
{/* <Tabs.SectionTitle>Server</Tabs.SectionTitle> */}
|
||||
{/* <Tabs.Trigger value="permissions"> */}
|
||||
{/* <Icon name="checklist" /> */}
|
||||
{/* Permissions */}
|
||||
{/* </Tabs.Trigger> */}
|
||||
{/* <Tabs.Trigger value="providers"> */}
|
||||
{/* <Icon name="server" /> */}
|
||||
{/* Providers */}
|
||||
{/* </Tabs.Trigger> */}
|
||||
{/* <Tabs.Trigger value="models"> */}
|
||||
{/* <Icon name="brain" /> */}
|
||||
{/* Models */}
|
||||
{/* </Tabs.Trigger> */}
|
||||
{/* <Tabs.Trigger value="agents"> */}
|
||||
{/* <Icon name="task" /> */}
|
||||
{/* Agents */}
|
||||
{/* </Tabs.Trigger> */}
|
||||
{/* <Tabs.Trigger value="commands"> */}
|
||||
{/* <Icon name="console" /> */}
|
||||
{/* Commands */}
|
||||
{/* </Tabs.Trigger> */}
|
||||
{/* <Tabs.Trigger value="mcp"> */}
|
||||
{/* <Icon name="mcp" /> */}
|
||||
{/* MCP */}
|
||||
{/* </Tabs.Trigger> */}
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="general" class="no-scrollbar">
|
||||
<SettingsGeneral />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="shortcuts" class="no-scrollbar">
|
||||
<SettingsKeybinds />
|
||||
</Tabs.Content>
|
||||
{/* <Tabs.Content value="permissions" class="no-scrollbar"> */}
|
||||
{/* <SettingsPermissions /> */}
|
||||
{/* </Tabs.Content> */}
|
||||
{/* <Tabs.Content value="providers" class="no-scrollbar"> */}
|
||||
{/* <SettingsProviders /> */}
|
||||
{/* </Tabs.Content> */}
|
||||
{/* <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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createMemo, splitProps, type ComponentProps, type JSX } from "solid-js"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
import { getAvatarColors } from "@/context/layout"
|
||||
|
||||
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||
const OPENCODE_FAVICON_URL = "https://opencode.ai/favicon.svg"
|
||||
|
||||
export interface ProjectAvatarProps extends Omit<ComponentProps<"div">, "children"> {
|
||||
name: string
|
||||
iconUrl?: string
|
||||
iconColor?: string
|
||||
projectId?: string
|
||||
size?: "small" | "normal" | "large"
|
||||
}
|
||||
|
||||
export const isValidImageUrl = (url: string | undefined): boolean => {
|
||||
if (!url) {
|
||||
return false
|
||||
}
|
||||
if (url.startsWith("data:image/x-icon")) {
|
||||
return false
|
||||
}
|
||||
if (url.startsWith("data:image/vnd.microsoft.icon")) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const isValidImageFile = (file: File): boolean => {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return false
|
||||
}
|
||||
if (file.type === "image/x-icon" || file.type === "image/vnd.microsoft.icon") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const ProjectAvatar = (props: ProjectAvatarProps) => {
|
||||
const [local, rest] = splitProps(props, [
|
||||
"name",
|
||||
"iconUrl",
|
||||
"iconColor",
|
||||
"projectId",
|
||||
"size",
|
||||
"class",
|
||||
"classList",
|
||||
"style",
|
||||
])
|
||||
const colors = createMemo(() => getAvatarColors(local.iconColor))
|
||||
const validSrc = createMemo(() => {
|
||||
if (isValidImageUrl(local.iconUrl)) {
|
||||
return local.iconUrl
|
||||
}
|
||||
if (local.projectId === OPENCODE_PROJECT_ID) {
|
||||
return OPENCODE_FAVICON_URL
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
fallback={local.name}
|
||||
src={validSrc()}
|
||||
size={local.size}
|
||||
{...colors()}
|
||||
class={local.class}
|
||||
classList={local.classList}
|
||||
style={local.style as JSX.CSSProperties}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -255,6 +255,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
createEffect(() => {
|
||||
params.id
|
||||
editorRef.focus()
|
||||
if (params.id) return
|
||||
const interval = setInterval(() => {
|
||||
setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length)
|
||||
@@ -793,7 +794,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
.abort({
|
||||
sessionID: params.id!,
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => { })
|
||||
|
||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||
const text = prompt
|
||||
@@ -1254,7 +1255,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const optimisticParts = requestParts.map((part) => ({
|
||||
...part,
|
||||
sessionID: session.id,
|
||||
sessionID: session?.id ?? "",
|
||||
messageID,
|
||||
})) as unknown as Part[]
|
||||
|
||||
@@ -1272,9 +1273,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const addOptimisticMessage = () => {
|
||||
setSyncStore(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[session.id]
|
||||
const messages = draft.message[session?.id ?? ""]
|
||||
if (!messages) {
|
||||
draft.message[session.id] = [optimisticMessage]
|
||||
draft.message[session?.id ?? ""] = [optimisticMessage]
|
||||
} else {
|
||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||
messages.splice(result.index, 0, optimisticMessage)
|
||||
@@ -1290,7 +1291,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const removeOptimisticMessage = () => {
|
||||
setSyncStore(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[session.id]
|
||||
const messages = draft.message[session?.id ?? ""]
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||
if (result.found) messages.splice(result.index, 1)
|
||||
@@ -1566,7 +1567,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
<div class="relative p-3 flex items-center justify-between">
|
||||
<div class="flex items-center justify-start gap-0.5">
|
||||
<div class="flex items-center justify-start gap-1">
|
||||
<Switch>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<div class="flex items-center gap-2 px-2 h-6">
|
||||
@@ -1617,13 +1618,60 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
title="Thinking effort"
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
|
||||
onClick={() => local.model.variant.cycle()}
|
||||
>
|
||||
{local.model.variant.current() ?? "Default"}
|
||||
</Button>
|
||||
{(() => {
|
||||
const [text, setText] = createSignal(local.model.variant.current() ?? "Default")
|
||||
const [animating, setAnimating] = createSignal(false)
|
||||
let locked = false
|
||||
|
||||
const handleClick = async () => {
|
||||
if (locked) return
|
||||
|
||||
local.model.variant.cycle()
|
||||
const newText = local.model.variant.current() ?? "Default"
|
||||
|
||||
if (newText === text()) return
|
||||
|
||||
locked = true
|
||||
setAnimating(true)
|
||||
|
||||
// Wait for exit animation
|
||||
const charCount = text().length
|
||||
await new Promise((r) => setTimeout(r, charCount * 40 + 400))
|
||||
|
||||
// Reset animating before setting new text so @starting-style works
|
||||
setAnimating(false)
|
||||
setText(newText)
|
||||
|
||||
// Wait for enter animation
|
||||
const newCharCount = newText.length
|
||||
await new Promise((r) => setTimeout(r, newCharCount * 40 + 400))
|
||||
|
||||
locked = false
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-text-base _hidden text-12-regular"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span data-slot="cycle-text" data-animating={animating()}>
|
||||
<For each={text().split("")}>
|
||||
{(char, i) =>
|
||||
char === " " ? (
|
||||
<span data-slot="space" />
|
||||
) : (
|
||||
<span data-slot="char" style={{ "--i": i() }}>
|
||||
{i() === 0 ? char.toUpperCase() : char}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</For>
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Button>
|
||||
)
|
||||
})()}
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
<Show when={permission.permissionsEnabled() && params.id}>
|
||||
@@ -1699,7 +1747,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
disabled={!prompt.dirty() && !working()}
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="h-6 w-4.5"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -45,8 +45,6 @@ export function SessionHeader() {
|
||||
|
||||
const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
|
||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||
const showReview = createMemo(() => !!currentSession()?.summary?.files)
|
||||
const showShare = createMemo(() => shareEnabled() && !!currentSession())
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const view = createMemo(() => layout.view(sessionKey()))
|
||||
|
||||
@@ -174,14 +172,12 @@ export function SessionHeader() {
|
||||
{/* <SessionMcpIndicator /> */}
|
||||
{/* </div> */}
|
||||
<div class="flex items-center gap-1">
|
||||
<div
|
||||
class="hidden md:block shrink-0"
|
||||
classList={{
|
||||
"opacity-0 pointer-events-none": !showReview(),
|
||||
}}
|
||||
aria-hidden={!showReview()}
|
||||
>
|
||||
<TooltipKeybind title="Toggle review" keybind={command.keybind("review.toggle")}>
|
||||
<Show when={currentSession()?.summary?.files}>
|
||||
<TooltipKeybind
|
||||
class="hidden md:block shrink-0"
|
||||
title="Toggle review"
|
||||
keybind={command.keybind("review.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/review-toggle size-6 p-0"
|
||||
@@ -206,7 +202,7 @@ export function SessionHeader() {
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Show>
|
||||
<TooltipKeybind
|
||||
class="hidden md:block shrink-0"
|
||||
title="Toggle terminal"
|
||||
@@ -237,87 +233,84 @@ export function SessionHeader() {
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center"
|
||||
classList={{
|
||||
"opacity-0 pointer-events-none": !showShare(),
|
||||
}}
|
||||
aria-hidden={!showShare()}
|
||||
>
|
||||
<Popover
|
||||
title="Publish on web"
|
||||
description={
|
||||
shareUrl()
|
||||
? "This session is public on the web. It is accessible to anyone with the link."
|
||||
: "Share session publicly on the web. It will be accessible to anyone with the link."
|
||||
}
|
||||
trigger={
|
||||
<Tooltip class="shrink-0" value="Share session">
|
||||
<Button
|
||||
variant="secondary"
|
||||
classList={{ "rounded-r-none": shareUrl() !== undefined }}
|
||||
style={{ scale: 1 }}
|
||||
<Show when={shareEnabled() && currentSession()}>
|
||||
<div class="flex items-center">
|
||||
<Popover
|
||||
gutter={16}
|
||||
title="Publish on web"
|
||||
description={
|
||||
shareUrl()
|
||||
? "This session is public on the web. It is accessible to anyone with the link."
|
||||
: "Share session publicly on the web. It will be accessible to anyone with the link."
|
||||
}
|
||||
trigger={
|
||||
<Tooltip class="shrink-0" value="Share session">
|
||||
<Button
|
||||
variant="secondary"
|
||||
classList={{ "rounded-r-none": shareUrl() !== undefined }}
|
||||
style={{ scale: 1 }}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Show
|
||||
when={shareUrl()}
|
||||
fallback={
|
||||
<div class="flex">
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-1/2"
|
||||
onClick={shareSession}
|
||||
disabled={state.share}
|
||||
>
|
||||
{state.share ? "Publishing..." : "Publish"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
<div class="flex flex-col gap-2 w-72">
|
||||
<TextField value={shareUrl() ?? ""} readOnly copyable class="w-full" />
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={unshareSession}
|
||||
disabled={state.unshare}
|
||||
>
|
||||
{state.unshare ? "Unpublishing..." : "Unpublish"}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={viewShare}
|
||||
disabled={state.unshare}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Popover>
|
||||
<Show when={shareUrl()} fallback={<div class="size-6" aria-hidden="true" />}>
|
||||
<Tooltip value={state.copied ? "Copied" : "Copy link"} placement="top-end" gutter={12}>
|
||||
<IconButton
|
||||
icon={state.copied ? "check" : "copy"}
|
||||
variant="secondary"
|
||||
class="rounded-l-none"
|
||||
onClick={copyLink}
|
||||
disabled={state.unshare}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Show
|
||||
when={shareUrl()}
|
||||
fallback={
|
||||
<div class="flex">
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-1/2"
|
||||
onClick={shareSession}
|
||||
disabled={state.share}
|
||||
>
|
||||
{state.share ? "Publishing..." : "Publish"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2 w-72">
|
||||
<TextField value={shareUrl() ?? ""} readOnly copyable class="w-full" />
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={unshareSession}
|
||||
disabled={state.unshare}
|
||||
>
|
||||
{state.unshare ? "Unpublishing..." : "Unpublish"}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={viewShare}
|
||||
disabled={state.unshare}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Popover>
|
||||
<Show when={shareUrl()} fallback={<div class="size-6" aria-hidden="true" />}>
|
||||
<Tooltip value={state.copied ? "Copied" : "Copy link"} placement="top" gutter={8}>
|
||||
<IconButton
|
||||
icon={state.copied ? "check" : "copy"}
|
||||
variant="secondary"
|
||||
class="rounded-l-none"
|
||||
onClick={copyLink}
|
||||
disabled={state.unshare}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Component } from "solid-js"
|
||||
|
||||
export const SettingsAgents: Component = () => {
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">Agents</h2>
|
||||
<p class="text-14-regular text-text-weak">Agent settings will be configurable here.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Component } from "solid-js"
|
||||
|
||||
export const SettingsCommands: Component = () => {
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">Commands</h2>
|
||||
<p class="text-14-regular text-text-weak">Command settings will be configurable here.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import { Component, createMemo, type JSX } from "solid-js"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { useSettings, monoFontFamily } from "@/context/settings"
|
||||
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
|
||||
|
||||
export const SettingsGeneral: Component = () => {
|
||||
const theme = useTheme()
|
||||
const settings = useSettings()
|
||||
|
||||
const themeOptions = createMemo(() =>
|
||||
Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
|
||||
)
|
||||
|
||||
const colorSchemeOptions: { value: ColorScheme; label: string }[] = [
|
||||
{ value: "system", label: "System setting" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
]
|
||||
|
||||
const fontOptions = [
|
||||
{ value: "ibm-plex-mono", label: "IBM Plex Mono" },
|
||||
{ value: "cascadia-code", label: "Cascadia Code" },
|
||||
{ value: "fira-code", label: "Fira Code" },
|
||||
{ value: "hack", label: "Hack" },
|
||||
{ value: "inconsolata", label: "Inconsolata" },
|
||||
{ value: "intel-one-mono", label: "Intel One Mono" },
|
||||
{ value: "jetbrains-mono", label: "JetBrains Mono" },
|
||||
{ value: "meslo-lgs", label: "Meslo LGS" },
|
||||
{ value: "roboto-mono", label: "Roboto Mono" },
|
||||
{ value: "source-code-pro", label: "Source Code Pro" },
|
||||
{ value: "ubuntu-mono", label: "Ubuntu Mono" },
|
||||
]
|
||||
|
||||
const soundOptions = [...SOUND_OPTIONS]
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar" style={{ padding: "0 40px 40px 40px" }}>
|
||||
<div
|
||||
class="sticky top-0 z-10"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha) calc(100% - 24px), transparent)",
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col gap-1 pt-6 pb-8">
|
||||
<h2 class="text-16-medium text-text-strong">General</h2>
|
||||
</div>
|
||||
</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">Appearance</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow title="Appearance" description="Customise how OpenCode looks on your device">
|
||||
<Select
|
||||
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="Theme"
|
||||
description={
|
||||
<>
|
||||
Customise how OpenCode is themed.{" "}
|
||||
<a href="#" class="text-text-interactive-base">
|
||||
Learn more
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
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="Font" description="Customise the mono font used in code blocks">
|
||||
<Select
|
||||
options={fontOptions}
|
||||
current={fontOptions.find((o) => o.value === settings.appearance.font())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => 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?.label}</span>}
|
||||
</Select>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System notifications Section */}
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">System notifications</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow
|
||||
title="Agent"
|
||||
description="Show system notification when the agent is complete or needs attention"
|
||||
>
|
||||
<Switch
|
||||
checked={settings.notifications.agent()}
|
||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow title="Permissions" description="Show system notification when a permission is required">
|
||||
<Switch
|
||||
checked={settings.notifications.permissions()}
|
||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow title="Errors" description="Show system notification when an error occurs">
|
||||
<Switch
|
||||
checked={settings.notifications.errors()}
|
||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sound effects Section */}
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">Sound effects</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow title="Agent" description="Play sound when the agent is complete or needs attention">
|
||||
<Select
|
||||
options={soundOptions}
|
||||
current={soundOptions.find((o) => o.id === settings.sounds.agent())}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.label}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
playSound(option.src)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
settings.sounds.setAgent(option.id)
|
||||
playSound(option.src)
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow title="Permissions" description="Play sound when a permission is required">
|
||||
<Select
|
||||
options={soundOptions}
|
||||
current={soundOptions.find((o) => o.id === settings.sounds.permissions())}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.label}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
playSound(option.src)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
settings.sounds.setPermissions(option.id)
|
||||
playSound(option.src)
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow title="Errors" description="Play sound when an error occurs">
|
||||
<Select
|
||||
options={soundOptions}
|
||||
current={soundOptions.find((o) => o.id === settings.sounds.errors())}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.label}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
playSound(option.src)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
settings.sounds.setErrors(option.id)
|
||||
playSound(option.src)
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingsRowProps {
|
||||
title: string
|
||||
description: string | JSX.Element
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
||||
return (
|
||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-14-medium text-text-strong">{props.title}</span>
|
||||
<span class="text-12-regular text-text-weak">{props.description}</span>
|
||||
</div>
|
||||
<div class="flex-shrink-0">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
||||
import { useSettings } from "@/context/settings"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
const PALETTE_ID = "command.palette"
|
||||
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
||||
|
||||
type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
|
||||
|
||||
type KeybindMeta = {
|
||||
title: string
|
||||
group: KeybindGroup
|
||||
}
|
||||
|
||||
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
|
||||
|
||||
function groupFor(id: string): KeybindGroup {
|
||||
if (id === PALETTE_ID) return "General"
|
||||
if (id.startsWith("terminal.")) return "Terminal"
|
||||
if (id.startsWith("model.") || id.startsWith("agent.") || id.startsWith("mcp.")) return "Model and agent"
|
||||
if (id.startsWith("file.")) return "Navigation"
|
||||
if (id.startsWith("prompt.")) return "Prompt"
|
||||
if (
|
||||
id.startsWith("session.") ||
|
||||
id.startsWith("message.") ||
|
||||
id.startsWith("permissions.") ||
|
||||
id.startsWith("steps.") ||
|
||||
id.startsWith("review.")
|
||||
)
|
||||
return "Session"
|
||||
|
||||
return "General"
|
||||
}
|
||||
|
||||
function isModifier(key: string) {
|
||||
return key === "Shift" || key === "Control" || key === "Alt" || key === "Meta"
|
||||
}
|
||||
|
||||
function normalizeKey(key: string) {
|
||||
if (key === ",") return "comma"
|
||||
if (key === "+") return "plus"
|
||||
if (key === " ") return "space"
|
||||
return key.toLowerCase()
|
||||
}
|
||||
|
||||
function recordKeybind(event: KeyboardEvent) {
|
||||
if (isModifier(event.key)) return
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
const mod = IS_MAC ? event.metaKey : event.ctrlKey
|
||||
if (mod) parts.push("mod")
|
||||
|
||||
if (IS_MAC && event.ctrlKey) parts.push("ctrl")
|
||||
if (!IS_MAC && event.metaKey) parts.push("meta")
|
||||
if (event.altKey) parts.push("alt")
|
||||
if (event.shiftKey) parts.push("shift")
|
||||
|
||||
const key = normalizeKey(event.key)
|
||||
if (!key) return
|
||||
parts.push(key)
|
||||
|
||||
return parts.join("+")
|
||||
}
|
||||
|
||||
function signatures(config: string | undefined) {
|
||||
if (!config) return []
|
||||
const sigs: string[] = []
|
||||
|
||||
for (const kb of parseKeybind(config)) {
|
||||
const parts: string[] = []
|
||||
if (kb.ctrl) parts.push("ctrl")
|
||||
if (kb.alt) parts.push("alt")
|
||||
if (kb.shift) parts.push("shift")
|
||||
if (kb.meta) parts.push("meta")
|
||||
if (kb.key) parts.push(kb.key)
|
||||
if (parts.length === 0) continue
|
||||
sigs.push(parts.join("+"))
|
||||
}
|
||||
|
||||
return sigs
|
||||
}
|
||||
|
||||
export const SettingsKeybinds: Component = () => {
|
||||
const command = useCommand()
|
||||
const settings = useSettings()
|
||||
|
||||
const [active, setActive] = createSignal<string | null>(null)
|
||||
|
||||
const stop = () => {
|
||||
if (!active()) return
|
||||
setActive(null)
|
||||
command.keybinds(true)
|
||||
}
|
||||
|
||||
const start = (id: string) => {
|
||||
if (active() === id) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
if (active()) stop()
|
||||
|
||||
setActive(id)
|
||||
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 resetAll = () => {
|
||||
stop()
|
||||
settings.keybinds.resetAll()
|
||||
showToast({ title: "Shortcuts reset", description: "Keyboard shortcuts have been reset to defaults." })
|
||||
}
|
||||
|
||||
const list = createMemo(() => {
|
||||
const out = new Map<string, KeybindMeta>()
|
||||
out.set(PALETTE_ID, { title: "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
|
||||
})
|
||||
|
||||
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 used = createMemo(() => {
|
||||
const map = new Map<string, { id: string; title: string }[]>()
|
||||
|
||||
const add = (key: string, value: { id: string; title: string }) => {
|
||||
const list = map.get(key)
|
||||
if (!list) {
|
||||
map.set(key, [value])
|
||||
return
|
||||
}
|
||||
list.push(value)
|
||||
}
|
||||
|
||||
const palette = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
|
||||
for (const sig of signatures(palette)) {
|
||||
add(sig, { id: PALETTE_ID, title: "Command palette" })
|
||||
}
|
||||
|
||||
const valueFor = (id: string) => {
|
||||
const custom = settings.keybinds.get(id)
|
||||
if (typeof custom === "string") return custom
|
||||
|
||||
const live = command.options.find((x) => x.id === id)
|
||||
if (live?.keybind) return live.keybind
|
||||
|
||||
const meta = command.catalog.find((x) => x.id === id)
|
||||
return meta?.keybind
|
||||
}
|
||||
|
||||
for (const id of list().keys()) {
|
||||
if (id === PALETTE_ID) continue
|
||||
for (const sig of signatures(valueFor(id))) {
|
||||
add(sig, { id, title: title(id) })
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const setKeybind = (id: string, keybind: string) => {
|
||||
settings.keybinds.set(id, keybind)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const handle = (event: KeyboardEvent) => {
|
||||
const id = 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: "Shortcut already in use",
|
||||
description: `${formatKeybind(next)} is already assigned to ${[...conflicts.values()].join(", ")}.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setKeybind(id, next)
|
||||
stop()
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handle, true)
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handle, true)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (active()) command.keybinds(true)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar" style={{ padding: "0 40px 40px 40px" }}>
|
||||
<div
|
||||
class="sticky top-0 z-10"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha) calc(100% - 24px), transparent)",
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4 pt-6 pb-8 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">Keyboard shortcuts</h2>
|
||||
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 max-w-[720px]">
|
||||
<For each={GROUPS}>
|
||||
{(group) => (
|
||||
<Show when={(grouped().get(group) ?? []).length > 0}>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{group}</h3>
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<For each={grouped().get(group) ?? []}>
|
||||
{(id) => (
|
||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<span class="text-14-regular text-text-strong">{title(id)}</span>
|
||||
<button
|
||||
type="button"
|
||||
classList={{
|
||||
"h-8 px-3 rounded-md text-12-regular": true,
|
||||
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
|
||||
active() !== id,
|
||||
"border border-border-weak-base bg-surface-inset-base text-text-weak": active() === id,
|
||||
}}
|
||||
onClick={() => start(id)}
|
||||
>
|
||||
<Show when={active() === id} fallback={command.keybind(id) || "Unassigned"}>
|
||||
Press keys
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Component } from "solid-js"
|
||||
|
||||
export const SettingsMcp: Component = () => {
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">MCP</h2>
|
||||
<p class="text-14-regular text-text-weak">MCP settings will be configurable here.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Component } from "solid-js"
|
||||
|
||||
export const SettingsModels: Component = () => {
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">Models</h2>
|
||||
<p class="text-14-regular text-text-weak">Model settings will be configurable here.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Component, For, createMemo, type JSX } from "solid-js"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
|
||||
type PermissionAction = "allow" | "ask" | "deny"
|
||||
|
||||
type PermissionObject = Record<string, PermissionAction>
|
||||
type PermissionValue = PermissionAction | PermissionObject | string[] | undefined
|
||||
type PermissionMap = Record<string, PermissionValue>
|
||||
|
||||
type PermissionItem = {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const ACTIONS: Array<{ value: PermissionAction; label: string }> = [
|
||||
{ value: "allow", label: "Allow" },
|
||||
{ value: "ask", label: "Ask" },
|
||||
{ value: "deny", label: "Deny" },
|
||||
]
|
||||
|
||||
const ITEMS: PermissionItem[] = [
|
||||
{ id: "read", title: "Read", description: "Reading a file (matches the file path)" },
|
||||
{ id: "edit", title: "Edit", description: "Modify files, including edits, writes, patches, and multi-edits" },
|
||||
{ id: "glob", title: "Glob", description: "Match files using glob patterns" },
|
||||
{ id: "grep", title: "Grep", description: "Search file contents using regular expressions" },
|
||||
{ id: "list", title: "List", description: "List files within a directory" },
|
||||
{ id: "bash", title: "Bash", description: "Run shell commands" },
|
||||
{ id: "task", title: "Task", description: "Launch sub-agents" },
|
||||
{ id: "skill", title: "Skill", description: "Load a skill by name" },
|
||||
{ id: "lsp", title: "LSP", description: "Run language server queries" },
|
||||
{ id: "todoread", title: "Todo Read", description: "Read the todo list" },
|
||||
{ id: "todowrite", title: "Todo Write", description: "Update the todo list" },
|
||||
{ id: "webfetch", title: "Web Fetch", description: "Fetch content from a URL" },
|
||||
{ id: "websearch", title: "Web Search", description: "Search the web" },
|
||||
{ id: "codesearch", title: "Code Search", description: "Search code on the web" },
|
||||
{ id: "external_directory", title: "External Directory", description: "Access files outside the project directory" },
|
||||
{ id: "doom_loop", title: "Doom Loop", description: "Detect repeated tool calls with identical input" },
|
||||
]
|
||||
|
||||
const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"])
|
||||
|
||||
function toMap(value: unknown): PermissionMap {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap
|
||||
|
||||
const action = getAction(value)
|
||||
if (action) return { "*": action }
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
function getAction(value: unknown): PermissionAction | undefined {
|
||||
if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction
|
||||
return
|
||||
}
|
||||
|
||||
function getRuleDefault(value: unknown): PermissionAction | undefined {
|
||||
const action = getAction(value)
|
||||
if (action) return action
|
||||
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return
|
||||
|
||||
return getAction((value as Record<string, unknown>)["*"])
|
||||
}
|
||||
|
||||
export const SettingsPermissions: Component = () => {
|
||||
const globalSync = useGlobalSync()
|
||||
|
||||
const permission = createMemo(() => {
|
||||
return toMap(globalSync.data.config.permission)
|
||||
})
|
||||
|
||||
const actionFor = (id: string): PermissionAction => {
|
||||
const value = permission()[id]
|
||||
const direct = getRuleDefault(value)
|
||||
if (direct) return direct
|
||||
|
||||
const wildcard = getRuleDefault(permission()["*"])
|
||||
if (wildcard) return wildcard
|
||||
|
||||
return "allow"
|
||||
}
|
||||
|
||||
const setPermission = async (id: string, action: PermissionAction) => {
|
||||
const before = globalSync.data.config.permission
|
||||
const map = toMap(before)
|
||||
const existing = map[id]
|
||||
|
||||
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) => {
|
||||
globalSync.set("config", "permission", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: "Failed to update permissions", description: message })
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
|
||||
<div
|
||||
class="sticky top-0 z-10"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha) calc(100% - 24px), transparent)",
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col gap-1 p-8 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">Permissions</h2>
|
||||
<p class="text-14-regular text-text-weak">Control what tools the server can use by default.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6 p-8 pt-6 max-w-[720px]">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-14-medium text-text-strong">Appearance</h3>
|
||||
<div class="border border-border-weak-base rounded-lg overflow-hidden">
|
||||
<For each={ITEMS}>
|
||||
{(item) => (
|
||||
<SettingsRow title={item.title} description={item.description}>
|
||||
<Select
|
||||
options={ACTIONS}
|
||||
current={ACTIONS.find((o) => o.value === actionFor(item.id))}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && setPermission(item.id, option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingsRowProps {
|
||||
title: string
|
||||
description: string
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
||||
return (
|
||||
<div class="flex items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-14-medium text-text-strong">{props.title}</span>
|
||||
<span class="text-12-regular text-text-weak">{props.description}</span>
|
||||
</div>
|
||||
<div class="flex-shrink-0">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Component } from "solid-js"
|
||||
|
||||
export const SettingsProviders: Component = () => {
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">Providers</h2>
|
||||
<p class="text-14-regular text-text-weak">Provider settings will be configurable here.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
||||
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { monoFontFamily, useSettings } from "@/context/settings"
|
||||
import { SerializeAddon } from "@/addons/serialize"
|
||||
import { LocalPTY } from "@/context/terminal"
|
||||
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
|
||||
@@ -37,7 +36,6 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
|
||||
|
||||
export const Terminal = (props: TerminalProps) => {
|
||||
const sdk = useSDK()
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
|
||||
@@ -84,14 +82,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
setOption("theme", colors)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const font = monoFontFamily(settings.appearance.font())
|
||||
if (!term) return
|
||||
const setOption = (term as unknown as { setOption?: (key: string, value: string) => void }).setOption
|
||||
if (!setOption) return
|
||||
setOption("fontFamily", font)
|
||||
})
|
||||
|
||||
const focusTerminal = () => {
|
||||
const t = term
|
||||
if (!t) return
|
||||
@@ -122,7 +112,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
fontSize: 14,
|
||||
fontFamily: monoFontFamily(settings.appearance.font()),
|
||||
fontFamily: "IBM Plex Mono, monospace",
|
||||
allowTransparency: true,
|
||||
theme: terminalColors(),
|
||||
scrollback: 10_000,
|
||||
|
||||
@@ -1,28 +1,9 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
|
||||
const PALETTE_ID = "command.palette"
|
||||
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
||||
const SUGGESTED_PREFIX = "suggested."
|
||||
|
||||
function actionId(id: string) {
|
||||
if (!id.startsWith(SUGGESTED_PREFIX)) return id
|
||||
return id.slice(SUGGESTED_PREFIX.length)
|
||||
}
|
||||
|
||||
function normalizeKey(key: string) {
|
||||
if (key === ",") return "comma"
|
||||
if (key === "+") return "plus"
|
||||
if (key === " ") return "space"
|
||||
return key.toLowerCase()
|
||||
}
|
||||
|
||||
export type KeybindConfig = string
|
||||
|
||||
export interface Keybind {
|
||||
@@ -46,14 +27,6 @@ export interface CommandOption {
|
||||
onHighlight?: () => (() => void) | void
|
||||
}
|
||||
|
||||
export type CommandCatalogItem = {
|
||||
title: string
|
||||
description?: string
|
||||
category?: string
|
||||
keybind?: KeybindConfig
|
||||
slash?: string
|
||||
}
|
||||
|
||||
export function parseKeybind(config: string): Keybind[] {
|
||||
if (!config || config === "none") return []
|
||||
|
||||
@@ -100,7 +73,7 @@ export function parseKeybind(config: string): Keybind[] {
|
||||
}
|
||||
|
||||
export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
|
||||
const eventKey = normalizeKey(event.key)
|
||||
const eventKey = event.key.toLowerCase()
|
||||
|
||||
for (const kb of keybinds) {
|
||||
const keyMatch = kb.key === eventKey
|
||||
@@ -132,17 +105,15 @@ export function formatKeybind(config: string): string {
|
||||
if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
|
||||
|
||||
if (kb.key) {
|
||||
const keys: Record<string, string> = {
|
||||
const arrows: Record<string, string> = {
|
||||
arrowup: "↑",
|
||||
arrowdown: "↓",
|
||||
arrowleft: "←",
|
||||
arrowright: "→",
|
||||
comma: ",",
|
||||
plus: "+",
|
||||
space: "Space",
|
||||
}
|
||||
const key = kb.key.toLowerCase()
|
||||
const displayKey = keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1))
|
||||
const displayKey =
|
||||
arrows[kb.key.toLowerCase()] ??
|
||||
(kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1))
|
||||
parts.push(displayKey)
|
||||
}
|
||||
|
||||
@@ -153,23 +124,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
name: "Command",
|
||||
init: () => {
|
||||
const dialog = useDialog()
|
||||
const settings = useSettings()
|
||||
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
||||
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||
|
||||
const [catalog, setCatalog, _, catalogReady] = persisted(
|
||||
Persist.global("command.catalog.v1"),
|
||||
createStore<Record<string, CommandCatalogItem>>({}),
|
||||
)
|
||||
|
||||
const bind = (id: string, def: KeybindConfig | undefined) => {
|
||||
const custom = settings.keybinds.get(actionId(id))
|
||||
const config = custom ?? def
|
||||
if (!config || config === "none") return
|
||||
return config
|
||||
}
|
||||
|
||||
const registered = createMemo(() => {
|
||||
const options = createMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
const all: CommandOption[] = []
|
||||
|
||||
@@ -181,41 +139,15 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
}
|
||||
}
|
||||
|
||||
return all
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!catalogReady()) return
|
||||
|
||||
for (const opt of registered()) {
|
||||
const id = actionId(opt.id)
|
||||
setCatalog(id, {
|
||||
title: opt.title,
|
||||
description: opt.description,
|
||||
category: opt.category,
|
||||
keybind: opt.keybind,
|
||||
slash: opt.slash,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
|
||||
|
||||
const options = createMemo(() => {
|
||||
const resolved = registered().map((opt) => ({
|
||||
...opt,
|
||||
keybind: bind(opt.id, opt.keybind),
|
||||
}))
|
||||
|
||||
const suggested = resolved.filter((x) => x.suggested && !x.disabled)
|
||||
const suggested = all.filter((x) => x.suggested && !x.disabled)
|
||||
|
||||
return [
|
||||
...suggested.map((x) => ({
|
||||
...x,
|
||||
id: SUGGESTED_PREFIX + x.id,
|
||||
id: "suggested." + x.id,
|
||||
category: "Suggested",
|
||||
})),
|
||||
...resolved,
|
||||
...all,
|
||||
]
|
||||
})
|
||||
|
||||
@@ -237,7 +169,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (suspended() || dialog.active) return
|
||||
|
||||
const paletteKeybinds = parseKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
|
||||
const paletteKeybinds = parseKeybind("mod+shift+p")
|
||||
if (matchKeybind(paletteKeybinds, event)) {
|
||||
event.preventDefault()
|
||||
showPalette()
|
||||
@@ -277,27 +209,15 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
run(id, source)
|
||||
},
|
||||
keybind(id: string) {
|
||||
if (id === PALETTE_ID) {
|
||||
return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
|
||||
}
|
||||
|
||||
const base = actionId(id)
|
||||
const option = options().find((x) => actionId(x.id) === base)
|
||||
if (option?.keybind) return formatKeybind(option.keybind)
|
||||
|
||||
const meta = catalog[base]
|
||||
const config = bind(base, meta?.keybind)
|
||||
if (!config) return ""
|
||||
return formatKeybind(config)
|
||||
const option = options().find((x) => x.id === id || x.id === "suggested." + id)
|
||||
if (!option?.keybind) return ""
|
||||
return formatKeybind(option.keybind)
|
||||
},
|
||||
show: showPalette,
|
||||
keybinds(enabled: boolean) {
|
||||
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
||||
},
|
||||
suspended,
|
||||
get catalog() {
|
||||
return catalogOptions()
|
||||
},
|
||||
get options() {
|
||||
return options()
|
||||
},
|
||||
|
||||
@@ -88,10 +88,6 @@ type VcsCache = {
|
||||
ready: Accessor<boolean>
|
||||
}
|
||||
|
||||
type ChildOptions = {
|
||||
bootstrap?: boolean
|
||||
}
|
||||
|
||||
function createGlobalSync() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
@@ -105,37 +101,17 @@ function createGlobalSync() {
|
||||
project: Project[]
|
||||
provider: ProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
config: Config
|
||||
reload: undefined | "pending" | "complete"
|
||||
}>({
|
||||
ready: false,
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
project: [],
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
provider_auth: {},
|
||||
config: {},
|
||||
reload: undefined,
|
||||
})
|
||||
let bootstrapQueue: string[] = []
|
||||
|
||||
createEffect(async () => {
|
||||
if (globalStore.reload !== "complete") return
|
||||
if (bootstrapQueue.length) {
|
||||
for (const directory of bootstrapQueue) {
|
||||
bootstrapInstance(directory)
|
||||
}
|
||||
bootstrap()
|
||||
}
|
||||
bootstrapQueue = []
|
||||
setGlobalStore("reload", undefined)
|
||||
})
|
||||
|
||||
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||
const booting = new Map<string, Promise<void>>()
|
||||
const sessionLoads = new Map<string, Promise<void>>()
|
||||
const sessionMeta = new Map<string, { limit: number }>()
|
||||
|
||||
function ensureChild(directory: string) {
|
||||
function child(directory: string) {
|
||||
if (!directory) console.error("No directory provided")
|
||||
if (!children[directory]) {
|
||||
const cache = runWithOwner(owner, () =>
|
||||
@@ -170,6 +146,7 @@ function createGlobalSync() {
|
||||
message: {},
|
||||
part: {},
|
||||
})
|
||||
bootstrapInstance(directory)
|
||||
}
|
||||
|
||||
runWithOwner(owner, init)
|
||||
@@ -179,24 +156,11 @@ function createGlobalSync() {
|
||||
return childStore
|
||||
}
|
||||
|
||||
function child(directory: string, options: ChildOptions = {}) {
|
||||
const childStore = ensureChild(directory)
|
||||
const shouldBootstrap = options.bootstrap ?? true
|
||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||
void bootstrapInstance(directory)
|
||||
}
|
||||
return childStore
|
||||
}
|
||||
|
||||
async function loadSessions(directory: string) {
|
||||
const pending = sessionLoads.get(directory)
|
||||
if (pending) return pending
|
||||
const [store, setStore] = child(directory)
|
||||
const limit = store.limit
|
||||
|
||||
const [store, setStore] = child(directory, { bootstrap: false })
|
||||
const meta = sessionMeta.get(directory)
|
||||
if (meta && meta.limit >= store.limit) return
|
||||
|
||||
const promise = globalSDK.client.session
|
||||
return globalSDK.client.session
|
||||
.list({ directory, roots: true })
|
||||
.then((x) => {
|
||||
const nonArchived = (x.data ?? [])
|
||||
@@ -205,15 +169,9 @@ function createGlobalSync() {
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
|
||||
// Read the current limit at resolve-time so callers that bump the limit while
|
||||
// a request is in-flight still get the expanded result.
|
||||
const limit = store.limit
|
||||
|
||||
const sandboxWorkspace = globalStore.project.some((p) => (p.sandboxes ?? []).includes(directory))
|
||||
if (sandboxWorkspace) {
|
||||
setStore("sessionTotal", nonArchived.length)
|
||||
setStore("session", reconcile(nonArchived, { key: "id" }))
|
||||
sessionMeta.set(directory, { limit })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -227,164 +185,136 @@ function createGlobalSync() {
|
||||
// Store total session count (used for "load more" pagination)
|
||||
setStore("sessionTotal", nonArchived.length)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
sessionMeta.set(directory, { limit })
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load sessions", err)
|
||||
const project = getFilename(directory)
|
||||
showToast({ title: `Failed to load sessions for ${project}`, description: err.message })
|
||||
})
|
||||
|
||||
sessionLoads.set(directory, promise)
|
||||
promise.finally(() => {
|
||||
sessionLoads.delete(directory)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
async function bootstrapInstance(directory: string) {
|
||||
if (!directory) return
|
||||
const pending = booting.get(directory)
|
||||
if (pending) return pending
|
||||
const [store, setStore] = child(directory)
|
||||
const cache = vcsCache.get(directory)
|
||||
if (!cache) return
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
const promise = (async () => {
|
||||
const [store, setStore] = ensureChild(directory)
|
||||
const cache = vcsCache.get(directory)
|
||||
if (!cache) return
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!cache.ready()) return
|
||||
const cached = cache.store.value
|
||||
if (!cached?.branch) return
|
||||
setStore("vcs", (value) => value ?? cached)
|
||||
})
|
||||
|
||||
setStore("status", "loading")
|
||||
const blockingRequests = {
|
||||
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
|
||||
provider: () =>
|
||||
sdk.provider.list().then((x) => {
|
||||
const data = x.data!
|
||||
setStore("provider", {
|
||||
...data,
|
||||
all: data.all.map((provider) => ({
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||
),
|
||||
})),
|
||||
})
|
||||
}),
|
||||
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||
}
|
||||
await Promise.all(Object.values(blockingRequests).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
|
||||
.then(() => {
|
||||
if (store.status !== "complete") setStore("status", "partial")
|
||||
// non-blocking
|
||||
Promise.all([
|
||||
sdk.path.get().then((x) => setStore("path", x.data!)),
|
||||
sdk.command.list().then((x) => setStore("command", x.data ?? [])),
|
||||
sdk.session.status().then((x) => setStore("session_status", x.data!)),
|
||||
loadSessions(directory),
|
||||
sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
|
||||
sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
|
||||
sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? store.vcs
|
||||
setStore("vcs", next)
|
||||
if (next?.branch) cache.setStore("value", next)
|
||||
}),
|
||||
sdk.permission.list().then((x) => {
|
||||
const grouped: Record<string, PermissionRequest[]> = {}
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id || !perm.sessionID) continue
|
||||
const existing = grouped[perm.sessionID]
|
||||
if (existing) {
|
||||
existing.push(perm)
|
||||
continue
|
||||
}
|
||||
grouped[perm.sessionID] = [perm]
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!cache.ready()) return
|
||||
const cached = cache.store.value
|
||||
if (!cached?.branch) return
|
||||
setStore("vcs", (value) => value ?? cached)
|
||||
})
|
||||
|
||||
const blockingRequests = {
|
||||
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
|
||||
provider: () =>
|
||||
sdk.provider.list().then((x) => {
|
||||
const data = x.data!
|
||||
setStore("provider", {
|
||||
...data,
|
||||
all: data.all.map((provider) => ({
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||
),
|
||||
})),
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions
|
||||
.filter((p) => !!p?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||
}
|
||||
sdk.question.list().then((x) => {
|
||||
const grouped: Record<string, QuestionRequest[]> = {}
|
||||
for (const question of x.data ?? []) {
|
||||
if (!question?.id || !question.sessionID) continue
|
||||
const existing = grouped[question.sessionID]
|
||||
if (existing) {
|
||||
existing.push(question)
|
||||
continue
|
||||
}
|
||||
grouped[question.sessionID] = [question]
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
|
||||
} catch (err) {
|
||||
console.error("Failed to bootstrap instance", err)
|
||||
const project = getFilename(directory)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: `Failed to reload ${project}`, description: message })
|
||||
setStore("status", "partial")
|
||||
return
|
||||
}
|
||||
|
||||
if (store.status !== "complete") setStore("status", "partial")
|
||||
|
||||
Promise.all([
|
||||
sdk.path.get().then((x) => setStore("path", x.data!)),
|
||||
sdk.command.list().then((x) => setStore("command", x.data ?? [])),
|
||||
sdk.session.status().then((x) => setStore("session_status", x.data!)),
|
||||
loadSessions(directory),
|
||||
sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
|
||||
sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
|
||||
sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? store.vcs
|
||||
setStore("vcs", next)
|
||||
if (next?.branch) cache.setStore("value", next)
|
||||
}),
|
||||
sdk.permission.list().then((x) => {
|
||||
const grouped: Record<string, PermissionRequest[]> = {}
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id || !perm.sessionID) continue
|
||||
const existing = grouped[perm.sessionID]
|
||||
if (existing) {
|
||||
existing.push(perm)
|
||||
continue
|
||||
}
|
||||
grouped[perm.sessionID] = [perm]
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions
|
||||
.filter((p) => !!p?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
sdk.question.list().then((x) => {
|
||||
const grouped: Record<string, QuestionRequest[]> = {}
|
||||
for (const question of x.data ?? []) {
|
||||
if (!question?.id || !question.sessionID) continue
|
||||
const existing = grouped[question.sessionID]
|
||||
if (existing) {
|
||||
existing.push(question)
|
||||
continue
|
||||
}
|
||||
grouped[question.sessionID] = [question]
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions
|
||||
.filter((q) => !!q?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
]).then(() => {
|
||||
setStore("status", "complete")
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions
|
||||
.filter((q) => !!q?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
]).then(() => {
|
||||
setStore("status", "complete")
|
||||
})
|
||||
})
|
||||
})()
|
||||
|
||||
booting.set(directory, promise)
|
||||
promise.finally(() => {
|
||||
booting.delete(directory)
|
||||
})
|
||||
return promise
|
||||
.catch((e) => setGlobalStore("error", e))
|
||||
}
|
||||
|
||||
const unsub = globalSDK.event.listen((e) => {
|
||||
@@ -394,7 +324,6 @@ function createGlobalSync() {
|
||||
if (directory === "global") {
|
||||
switch (event?.type) {
|
||||
case "global.disposed": {
|
||||
if (globalStore.reload) return
|
||||
bootstrap()
|
||||
break
|
||||
}
|
||||
@@ -416,16 +345,9 @@ function createGlobalSync() {
|
||||
return
|
||||
}
|
||||
|
||||
const existing = children[directory]
|
||||
if (!existing) return
|
||||
|
||||
const [store, setStore] = existing
|
||||
const [store, setStore] = child(directory)
|
||||
switch (event.type) {
|
||||
case "server.instance.disposed": {
|
||||
if (globalStore.reload) {
|
||||
bootstrapQueue.push(directory)
|
||||
return
|
||||
}
|
||||
bootstrapInstance(directory)
|
||||
break
|
||||
}
|
||||
@@ -669,11 +591,6 @@ function createGlobalSync() {
|
||||
setGlobalStore("path", x.data!)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
globalSDK.client.config.get().then((x) => {
|
||||
setGlobalStore("config", x.data!)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
globalSDK.client.project.list().then(async (x) => {
|
||||
const projects = (x.data ?? [])
|
||||
@@ -714,7 +631,6 @@ function createGlobalSync() {
|
||||
|
||||
return {
|
||||
data: globalStore,
|
||||
set: setGlobalStore,
|
||||
get ready() {
|
||||
return globalStore.ready
|
||||
},
|
||||
@@ -723,14 +639,6 @@ function createGlobalSync() {
|
||||
},
|
||||
child,
|
||||
bootstrap,
|
||||
updateConfig: async (config: Config) => {
|
||||
setGlobalStore("reload", "pending")
|
||||
const response = await globalSDK.client.config.update({ config })
|
||||
setTimeout(() => {
|
||||
setGlobalStore("reload", "complete")
|
||||
}, 1000)
|
||||
return response
|
||||
},
|
||||
project: {
|
||||
loadSessions,
|
||||
},
|
||||
|
||||
@@ -4,12 +4,13 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
import { makeAudioPlayer } from "@solid-primitives/audio"
|
||||
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
|
||||
import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { playSound, soundSrc } from "@/utils/sound"
|
||||
|
||||
type NotificationBase = {
|
||||
directory?: string
|
||||
@@ -43,10 +44,19 @@ function pruneNotifications(list: Notification[]) {
|
||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||
name: "Notification",
|
||||
init: () => {
|
||||
let idlePlayer: ReturnType<typeof makeAudioPlayer> | undefined
|
||||
let errorPlayer: ReturnType<typeof makeAudioPlayer> | undefined
|
||||
|
||||
try {
|
||||
idlePlayer = makeAudioPlayer(idleSound)
|
||||
errorPlayer = makeAudioPlayer(errorSound)
|
||||
} catch (err) {
|
||||
console.log("Failed to load audio", err)
|
||||
}
|
||||
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("notification", ["notification.v1"]),
|
||||
@@ -83,20 +93,16 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
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()))
|
||||
|
||||
try {
|
||||
idlePlayer?.play()
|
||||
} catch {}
|
||||
append({
|
||||
...base,
|
||||
type: "turn-complete",
|
||||
session: sessionID,
|
||||
})
|
||||
|
||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify("Response ready", session?.title ?? sessionID, href)
|
||||
}
|
||||
|
||||
void platform.notify("Response ready", session?.title ?? sessionID, href)
|
||||
break
|
||||
}
|
||||
case "session.error": {
|
||||
@@ -105,9 +111,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
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()))
|
||||
|
||||
try {
|
||||
errorPlayer?.play()
|
||||
} catch {}
|
||||
const error = "error" in event.properties ? event.properties.error : undefined
|
||||
append({
|
||||
...base,
|
||||
@@ -115,13 +121,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
session: sessionID ?? "global",
|
||||
error,
|
||||
})
|
||||
|
||||
const description = session?.title ?? (typeof error === "string" ? error : "An error occurred")
|
||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||
if (settings.notifications.errors()) {
|
||||
void platform.notify("Session error", description, href)
|
||||
}
|
||||
|
||||
void platform.notify("Session error", description, href)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { persisted } from "@/utils/persist"
|
||||
|
||||
export interface NotificationSettings {
|
||||
agent: boolean
|
||||
permissions: boolean
|
||||
errors: boolean
|
||||
}
|
||||
|
||||
export interface SoundSettings {
|
||||
agent: string
|
||||
permissions: string
|
||||
errors: string
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
general: {
|
||||
autoSave: boolean
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
font: string
|
||||
}
|
||||
keybinds: Record<string, string>
|
||||
permissions: {
|
||||
autoApprove: boolean
|
||||
}
|
||||
notifications: NotificationSettings
|
||||
sounds: SoundSettings
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
general: {
|
||||
autoSave: true,
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
font: "ibm-plex-mono",
|
||||
},
|
||||
keybinds: {},
|
||||
permissions: {
|
||||
autoApprove: false,
|
||||
},
|
||||
notifications: {
|
||||
agent: true,
|
||||
permissions: true,
|
||||
errors: false,
|
||||
},
|
||||
sounds: {
|
||||
agent: "staplebops-01",
|
||||
permissions: "staplebops-02",
|
||||
errors: "nope-03",
|
||||
},
|
||||
}
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
|
||||
const monoFonts: Record<string, string> = {
|
||||
"ibm-plex-mono": `"IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"cascadia-code": `"Cascadia Code Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"fira-code": `"Fira Code Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
hack: `"Hack Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
inconsolata: `"Inconsolata Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"intel-one-mono": `"Intel One Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"jetbrains-mono": `"JetBrains Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"meslo-lgs": `"Meslo LGS Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"roboto-mono": `"Roboto Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"source-code-pro": `"Source Code Pro Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"ubuntu-mono": `"Ubuntu Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
}
|
||||
|
||||
export function monoFontFamily(font: string | undefined) {
|
||||
return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font]
|
||||
}
|
||||
|
||||
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
||||
name: "Settings",
|
||||
init: () => {
|
||||
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
get current() {
|
||||
return store
|
||||
},
|
||||
general: {
|
||||
autoSave: createMemo(() => store.general?.autoSave ?? defaultSettings.general.autoSave),
|
||||
setAutoSave(value: boolean) {
|
||||
setStore("general", "autoSave", value)
|
||||
},
|
||||
},
|
||||
appearance: {
|
||||
fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize),
|
||||
setFontSize(value: number) {
|
||||
setStore("appearance", "fontSize", value)
|
||||
},
|
||||
font: createMemo(() => store.appearance?.font ?? defaultSettings.appearance.font),
|
||||
setFont(value: string) {
|
||||
setStore("appearance", "font", value)
|
||||
},
|
||||
},
|
||||
keybinds: {
|
||||
get: (action: string) => store.keybinds?.[action],
|
||||
set(action: string, keybind: string) {
|
||||
setStore("keybinds", action, keybind)
|
||||
},
|
||||
reset(action: string) {
|
||||
setStore("keybinds", action, undefined!)
|
||||
},
|
||||
resetAll() {
|
||||
setStore("keybinds", reconcile({}))
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
autoApprove: createMemo(() => store.permissions?.autoApprove ?? defaultSettings.permissions.autoApprove),
|
||||
setAutoApprove(value: boolean) {
|
||||
setStore("permissions", "autoApprove", value)
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
agent: createMemo(() => store.notifications?.agent ?? defaultSettings.notifications.agent),
|
||||
setAgent(value: boolean) {
|
||||
setStore("notifications", "agent", value)
|
||||
},
|
||||
permissions: createMemo(() => store.notifications?.permissions ?? defaultSettings.notifications.permissions),
|
||||
setPermissions(value: boolean) {
|
||||
setStore("notifications", "permissions", value)
|
||||
},
|
||||
errors: createMemo(() => store.notifications?.errors ?? defaultSettings.notifications.errors),
|
||||
setErrors(value: boolean) {
|
||||
setStore("notifications", "errors", value)
|
||||
},
|
||||
},
|
||||
sounds: {
|
||||
agent: createMemo(() => store.sounds?.agent ?? defaultSettings.sounds.agent),
|
||||
setAgent(value: string) {
|
||||
setStore("sounds", "agent", value)
|
||||
},
|
||||
permissions: createMemo(() => store.sounds?.permissions ?? defaultSettings.sounds.permissions),
|
||||
setPermissions(value: string) {
|
||||
setStore("sounds", "permissions", value)
|
||||
},
|
||||
errors: createMemo(() => store.sounds?.errors ?? defaultSettings.sounds.errors),
|
||||
setErrors(value: string) {
|
||||
setStore("sounds", "errors", value)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -38,22 +38,6 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess
|
||||
}),
|
||||
)
|
||||
|
||||
const unsub = sdk.event.on("pty.exited", (event) => {
|
||||
const id = event.properties.id
|
||||
if (!store.all.some((x) => x.id === id)) return
|
||||
batch(() => {
|
||||
setStore(
|
||||
"all",
|
||||
store.all.filter((x) => x.id !== id),
|
||||
)
|
||||
if (store.active === id) {
|
||||
const remaining = store.all.filter((x) => x.id !== id)
|
||||
setStore("active", remaining[0]?.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
return {
|
||||
ready,
|
||||
all: createMemo(() => Object.values(store.all)),
|
||||
|
||||
+120
-258
@@ -20,7 +20,6 @@ import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -35,9 +34,8 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client"
|
||||
import { Session, type Message, type TextPart, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import {
|
||||
DragDropProvider,
|
||||
@@ -55,20 +53,20 @@ import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { playSound, soundSrc } from "@/utils/sound"
|
||||
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { DialogSettings } from "@/components/dialog-settings"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ProjectAvatar } from "@/components/project-avatar"
|
||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||
import { navStart } from "@/utils/perf"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
||||
import { Titlebar } from "@/components/titlebar"
|
||||
import { useServer } from "@/context/server"
|
||||
import { ScrollReveal } from "@opencode-ai/ui/scroll-reveal"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const [store, setStore, , ready] = persisted(
|
||||
@@ -79,7 +77,6 @@ export default function Layout(props: ParentProps) {
|
||||
activeWorkspace: undefined as string | undefined,
|
||||
workspaceOrder: {} as Record<string, string[]>,
|
||||
workspaceName: {} as Record<string, string>,
|
||||
workspaceBranchName: {} as Record<string, Record<string, string>>,
|
||||
workspaceExpanded: {} as Record<string, boolean>,
|
||||
}),
|
||||
)
|
||||
@@ -100,7 +97,6 @@ export default function Layout(props: ParentProps) {
|
||||
const layout = useLayout()
|
||||
const layoutReady = createMemo(() => layout.ready())
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const server = useServer()
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
@@ -122,36 +118,15 @@ export default function Layout(props: ParentProps) {
|
||||
active: "" as string,
|
||||
value: "",
|
||||
})
|
||||
const [busyWorkspaces, setBusyWorkspaces] = createSignal<Set<string>>(new Set())
|
||||
const setBusy = (directory: string, value: boolean) => {
|
||||
const key = workspaceKey(directory)
|
||||
setBusyWorkspaces((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (value) next.add(key)
|
||||
else next.delete(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const isBusy = (directory: string) => busyWorkspaces().has(workspaceKey(directory))
|
||||
const editorRef = { current: undefined as HTMLInputElement | undefined }
|
||||
|
||||
const autoselecting = createMemo(() => {
|
||||
if (params.dir) return false
|
||||
if (initialDir) return false
|
||||
if (!autoselect()) return false
|
||||
if (!pageReady()) return true
|
||||
if (!layoutReady()) return true
|
||||
const list = layout.projects.list()
|
||||
if (list.length === 0) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const editorOpen = (id: string) => editor.active === id
|
||||
const editorValue = () => editor.value
|
||||
|
||||
const openEditor = (id: string, value: string) => {
|
||||
if (!id) return
|
||||
setEditor({ active: id, value })
|
||||
queueMicrotask(() => editorRef.current?.focus())
|
||||
}
|
||||
|
||||
const closeEditor = () => setEditor({ active: "", value: "" })
|
||||
@@ -213,22 +188,20 @@ export default function Layout(props: ParentProps) {
|
||||
onClick={stopPropagation}
|
||||
onTouchStart={stopPropagation}
|
||||
>
|
||||
{props.value()}
|
||||
<ScrollReveal>
|
||||
{props.value()}
|
||||
</ScrollReveal>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
editorRef.current = el
|
||||
requestAnimationFrame(() => el.focus())
|
||||
}}
|
||||
value={editorValue()}
|
||||
class={props.class}
|
||||
onInput={(event) => setEditor("value", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
editorKeyDown(event, props.onSave)
|
||||
}}
|
||||
onKeyDown={(event) => editorKeyDown(event, props.onSave)}
|
||||
onBlur={() => closeEditor()}
|
||||
onPointerDown={stopPropagation}
|
||||
onClick={stopPropagation}
|
||||
@@ -343,18 +316,7 @@ export default function Layout(props: ParentProps) {
|
||||
if (now - lastAlerted < cooldownMs) return
|
||||
alertedAtBySession.set(sessionKey, now)
|
||||
|
||||
if (e.details.type === "permission.asked") {
|
||||
playSound(soundSrc(settings.sounds.permissions()))
|
||||
if (settings.notifications.permissions()) {
|
||||
void platform.notify(config.title, description, href)
|
||||
}
|
||||
}
|
||||
|
||||
if (e.details.type === "question.asked") {
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify(config.title, description, href)
|
||||
}
|
||||
}
|
||||
void platform.notify(config.title, description, href)
|
||||
|
||||
const currentDir = params.dir ? base64Decode(params.dir) : undefined
|
||||
const currentSession = params.id
|
||||
@@ -499,27 +461,9 @@ export default function Layout(props: ParentProps) {
|
||||
),
|
||||
)
|
||||
|
||||
const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "")
|
||||
|
||||
const workspaceName = (directory: string, projectId?: string, branch?: string) => {
|
||||
const key = workspaceKey(directory)
|
||||
const direct = store.workspaceName[key] ?? store.workspaceName[directory]
|
||||
if (direct) return direct
|
||||
if (!projectId) return
|
||||
if (!branch) return
|
||||
return store.workspaceBranchName[projectId]?.[branch]
|
||||
}
|
||||
|
||||
const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => {
|
||||
const key = workspaceKey(directory)
|
||||
setStore("workspaceName", (prev) => ({ ...(prev ?? {}), [key]: next }))
|
||||
if (!projectId) return
|
||||
if (!branch) return
|
||||
setStore("workspaceBranchName", projectId, (prev) => ({ ...(prev ?? {}), [branch]: next }))
|
||||
}
|
||||
|
||||
const workspaceLabel = (directory: string, branch?: string, projectId?: string) =>
|
||||
workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
|
||||
const workspaceName = (directory: string) => store.workspaceName[directory]
|
||||
const workspaceLabel = (directory: string, branch?: string) =>
|
||||
workspaceName(directory) ?? branch ?? getFilename(directory)
|
||||
|
||||
const isWorkspaceEditing = () => editor.active.startsWith("workspace:")
|
||||
|
||||
@@ -574,13 +518,9 @@ export default function Layout(props: ParentProps) {
|
||||
if (!project) return [] as Session[]
|
||||
if (workspaceSetting()) {
|
||||
const dirs = workspaceIds(project)
|
||||
const activeDir = params.dir ? base64Decode(params.dir) : ""
|
||||
const result: Session[] = []
|
||||
for (const dir of dirs) {
|
||||
const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree
|
||||
const active = dir === activeDir
|
||||
if (!expanded && !active) continue
|
||||
const [dirStore] = globalSync.child(dir, { bootstrap: true })
|
||||
const [dirStore] = globalSync.child(dir)
|
||||
const dirSessions = dirStore.session
|
||||
.filter((session) => session.directory === dirStore.path.directory)
|
||||
.filter((session) => !session.parentID && !session.time?.archived)
|
||||
@@ -910,10 +850,6 @@ export default function Layout(props: ParentProps) {
|
||||
dialog.show(() => <DialogSelectServer />)
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
dialog.show(() => <DialogSettings />)
|
||||
}
|
||||
|
||||
function navigateToProject(directory: string | undefined) {
|
||||
if (!directory) return
|
||||
server.projects.touch(directory)
|
||||
@@ -952,10 +888,10 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
|
||||
const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
|
||||
const renameWorkspace = (directory: string, next: string) => {
|
||||
const current = workspaceName(directory) ?? getFilename(directory)
|
||||
if (current === next) return
|
||||
setWorkspaceName(directory, next, projectId, branch)
|
||||
setStore("workspaceName", directory, next)
|
||||
}
|
||||
|
||||
function closeProject(directory: string) {
|
||||
@@ -1006,8 +942,6 @@ export default function Layout(props: ParentProps) {
|
||||
if (!current) return
|
||||
if (directory === current.worktree) return
|
||||
|
||||
setBusy(directory, true)
|
||||
|
||||
const result = await globalSDK.client.worktree
|
||||
.remove({ directory: current.worktree, worktreeRemoveInput: { directory } })
|
||||
.then((x) => x.data)
|
||||
@@ -1019,8 +953,6 @@ export default function Layout(props: ParentProps) {
|
||||
return false
|
||||
})
|
||||
|
||||
setBusy(directory, false)
|
||||
|
||||
if (!result) return
|
||||
|
||||
layout.projects.close(directory)
|
||||
@@ -1036,7 +968,12 @@ export default function Layout(props: ParentProps) {
|
||||
if (!current) return
|
||||
if (directory === current.worktree) return
|
||||
|
||||
setBusy(directory, true)
|
||||
const progress = showToast({
|
||||
persistent: true,
|
||||
title: "Resetting workspace",
|
||||
description: "This may take a minute.",
|
||||
})
|
||||
const dismiss = () => toaster.dismiss(progress)
|
||||
|
||||
const sessions = await globalSDK.client.session
|
||||
.list({ directory })
|
||||
@@ -1047,6 +984,7 @@ export default function Layout(props: ParentProps) {
|
||||
.reset({ directory: current.worktree, worktreeResetInput: { directory } })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
dismiss()
|
||||
showToast({
|
||||
title: "Failed to reset workspace",
|
||||
description: errorMessage(err),
|
||||
@@ -1055,7 +993,7 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
setBusy(directory, false)
|
||||
dismiss()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1075,8 +1013,7 @@ export default function Layout(props: ParentProps) {
|
||||
)
|
||||
|
||||
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
|
||||
|
||||
setBusy(directory, false)
|
||||
dismiss()
|
||||
|
||||
const href = `/${base64Encode(directory)}/session`
|
||||
navigate(href)
|
||||
@@ -1252,12 +1189,8 @@ export default function Layout(props: ParentProps) {
|
||||
if (!project) return
|
||||
|
||||
if (workspaceSetting()) {
|
||||
const activeDir = params.dir ? base64Decode(params.dir) : ""
|
||||
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
||||
for (const directory of dirs) {
|
||||
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
||||
const active = directory === activeDir
|
||||
if (!expanded && !active) continue
|
||||
globalSync.project.loadSessions(directory)
|
||||
}
|
||||
return
|
||||
@@ -1347,15 +1280,16 @@ export default function Layout(props: ParentProps) {
|
||||
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
||||
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
||||
const mask = "radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px)"
|
||||
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||
|
||||
return (
|
||||
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
|
||||
<div class="size-full rounded overflow-clip">
|
||||
<Avatar
|
||||
fallback={name()}
|
||||
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.override}
|
||||
{...getAvatarColors(props.project.icon?.color)}
|
||||
<div class={`relative size-8 shrink-0 rounded-sm ${props.class ?? ""}`}>
|
||||
<div class="size-full rounded-sm overflow-clip">
|
||||
<ProjectAvatar
|
||||
name={name()}
|
||||
projectId={props.project.id}
|
||||
iconUrl={props.project.icon?.url}
|
||||
iconColor={props.project.icon?.color}
|
||||
size="small"
|
||||
class="size-full rounded"
|
||||
style={
|
||||
notifications().length > 0 && props.notify
|
||||
@@ -1418,7 +1352,7 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
const hoverMessages = createMemo(() =>
|
||||
sessionStore.message[props.session.id]?.filter((message) => message.role === "user"),
|
||||
sessionStore.message[props.session.id]?.filter((message) => message.role === "user") as UserMessage[],
|
||||
)
|
||||
const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
|
||||
const hoverAllowed = createMemo(() => !props.mobile && layout.sidebar.opened())
|
||||
@@ -1491,7 +1425,7 @@ export default function Layout(props: ParentProps) {
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<HoverCard openDelay={1000} closeDelay={0} placement="right-start" gutter={28} trigger={item}>
|
||||
<HoverCard openDelay={150} closeDelay={100} placement="right" gutter={28} trigger={item}>
|
||||
<Show when={hoverReady()} fallback={<div class="text-12-regular text-text-weak">Loading messages…</div>}>
|
||||
<MessageNav
|
||||
messages={hoverMessages() ?? []}
|
||||
@@ -1561,7 +1495,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const [workspaceStore] = globalSync.child(directory)
|
||||
const kind = directory === project.worktree ? "local" : "sandbox"
|
||||
const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
|
||||
const name = workspaceLabel(directory, workspaceStore.vcs?.branch)
|
||||
return `${kind} : ${name}`
|
||||
})
|
||||
|
||||
@@ -1576,9 +1510,8 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => {
|
||||
const sortable = createSortable(props.directory)
|
||||
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
|
||||
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory)
|
||||
const [menuOpen, setMenuOpen] = createSignal(false)
|
||||
const [pendingRename, setPendingRename] = createSignal(false)
|
||||
const slug = createMemo(() => base64Encode(props.directory))
|
||||
const sessions = createMemo(() =>
|
||||
workspaceStore.session
|
||||
@@ -1587,20 +1520,13 @@ export default function Layout(props: ParentProps) {
|
||||
.toSorted(sortSessions),
|
||||
)
|
||||
const local = createMemo(() => props.directory === props.project.worktree)
|
||||
const active = createMemo(() => {
|
||||
const current = params.dir ? base64Decode(params.dir) : ""
|
||||
return current === props.directory
|
||||
})
|
||||
const workspaceValue = createMemo(() => {
|
||||
const branch = workspaceStore.vcs?.branch
|
||||
const name = branch ?? getFilename(props.directory)
|
||||
return workspaceName(props.directory, props.project.id, branch) ?? name
|
||||
const name = workspaceStore.vcs?.branch ?? getFilename(props.directory)
|
||||
return workspaceName(props.directory) ?? name
|
||||
})
|
||||
const open = createMemo(() => store.workspaceExpanded[props.directory] ?? local())
|
||||
const boot = createMemo(() => open() || active())
|
||||
const open = createMemo(() => store.workspaceExpanded[props.directory] ?? true)
|
||||
const loading = createMemo(() => open() && workspaceStore.status !== "complete" && sessions().length === 0)
|
||||
const hasMore = createMemo(() => local() && workspaceStore.sessionTotal > workspaceStore.session.length)
|
||||
const busy = createMemo(() => isBusy(props.directory))
|
||||
const loadMore = async () => {
|
||||
if (!local()) return
|
||||
setWorkspaceStore("limit", (limit) => limit + 5)
|
||||
@@ -1615,74 +1541,50 @@ export default function Layout(props: ParentProps) {
|
||||
if (editorOpen(`workspace:${props.directory}`)) closeEditor()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!boot()) return
|
||||
globalSync.child(props.directory, { bootstrap: true })
|
||||
})
|
||||
|
||||
const header = () => (
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center justify-center shrink-0 size-6">
|
||||
<Show when={busy()} fallback={<Icon name="branch" size="small" />}>
|
||||
<Spinner class="size-[15px]" />
|
||||
</Show>
|
||||
</div>
|
||||
<span class="text-14-medium text-text-base shrink-0">{local() ? "local" : "sandbox"} :</span>
|
||||
<Show
|
||||
when={!local()}
|
||||
fallback={
|
||||
<span class="text-14-medium text-text-base min-w-0 truncate">
|
||||
{workspaceStore.vcs?.branch ?? getFilename(props.directory)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InlineEditor
|
||||
id={`workspace:${props.directory}`}
|
||||
value={workspaceValue}
|
||||
onSave={(next) => {
|
||||
const trimmed = next.trim()
|
||||
if (!trimmed) return
|
||||
renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch)
|
||||
setEditor("value", workspaceValue())
|
||||
}}
|
||||
class="text-14-medium text-text-base min-w-0 truncate"
|
||||
displayClass="text-14-medium text-text-base min-w-0 truncate"
|
||||
editing={workspaceEditActive()}
|
||||
stopPropagation={false}
|
||||
openOnDblClick={false}
|
||||
/>
|
||||
</Show>
|
||||
<Icon
|
||||
name={open() ? "chevron-down" : "chevron-right"}
|
||||
size="small"
|
||||
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
// @ts-ignore
|
||||
use:sortable
|
||||
classList={{
|
||||
"opacity-30": sortable.isActiveDraggable,
|
||||
"opacity-50 pointer-events-none": busy(),
|
||||
}}
|
||||
>
|
||||
// @ts-ignore
|
||||
<div use: sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
|
||||
<div class="px-2 py-1">
|
||||
<div class="group/workspace relative">
|
||||
<div class="flex items-center gap-1">
|
||||
<Show
|
||||
when={workspaceEditActive()}
|
||||
fallback={
|
||||
<Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
|
||||
{header()}
|
||||
</Collapsible.Trigger>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md">{header()}</div>
|
||||
</Show>
|
||||
<Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center justify-center shrink-0 size-6">
|
||||
<Icon name="branch" size="small" />
|
||||
</div>
|
||||
<span class="text-14-medium text-text-base shrink-0">{local() ? "local" : "sandbox"} :</span>
|
||||
<Show
|
||||
when={!local()}
|
||||
fallback={
|
||||
<span class="text-14-medium text-text-base min-w-0 truncate">
|
||||
{workspaceStore.vcs?.branch ?? getFilename(props.directory)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InlineEditor
|
||||
id={`workspace:${props.directory}`}
|
||||
value={workspaceValue}
|
||||
onSave={(next) => {
|
||||
const trimmed = next.trim()
|
||||
if (!trimmed) return
|
||||
renameWorkspace(props.directory, trimmed)
|
||||
setEditor("value", workspaceValue())
|
||||
}}
|
||||
class="text-14-medium text-text-base min-w-0 truncate"
|
||||
displayClass="text-14-medium text-text-base min-w-0 truncate"
|
||||
editing={workspaceEditActive()}
|
||||
stopPropagation={false}
|
||||
openOnDblClick={false}
|
||||
/>
|
||||
</Show>
|
||||
<Icon
|
||||
name={open() ? "chevron-down" : "chevron-right"}
|
||||
size="small"
|
||||
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<div
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
|
||||
classList={{
|
||||
@@ -1697,37 +1599,21 @@ export default function Layout(props: ParentProps) {
|
||||
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" class="size-6 rounded-md" />
|
||||
</Tooltip>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!pendingRename()) return
|
||||
event.preventDefault()
|
||||
setPendingRename(false)
|
||||
openEditor(`workspace:${props.directory}`, workspaceValue())
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item onSelect={() => navigate(`/${slug()}/session`)}>
|
||||
<DropdownMenu.ItemLabel>New session</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled={local()}
|
||||
onSelect={() => {
|
||||
setPendingRename(true)
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>Rename</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled={local() || busy()}
|
||||
onSelect={() => dialog.show(() => <DialogResetWorkspace directory={props.directory} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>Reset</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemLabel>Reset workspace</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled={local() || busy()}
|
||||
disabled={local()}
|
||||
onSelect={() => dialog.show(() => <DialogDeleteWorkspace directory={props.directory} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>Delete</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemLabel>Delete workspace</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
@@ -1771,7 +1657,7 @@ export default function Layout(props: ParentProps) {
|
||||
size="large"
|
||||
onClick={(e: MouseEvent) => {
|
||||
loadMore()
|
||||
;(e.currentTarget as HTMLButtonElement).blur()
|
||||
; (e.currentTarget as HTMLButtonElement).blur()
|
||||
}}
|
||||
>
|
||||
Load more
|
||||
@@ -1799,7 +1685,7 @@ export default function Layout(props: ParentProps) {
|
||||
const label = (directory: string) => {
|
||||
const [data] = globalSync.child(directory)
|
||||
const kind = directory === props.project.worktree ? "local" : "sandbox"
|
||||
const name = workspaceLabel(directory, data.vcs?.branch, props.project.id)
|
||||
const name = workspaceLabel(directory, data.vcs?.branch)
|
||||
return `${kind} : ${name}`
|
||||
}
|
||||
|
||||
@@ -1839,9 +1725,8 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||
<div use: sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||
<HoverCard
|
||||
open={open()}
|
||||
openDelay={0}
|
||||
closeDelay={0}
|
||||
placement="right-start"
|
||||
@@ -1849,7 +1734,7 @@ export default function Layout(props: ParentProps) {
|
||||
trigger={trigger}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<div class="-m-3 p-2 flex flex-col w-72">
|
||||
<div class="-m-3 flex flex-col w-72">
|
||||
<div class="px-4 pt-2 pb-1 text-14-medium text-text-strong truncate">{displayName(props.project)}</div>
|
||||
<div class="px-4 pb-2 text-12-medium text-text-weak">Recent sessions</div>
|
||||
<div class="px-2 pb-2 flex flex-col gap-2">
|
||||
@@ -1894,22 +1779,20 @@ export default function Layout(props: ParentProps) {
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="px-2 py-2 border-t border-border-weak-base">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
|
||||
onClick={() => {
|
||||
if (selected()) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
layout.sidebar.open()
|
||||
navigateToProject(props.project.worktree)
|
||||
}}
|
||||
>
|
||||
View all sessions
|
||||
</Button>
|
||||
</div>
|
||||
<Show when={!selected()}>
|
||||
<div class="px-2 py-2 border-t border-border-weak-base">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
|
||||
onClick={() => {
|
||||
layout.sidebar.open()
|
||||
navigateToProject(props.project.worktree)
|
||||
}}
|
||||
>
|
||||
View all sessions
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</HoverCard>
|
||||
</div>
|
||||
@@ -1940,7 +1823,7 @@ export default function Layout(props: ParentProps) {
|
||||
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar"
|
||||
style={{ "overflow-anchor": "none" }}
|
||||
>
|
||||
<nav class="flex flex-col gap-1 px-2">
|
||||
<nav class="flex flex-col gap-2 px-2">
|
||||
<Show when={loading()}>
|
||||
<SessionSkeleton />
|
||||
</Show>
|
||||
@@ -1955,7 +1838,7 @@ export default function Layout(props: ParentProps) {
|
||||
size="large"
|
||||
onClick={(e: MouseEvent) => {
|
||||
loadMore()
|
||||
;(e.currentTarget as HTMLButtonElement).blur()
|
||||
; (e.currentTarget as HTMLButtonElement).blur()
|
||||
}}
|
||||
>
|
||||
Load more
|
||||
@@ -2001,17 +1884,6 @@ export default function Layout(props: ParentProps) {
|
||||
navigate(`/${base64Encode(created.directory)}/session`)
|
||||
}
|
||||
|
||||
command.register(() => [
|
||||
{
|
||||
id: "workspace.new",
|
||||
title: "New workspace",
|
||||
category: "Workspace",
|
||||
keybind: "mod+shift+w",
|
||||
disabled: !layout.sidebar.workspaces(project()?.worktree ?? "")(),
|
||||
onSelect: createWorkspace,
|
||||
},
|
||||
])
|
||||
|
||||
const homedir = createMemo(() => sync.data.path.home)
|
||||
|
||||
return (
|
||||
@@ -2052,8 +1924,8 @@ export default function Layout(props: ParentProps) {
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
<div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
|
||||
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Settings">
|
||||
<IconButton icon="settings-gear" variant="ghost" size="large" onClick={openSettings} />
|
||||
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Settings" class="hidden">
|
||||
<IconButton disabled icon="settings-gear" variant="ghost" size="large" />
|
||||
</Tooltip>
|
||||
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Help">
|
||||
<IconButton
|
||||
@@ -2137,19 +2009,17 @@ export default function Layout(props: ParentProps) {
|
||||
fallback={
|
||||
<>
|
||||
<div class="py-4 px-3">
|
||||
<TooltipKeybind title="New session" keybind={command.keybind("session.new")} placement="top">
|
||||
<Button
|
||||
size="large"
|
||||
icon="plus-small"
|
||||
class="w-full"
|
||||
onClick={() => {
|
||||
navigate(`/${base64Encode(p.worktree)}/session`)
|
||||
layout.mobileSidebar.hide()
|
||||
}}
|
||||
>
|
||||
New session
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
<Button
|
||||
size="large"
|
||||
icon="plus-small"
|
||||
class="w-full"
|
||||
onClick={() => {
|
||||
navigate(`/${base64Encode(p.worktree)}/session`)
|
||||
layout.mobileSidebar.hide()
|
||||
}}
|
||||
>
|
||||
New session
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<LocalWorkspace project={p} mobile={sidebarProps.mobile} />
|
||||
@@ -2159,15 +2029,9 @@ export default function Layout(props: ParentProps) {
|
||||
>
|
||||
<>
|
||||
<div class="py-4 px-3">
|
||||
<TooltipKeybind
|
||||
title="New workspace"
|
||||
keybind={command.keybind("workspace.new")}
|
||||
placement="top"
|
||||
>
|
||||
<Button size="large" icon="plus-small" class="w-full" onClick={createWorkspace}>
|
||||
New workspace
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
<Button size="large" icon="plus-small" class="w-full" onClick={createWorkspace}>
|
||||
New workspace
|
||||
</Button>
|
||||
</div>
|
||||
<div class="relative flex-1 min-h-0">
|
||||
<DragDropProvider
|
||||
@@ -2283,9 +2147,7 @@ export default function Layout(props: ParentProps) {
|
||||
"xl:border-l xl:rounded-tl-sm": !layout.sidebar.opened(),
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
{props.children}
|
||||
</main>
|
||||
</div>
|
||||
<Toast.Region />
|
||||
|
||||
@@ -385,19 +385,6 @@ export default function Page() {
|
||||
terminal.new()
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => terminal.all().length,
|
||||
(count, prevCount) => {
|
||||
if (prevCount !== undefined && prevCount > 0 && count === 0) {
|
||||
if (view().terminal.opened()) {
|
||||
view().terminal.toggle()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => visibleUserMessages().at(-1)?.id,
|
||||
@@ -469,7 +456,7 @@ export default function Page() {
|
||||
title: "New terminal",
|
||||
description: "Create a new terminal tab",
|
||||
category: "Terminal",
|
||||
keybind: "ctrl+alt+t",
|
||||
keybind: "ctrl+shift+`",
|
||||
onSelect: () => terminal.new(),
|
||||
},
|
||||
{
|
||||
@@ -583,7 +570,7 @@ export default function Page() {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (status()?.type !== "idle") {
|
||||
await sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
await sdk.client.session.abort({ sessionID }).catch(() => { })
|
||||
}
|
||||
const revert = info()?.revert?.messageID
|
||||
// Find the last user message that's not already reverted
|
||||
@@ -666,69 +653,69 @@ export default function Page() {
|
||||
},
|
||||
...(sync.data.config.share !== "disabled"
|
||||
? [
|
||||
{
|
||||
id: "session.share",
|
||||
title: "Share session",
|
||||
description: "Share this session and copy the URL to clipboard",
|
||||
category: "Session",
|
||||
slash: "share",
|
||||
disabled: !params.id || !!info()?.share?.url,
|
||||
onSelect: async () => {
|
||||
if (!params.id) return
|
||||
await sdk.client.session
|
||||
.share({ sessionID: params.id })
|
||||
.then((res) => {
|
||||
navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
|
||||
showToast({
|
||||
title: "Failed to copy URL to clipboard",
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
})
|
||||
.then(() =>
|
||||
{
|
||||
id: "session.share",
|
||||
title: "Share session",
|
||||
description: "Share this session and copy the URL to clipboard",
|
||||
category: "Session",
|
||||
slash: "share",
|
||||
disabled: !params.id || !!info()?.share?.url,
|
||||
onSelect: async () => {
|
||||
if (!params.id) return
|
||||
await sdk.client.session
|
||||
.share({ sessionID: params.id })
|
||||
.then((res) => {
|
||||
navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
|
||||
showToast({
|
||||
title: "Session shared",
|
||||
description: "Share URL copied to clipboard!",
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
showToast({
|
||||
title: "Failed to share session",
|
||||
description: "An error occurred while sharing the session",
|
||||
title: "Failed to copy URL to clipboard",
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: "Session shared",
|
||||
description: "Share URL copied to clipboard!",
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
showToast({
|
||||
title: "Failed to share session",
|
||||
description: "An error occurred while sharing the session",
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "session.unshare",
|
||||
title: "Unshare session",
|
||||
description: "Stop sharing this session",
|
||||
category: "Session",
|
||||
slash: "unshare",
|
||||
disabled: !params.id || !info()?.share?.url,
|
||||
onSelect: async () => {
|
||||
if (!params.id) return
|
||||
await sdk.client.session
|
||||
.unshare({ sessionID: params.id })
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: "Session unshared",
|
||||
description: "Session unshared successfully!",
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
showToast({
|
||||
title: "Failed to unshare session",
|
||||
description: "An error occurred while unsharing the session",
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "session.unshare",
|
||||
title: "Unshare session",
|
||||
description: "Stop sharing this session",
|
||||
category: "Session",
|
||||
slash: "unshare",
|
||||
disabled: !params.id || !info()?.share?.url,
|
||||
onSelect: async () => {
|
||||
if (!params.id) return
|
||||
await sdk.client.session
|
||||
.unshare({ sessionID: params.id })
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: "Session unshared",
|
||||
description: "Session unshared successfully!",
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
showToast({
|
||||
title: "Failed to unshare session",
|
||||
description: "An error occurred while unsharing the session",
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
: []),
|
||||
])
|
||||
|
||||
@@ -837,22 +824,10 @@ export default function Page() {
|
||||
})
|
||||
|
||||
const isWorking = createMemo(() => status().type !== "idle")
|
||||
|
||||
const autoScroll = createAutoScroll({
|
||||
working: () => true,
|
||||
working: isWorking,
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
isWorking,
|
||||
(working, prev) => {
|
||||
if (!working || prev) return
|
||||
autoScroll.forceScrollToBottom()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
let scrollSpyFrame: number | undefined
|
||||
let scrollSpyTarget: HTMLDivElement | undefined
|
||||
|
||||
@@ -1275,7 +1250,7 @@ export default function Page() {
|
||||
<Match when={true}>
|
||||
<div class="h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">No changes in this session yet</div>
|
||||
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -1290,7 +1265,7 @@ export default function Page() {
|
||||
if (isDesktop()) scheduleScrollSpy(e.currentTarget)
|
||||
}}
|
||||
onClick={autoScroll.handleInteraction}
|
||||
class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar"
|
||||
class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar snap-both snap-mandatory"
|
||||
style={{ "--session-title-height": info()?.title ? "40px" : "0px" }}
|
||||
>
|
||||
<Show when={info()?.title}>
|
||||
@@ -1357,14 +1332,17 @@ export default function Page() {
|
||||
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={anchor(message.id)}
|
||||
data-message-id={message.id}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"min-w-0 w-full max-w-full snap-both": true,
|
||||
"md:max-w-200": !showTabs(),
|
||||
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
|
||||
platform.platform !== "desktop",
|
||||
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
|
||||
platform.platform === "desktop",
|
||||
}}
|
||||
>
|
||||
<SessionTurn
|
||||
@@ -1547,7 +1525,7 @@ export default function Page() {
|
||||
<Match when={true}>
|
||||
<div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">No changes in this session yet</div>
|
||||
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -16,83 +16,6 @@ type PersistTarget = {
|
||||
|
||||
const LEGACY_STORAGE = "default.dat"
|
||||
const GLOBAL_STORAGE = "opencode.global.dat"
|
||||
const LOCAL_PREFIX = "opencode."
|
||||
const fallback = { disabled: false }
|
||||
const cache = new Map<string, string>()
|
||||
|
||||
function quota(error: unknown) {
|
||||
if (error instanceof DOMException) {
|
||||
if (error.name === "QuotaExceededError") return true
|
||||
if (error.name === "NS_ERROR_DOM_QUOTA_REACHED") return true
|
||||
if (error.name === "QUOTA_EXCEEDED_ERR") return true
|
||||
if (error.code === 22 || error.code === 1014) return true
|
||||
return false
|
||||
}
|
||||
|
||||
if (!error || typeof error !== "object") return false
|
||||
const name = (error as { name?: string }).name
|
||||
if (name === "QuotaExceededError" || name === "NS_ERROR_DOM_QUOTA_REACHED") return true
|
||||
if (name && /quota/i.test(name)) return true
|
||||
|
||||
const code = (error as { code?: number }).code
|
||||
if (code === 22 || code === 1014) return true
|
||||
|
||||
const message = (error as { message?: string }).message
|
||||
if (typeof message !== "string") return false
|
||||
if (/quota/i.test(message)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
type Evict = { key: string; size: number }
|
||||
|
||||
function evict(storage: Storage, keep: string, value: string) {
|
||||
const total = storage.length
|
||||
const indexes = Array.from({ length: total }, (_, index) => index)
|
||||
const items: Evict[] = []
|
||||
|
||||
for (const index of indexes) {
|
||||
const name = storage.key(index)
|
||||
if (!name) continue
|
||||
if (!name.startsWith(LOCAL_PREFIX)) continue
|
||||
if (name === keep) continue
|
||||
const stored = storage.getItem(name)
|
||||
items.push({ key: name, size: stored?.length ?? 0 })
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.size - a.size)
|
||||
|
||||
for (const item of items) {
|
||||
storage.removeItem(item.key)
|
||||
|
||||
try {
|
||||
storage.setItem(keep, value)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!quota(error)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function write(storage: Storage, key: string, value: string) {
|
||||
try {
|
||||
storage.setItem(key, value)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!quota(error)) throw error
|
||||
}
|
||||
|
||||
try {
|
||||
storage.removeItem(key)
|
||||
storage.setItem(key, value)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!quota(error)) throw error
|
||||
}
|
||||
|
||||
return evict(storage, key, value)
|
||||
}
|
||||
|
||||
function snapshot(value: unknown) {
|
||||
return JSON.parse(JSON.stringify(value)) as unknown
|
||||
@@ -144,66 +67,10 @@ function workspaceStorage(dir: string) {
|
||||
|
||||
function localStorageWithPrefix(prefix: string): SyncStorage {
|
||||
const base = `${prefix}:`
|
||||
const item = (key: string) => base + key
|
||||
return {
|
||||
getItem: (key) => {
|
||||
const name = item(key)
|
||||
const cached = cache.get(name)
|
||||
if (fallback.disabled && cached !== undefined) return cached
|
||||
|
||||
const stored = localStorage.getItem(name)
|
||||
if (stored === null) return cached ?? null
|
||||
cache.set(name, stored)
|
||||
return stored
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
const name = item(key)
|
||||
cache.set(name, value)
|
||||
if (fallback.disabled) return
|
||||
try {
|
||||
if (write(localStorage, name, value)) return
|
||||
} catch {
|
||||
fallback.disabled = true
|
||||
return
|
||||
}
|
||||
fallback.disabled = true
|
||||
},
|
||||
removeItem: (key) => {
|
||||
const name = item(key)
|
||||
cache.delete(name)
|
||||
if (fallback.disabled) return
|
||||
localStorage.removeItem(name)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function localStorageDirect(): SyncStorage {
|
||||
return {
|
||||
getItem: (key) => {
|
||||
const cached = cache.get(key)
|
||||
if (fallback.disabled && cached !== undefined) return cached
|
||||
|
||||
const stored = localStorage.getItem(key)
|
||||
if (stored === null) return cached ?? null
|
||||
cache.set(key, stored)
|
||||
return stored
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
cache.set(key, value)
|
||||
if (fallback.disabled) return
|
||||
try {
|
||||
if (write(localStorage, key, value)) return
|
||||
} catch {
|
||||
fallback.disabled = true
|
||||
return
|
||||
}
|
||||
fallback.disabled = true
|
||||
},
|
||||
removeItem: (key) => {
|
||||
cache.delete(key)
|
||||
if (fallback.disabled) return
|
||||
localStorage.removeItem(key)
|
||||
},
|
||||
getItem: (key) => localStorage.getItem(base + key),
|
||||
setItem: (key, value) => localStorage.setItem(base + key, value),
|
||||
removeItem: (key) => localStorage.removeItem(base + key),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +99,7 @@ export function removePersisted(target: { storage?: string; key: string }) {
|
||||
}
|
||||
|
||||
if (!target.storage) {
|
||||
localStorageDirect().removeItem(target.key)
|
||||
localStorage.removeItem(target.key)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -253,12 +120,12 @@ export function persisted<T>(
|
||||
|
||||
const currentStorage = (() => {
|
||||
if (isDesktop) return platform.storage?.(config.storage)
|
||||
if (!config.storage) return localStorageDirect()
|
||||
if (!config.storage) return localStorage
|
||||
return localStorageWithPrefix(config.storage)
|
||||
})()
|
||||
|
||||
const legacyStorage = (() => {
|
||||
if (!isDesktop) return localStorageDirect()
|
||||
if (!isDesktop) return localStorage
|
||||
if (!config.storage) return platform.storage?.()
|
||||
return platform.storage?.(LEGACY_STORAGE)
|
||||
})()
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import nope01 from "@opencode-ai/ui/audio/nope-01.aac"
|
||||
import nope02 from "@opencode-ai/ui/audio/nope-02.aac"
|
||||
import nope03 from "@opencode-ai/ui/audio/nope-03.aac"
|
||||
import nope04 from "@opencode-ai/ui/audio/nope-04.aac"
|
||||
import nope05 from "@opencode-ai/ui/audio/nope-05.aac"
|
||||
import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac"
|
||||
import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac"
|
||||
import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac"
|
||||
import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac"
|
||||
import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac"
|
||||
import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac"
|
||||
import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac"
|
||||
|
||||
export const SOUND_OPTIONS = [
|
||||
{ id: "staplebops-01", label: "Boopy", src: staplebops01 },
|
||||
{ id: "staplebops-02", label: "Beepy", src: staplebops02 },
|
||||
{ id: "staplebops-03", label: "Staplebops 03", src: staplebops03 },
|
||||
{ id: "staplebops-04", label: "Staplebops 04", src: staplebops04 },
|
||||
{ id: "staplebops-05", label: "Staplebops 05", src: staplebops05 },
|
||||
{ id: "staplebops-06", label: "Staplebops 06", src: staplebops06 },
|
||||
{ id: "staplebops-07", label: "Staplebops 07", src: staplebops07 },
|
||||
{ id: "nope-01", label: "Nope 01", src: nope01 },
|
||||
{ id: "nope-02", label: "Nope 02", src: nope02 },
|
||||
{ id: "nope-03", label: "Oopsie", src: nope03 },
|
||||
{ id: "nope-04", label: "Nope 04", src: nope04 },
|
||||
{ id: "nope-05", label: "Nope 05", src: nope05 },
|
||||
] as const
|
||||
|
||||
export type SoundOption = (typeof SOUND_OPTIONS)[number]
|
||||
export type SoundID = SoundOption["id"]
|
||||
|
||||
const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string>
|
||||
|
||||
export function soundSrc(id: string | undefined) {
|
||||
if (!id) return
|
||||
if (!(id in soundById)) return
|
||||
return soundById[id as SoundID]
|
||||
}
|
||||
|
||||
export function playSound(src: string | undefined) {
|
||||
if (typeof Audio === "undefined") return
|
||||
if (!src) return
|
||||
void new Audio(src).play().catch(() => undefined)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"url": "/",
|
||||
"decorations": true,
|
||||
"dragDropEnabled": false,
|
||||
"zoomHotkeysEnabled": false,
|
||||
"zoomHotkeysEnabled": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"trafficLightPosition": { "x": 12.0, "y": 18.0 }
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// @refresh reload
|
||||
import "./webview-zoom"
|
||||
import { render } from "solid-js/web"
|
||||
import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app"
|
||||
import { open, save } from "@tauri-apps/plugin-dialog"
|
||||
@@ -95,21 +94,6 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
|
||||
const apiCache = new Map<string, AsyncStorage & { flush: () => Promise<void> }>()
|
||||
const memoryCache = new Map<string, StoreLike>()
|
||||
|
||||
const flushAll = async () => {
|
||||
const apis = Array.from(apiCache.values())
|
||||
await Promise.all(apis.map((api) => api.flush().catch(() => undefined)))
|
||||
}
|
||||
|
||||
if ("addEventListener" in globalThis) {
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState !== "hidden") return
|
||||
void flushAll()
|
||||
}
|
||||
|
||||
window.addEventListener("pagehide", () => void flushAll())
|
||||
document.addEventListener("visibilitychange", handleVisibility)
|
||||
}
|
||||
|
||||
const createMemoryStore = () => {
|
||||
const data = new Map<string, string>()
|
||||
const store: StoreLike = {
|
||||
@@ -319,6 +303,11 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
|
||||
|
||||
createMenu()
|
||||
|
||||
// Stops mousewheel events from reaching Tauri's pinch-to-zoom handler
|
||||
root?.addEventListener("mousewheel", (e) => {
|
||||
e.stopPropagation()
|
||||
})
|
||||
|
||||
render(() => {
|
||||
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
|
||||
const platform = createPlatform(() => serverPassword())
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu"
|
||||
import { type as ostype } from "@tauri-apps/plugin-os"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { relaunch } from "@tauri-apps/plugin-process"
|
||||
|
||||
import { runUpdater, UPDATER_ENABLED } from "./updater"
|
||||
import { installCli } from "./cli"
|
||||
@@ -26,17 +24,6 @@ export async function createMenu() {
|
||||
action: () => installCli(),
|
||||
text: "Install CLI...",
|
||||
}),
|
||||
await MenuItem.new({
|
||||
action: async () => window.location.reload(),
|
||||
text: "Reload Webview",
|
||||
}),
|
||||
await MenuItem.new({
|
||||
action: async () => {
|
||||
await invoke("kill_sidecar").catch(() => undefined)
|
||||
await relaunch().catch(() => undefined)
|
||||
},
|
||||
text: "Restart",
|
||||
}),
|
||||
await PredefinedMenuItem.new({
|
||||
item: "Separator",
|
||||
}),
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { type as ostype } from "@tauri-apps/plugin-os"
|
||||
|
||||
const OS_NAME = ostype()
|
||||
|
||||
let zoomLevel = 1
|
||||
|
||||
const MAX_ZOOM_LEVEL = 10
|
||||
const MIN_ZOOM_LEVEL = 0.2
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (OS_NAME === "macos" ? event.metaKey : event.ctrlKey) {
|
||||
if (event.key === "-") {
|
||||
zoomLevel -= 0.2
|
||||
} else if (event.key === "=" || event.key === "+") {
|
||||
zoomLevel += 0.2
|
||||
} else if (event.key === "0") {
|
||||
zoomLevel = 1
|
||||
} else {
|
||||
return
|
||||
}
|
||||
zoomLevel = Math.min(Math.max(zoomLevel, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
|
||||
invoke("plugin:webview|set_webview_zoom", {
|
||||
value: zoomLevel,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.1.28"
|
||||
version = "1.1.26"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
type PermissionOption,
|
||||
type PlanEntry,
|
||||
type PromptRequest,
|
||||
type Role,
|
||||
type SetSessionModelRequest,
|
||||
type SetSessionModeRequest,
|
||||
type SetSessionModeResponse,
|
||||
@@ -688,8 +687,7 @@ export namespace ACP {
|
||||
break
|
||||
}
|
||||
} else if (part.type === "text") {
|
||||
if (part.text) {
|
||||
const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined
|
||||
if (part.text && !part.ignored) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
@@ -698,7 +696,6 @@ export namespace ACP {
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.text,
|
||||
...(audience && { annotations: { audience } }),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -971,20 +968,14 @@ export namespace ACP {
|
||||
const agent = session.modeId ?? (await AgentModule.defaultAgent())
|
||||
|
||||
const parts: Array<
|
||||
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
|
||||
| { type: "file"; url: string; filename: string; mime: string }
|
||||
{ type: "text"; text: string } | { type: "file"; url: string; filename: string; mime: string }
|
||||
> = []
|
||||
for (const part of params.prompt) {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
const audience = part.annotations?.audience
|
||||
const forAssistant = audience?.length === 1 && audience[0] === "assistant"
|
||||
const forUser = audience?.length === 1 && audience[0] === "user"
|
||||
parts.push({
|
||||
type: "text" as const,
|
||||
text: part.text,
|
||||
...(forAssistant && { synthetic: true }),
|
||||
...(forUser && { ignored: true }),
|
||||
})
|
||||
break
|
||||
case "image": {
|
||||
|
||||
@@ -134,7 +134,7 @@ const AgentCreateCommand = cmd({
|
||||
selectedTools = cliTools ? cliTools.split(",").map((t) => t.trim()) : AVAILABLE_TOOLS
|
||||
} else {
|
||||
const result = await prompts.multiselect({
|
||||
message: "Select tools to enable (Space to toggle)",
|
||||
message: "Select tools to enable",
|
||||
options: AVAILABLE_TOOLS.map((tool) => ({
|
||||
label: tool,
|
||||
value: tool,
|
||||
|
||||
@@ -546,22 +546,16 @@ export function Prompt(props: PromptProps) {
|
||||
} else if (
|
||||
inputText.startsWith("/") &&
|
||||
iife(() => {
|
||||
const firstLine = inputText.split("\n")[0]
|
||||
const command = firstLine.split(" ")[0].slice(1)
|
||||
const command = inputText.split(" ")[0].slice(1)
|
||||
console.log(command)
|
||||
return sync.data.command.some((x) => x.name === command)
|
||||
})
|
||||
) {
|
||||
// Parse command from first line, preserve multi-line content in arguments
|
||||
const firstLineEnd = inputText.indexOf("\n")
|
||||
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
|
||||
const [command, ...firstLineArgs] = firstLine.split(" ")
|
||||
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
|
||||
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
|
||||
|
||||
let [command, ...args] = inputText.split(" ")
|
||||
sdk.client.session.command({
|
||||
sessionID,
|
||||
command: command.slice(1),
|
||||
arguments: args,
|
||||
arguments: args.join(" "),
|
||||
agent: local.agent.current().name,
|
||||
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
|
||||
messageID,
|
||||
|
||||
@@ -73,7 +73,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
width={42}
|
||||
height="100%"
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
|
||||
@@ -12,13 +12,7 @@ import { lazy } from "../util/lazy"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Auth } from "../auth"
|
||||
import {
|
||||
type ParseError as JsoncParseError,
|
||||
applyEdits,
|
||||
modify,
|
||||
parse as parseJsonc,
|
||||
printParseErrorCode,
|
||||
} from "jsonc-parser"
|
||||
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
|
||||
import { Instance } from "../project/instance"
|
||||
import { LSPServer } from "../lsp/server"
|
||||
import { BunProc } from "@/bun"
|
||||
@@ -26,8 +20,6 @@ import { Installation } from "@/installation"
|
||||
import { ConfigMarkdown } from "./markdown"
|
||||
import { existsSync } from "fs"
|
||||
import { Bus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Event } from "../server/event"
|
||||
|
||||
export namespace Config {
|
||||
const log = Log.create({ service: "config" })
|
||||
@@ -1250,10 +1242,6 @@ export namespace Config {
|
||||
return state().then((x) => x.config)
|
||||
}
|
||||
|
||||
export async function getGlobal() {
|
||||
return global()
|
||||
}
|
||||
|
||||
export async function update(config: Info) {
|
||||
const filepath = path.join(Instance.directory, "config.json")
|
||||
const existing = await loadFile(filepath)
|
||||
@@ -1261,100 +1249,6 @@ export namespace Config {
|
||||
await Instance.dispose()
|
||||
}
|
||||
|
||||
function globalConfigFile() {
|
||||
const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) =>
|
||||
path.join(Global.Path.config, file),
|
||||
)
|
||||
for (const file of candidates) {
|
||||
if (existsSync(file)) return file
|
||||
}
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
|
||||
if (!isRecord(patch)) {
|
||||
const edits = modify(input, path, patch, {
|
||||
formattingOptions: {
|
||||
insertSpaces: true,
|
||||
tabSize: 2,
|
||||
},
|
||||
})
|
||||
return applyEdits(input, edits)
|
||||
}
|
||||
|
||||
return Object.entries(patch).reduce((result, [key, value]) => {
|
||||
if (value === undefined) return result
|
||||
return patchJsonc(result, value, [...path, key])
|
||||
}, input)
|
||||
}
|
||||
|
||||
function parseConfig(text: string, filepath: string): Info {
|
||||
const errors: JsoncParseError[] = []
|
||||
const data = parseJsonc(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) {
|
||||
const lines = text.split("\n")
|
||||
const errorDetails = errors
|
||||
.map((e) => {
|
||||
const beforeOffset = text.substring(0, e.offset).split("\n")
|
||||
const line = beforeOffset.length
|
||||
const column = beforeOffset[beforeOffset.length - 1].length + 1
|
||||
const problemLine = lines[line - 1]
|
||||
|
||||
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
|
||||
if (!problemLine) return error
|
||||
|
||||
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
throw new JsonError({
|
||||
path: filepath,
|
||||
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = Info.safeParse(data)
|
||||
if (parsed.success) return parsed.data
|
||||
|
||||
throw new InvalidError({
|
||||
path: filepath,
|
||||
issues: parsed.error.issues,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateGlobal(config: Info) {
|
||||
const filepath = globalConfigFile()
|
||||
const before = await Bun.file(filepath)
|
||||
.text()
|
||||
.catch((err) => {
|
||||
if (err.code === "ENOENT") return "{}"
|
||||
throw new JsonError({ path: filepath }, { cause: err })
|
||||
})
|
||||
|
||||
if (!filepath.endsWith(".jsonc")) {
|
||||
const existing = parseConfig(before, filepath)
|
||||
await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2))
|
||||
} else {
|
||||
const next = patchJsonc(before, config)
|
||||
parseConfig(next, filepath)
|
||||
await Bun.write(filepath, next)
|
||||
}
|
||||
|
||||
global.reset()
|
||||
await Instance.disposeAll()
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Event.Disposed.type,
|
||||
properties: {},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function directories() {
|
||||
return state().then((x) => x.directories)
|
||||
}
|
||||
|
||||
@@ -32,16 +32,11 @@ export namespace FileWatcher {
|
||||
),
|
||||
}
|
||||
|
||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||
try {
|
||||
const binding = require(
|
||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
||||
)
|
||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||
} catch (error) {
|
||||
log.error("failed to load watcher binding", { error })
|
||||
return
|
||||
}
|
||||
const watcher = lazy(() => {
|
||||
const binding = require(
|
||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
||||
)
|
||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||
})
|
||||
|
||||
const state = Instance.state(
|
||||
@@ -59,10 +54,6 @@ export namespace FileWatcher {
|
||||
return {}
|
||||
}
|
||||
log.info("watcher backend", { platform: process.platform, backend })
|
||||
|
||||
const w = watcher()
|
||||
if (!w) return {}
|
||||
|
||||
const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => {
|
||||
if (err) return
|
||||
for (const evt of evts) {
|
||||
@@ -76,7 +67,7 @@ export namespace FileWatcher {
|
||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||
const pending = w.subscribe(Instance.directory, subscribe, {
|
||||
const pending = watcher().subscribe(Instance.directory, subscribe, {
|
||||
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
|
||||
backend,
|
||||
})
|
||||
@@ -98,7 +89,7 @@ export namespace FileWatcher {
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
const gitDirContents = await readdir(vcsDir).catch(() => [])
|
||||
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")
|
||||
const pending = w.subscribe(vcsDir, subscribe, {
|
||||
const pending = watcher().subscribe(vcsDir, subscribe, {
|
||||
ignore: ignoreList,
|
||||
backend,
|
||||
})
|
||||
|
||||
@@ -795,11 +795,6 @@ export namespace MCP {
|
||||
// The SDK has already added the state parameter to the authorization URL
|
||||
// We just need to open the browser
|
||||
log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
|
||||
|
||||
// Register the callback BEFORE opening the browser to avoid race condition
|
||||
// when the IdP has an active SSO session and redirects immediately
|
||||
const callbackPromise = McpOAuthCallback.waitForCallback(oauthState)
|
||||
|
||||
try {
|
||||
const subprocess = await open(authorizationUrl)
|
||||
// The open package spawns a detached process and returns immediately.
|
||||
@@ -827,8 +822,8 @@ export namespace MCP {
|
||||
Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl })
|
||||
}
|
||||
|
||||
// Wait for callback using the already-registered promise
|
||||
const code = await callbackPromise
|
||||
// Wait for callback using the OAuth state parameter
|
||||
const code = await McpOAuthCallback.waitForCallback(oauthState)
|
||||
|
||||
// Validate and clear the state
|
||||
const storedState = await McpAuth.getOAuthState(mcpName)
|
||||
|
||||
@@ -147,9 +147,6 @@ export namespace Pty {
|
||||
log.info("session exited", { id, exitCode })
|
||||
session.info.status = "exited"
|
||||
Bus.publish(Event.Exited, { id, exitCode })
|
||||
for (const ws of session.subscribers) {
|
||||
ws.close()
|
||||
}
|
||||
state().delete(id)
|
||||
})
|
||||
Bus.publish(Event.Created, { info })
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import z from "zod"
|
||||
|
||||
export const Event = {
|
||||
Connected: BusEvent.define("server.connected", z.object({})),
|
||||
Disposed: BusEvent.define("global.disposed", z.object({})),
|
||||
}
|
||||
@@ -54,6 +54,11 @@ export namespace Server {
|
||||
return _url ?? new URL("http://localhost:4096")
|
||||
}
|
||||
|
||||
export const Event = {
|
||||
Connected: BusEvent.define("server.connected", z.object({})),
|
||||
Disposed: BusEvent.define("global.disposed", z.object({})),
|
||||
}
|
||||
|
||||
const app = new Hono()
|
||||
export const App: () => Hono = lazy(
|
||||
() =>
|
||||
|
||||
@@ -176,7 +176,7 @@ export namespace SessionProcessor {
|
||||
...match,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: value.input ?? match.state.input,
|
||||
input: value.input,
|
||||
output: value.output.output,
|
||||
metadata: value.output.metadata,
|
||||
title: value.output.title,
|
||||
@@ -200,7 +200,7 @@ export namespace SessionProcessor {
|
||||
...match,
|
||||
state: {
|
||||
status: "error",
|
||||
input: value.input ?? match.state.input,
|
||||
input: value.input,
|
||||
error: (value.error as any).toString(),
|
||||
time: {
|
||||
start: match.state.time.start,
|
||||
|
||||
@@ -44,7 +44,6 @@ import { SessionStatus } from "./status"
|
||||
import { LLM } from "./llm"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Truncate } from "@/tool/truncation"
|
||||
|
||||
// @ts-ignore
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
@@ -802,17 +801,10 @@ export namespace SessionPrompt {
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = await Truncate.output(textParts.join("\n\n"), {}, input.agent)
|
||||
const metadata = {
|
||||
...(result.metadata ?? {}),
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||
}
|
||||
|
||||
return {
|
||||
title: "",
|
||||
metadata,
|
||||
output: truncated.content,
|
||||
metadata: result.metadata ?? {},
|
||||
output: textParts.join("\n\n"),
|
||||
attachments,
|
||||
content: result.content, // directly return content to preserve ordering when outputting to model
|
||||
}
|
||||
@@ -1627,15 +1619,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
if (position === last) return args.slice(argIndex).join(" ")
|
||||
return args[argIndex]
|
||||
})
|
||||
const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS")
|
||||
let template = withArgs.replaceAll("$ARGUMENTS", input.arguments)
|
||||
|
||||
// If command doesn't explicitly handle arguments (no $N or $ARGUMENTS placeholders)
|
||||
// but user provided arguments, append them to the template
|
||||
if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) {
|
||||
template = template + "\n\n" + input.arguments
|
||||
}
|
||||
|
||||
const shell = ConfigMarkdown.shell(template)
|
||||
if (shell.length > 0) {
|
||||
const results = await Promise.all(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
You are OpenCode, the best coding agent on the planet.
|
||||
|
||||
You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
|
||||
## Editing constraints
|
||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||
- Only add comments if they are necessary to make a non-obvious block easier to understand.
|
||||
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
|
||||
|
||||
## Tool usage
|
||||
- Prefer specialized tools over shell for file operations:
|
||||
- Use Read to view files, Edit to modify files, and Write only when needed.
|
||||
- Use Glob to find files by name and Grep to search file contents.
|
||||
- Use Bash for terminal operations (git, bun, builds, tests, running scripts).
|
||||
- Run tool calls in parallel when neither call needs the other’s output; otherwise run sequentially.
|
||||
|
||||
## Git and workspace hygiene
|
||||
- You may be in a dirty git worktree.
|
||||
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
|
||||
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
|
||||
* If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
|
||||
* If the changes are in unrelated files, just ignore them and don't revert them.
|
||||
- Do not amend commits unless explicitly requested.
|
||||
- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.
|
||||
|
||||
## Frontend tasks
|
||||
When doing frontend design tasks, avoid collapsing into bland, generic layouts.
|
||||
Aim for interfaces that feel intentional and deliberate.
|
||||
- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).
|
||||
- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.
|
||||
- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.
|
||||
- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.
|
||||
- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.
|
||||
- Ensure the page loads properly on both desktop and mobile.
|
||||
|
||||
Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language.
|
||||
|
||||
## Presenting your work and final message
|
||||
|
||||
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
|
||||
|
||||
- Default: be very concise; friendly coding teammate tone.
|
||||
- Ask only when needed; suggest ideas; mirror the user's style.
|
||||
- For substantial work, summarize clearly; follow final‑answer formatting.
|
||||
- Skip heavy formatting for simple confirmations.
|
||||
- Don't dump large files you've written; reference paths only.
|
||||
- No "save/copy this file" - User is on the same machine.
|
||||
- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.
|
||||
- For code changes:
|
||||
* Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in.
|
||||
* If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.
|
||||
* When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.
|
||||
- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.
|
||||
|
||||
## Final answer structure and style guidelines
|
||||
|
||||
- Plain text; CLI handles styling. Use structure only when it helps scanability.
|
||||
- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
|
||||
- Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent.
|
||||
- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
|
||||
- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
|
||||
- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
|
||||
- Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no "above/below"; parallel wording.
|
||||
- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.
|
||||
- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.
|
||||
- File References: When referencing files in your response follow the below rules:
|
||||
* Use inline code to make file paths clickable.
|
||||
* Each reference should have a stand alone path. Even if it's the same file.
|
||||
* Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.
|
||||
* Optionally include line/column (1‑based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
|
||||
* Do not use URIs like file://, vscode://, or https://.
|
||||
* Do not provide range of lines
|
||||
* Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5
|
||||
@@ -5,7 +5,6 @@ You are an interactive CLI tool that helps users with software engineering tasks
|
||||
## Editing constraints
|
||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||
- Only add comments if they are necessary to make a non-obvious block easier to understand.
|
||||
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
|
||||
|
||||
## Tool usage
|
||||
- Prefer specialized tools over shell for file operations:
|
||||
@@ -40,13 +39,7 @@ Exception: If working within an existing website or design system, preserve the
|
||||
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
|
||||
|
||||
- Default: be very concise; friendly coding teammate tone.
|
||||
- Default: do the work without asking questions. Treat short tasks as sufficient direction; infer missing details by reading the codebase and following existing conventions.
|
||||
- Questions: only ask when you are truly blocked after checking relevant context AND you cannot safely pick a reasonable default. This usually means one of:
|
||||
* The request is ambiguous in a way that materially changes the result and you cannot disambiguate by reading the repo.
|
||||
* The action is destructive/irreversible, touches production, or changes billing/security posture.
|
||||
* You need a secret/credential/value that cannot be inferred (API key, account id, etc.).
|
||||
- If you must ask: do all non-blocked work first, then ask exactly one targeted question, include your recommended default, and state what would change based on the answer.
|
||||
- Never ask permission questions like "Should I proceed?" or "Do you want me to run tests?"; proceed with the most reasonable option and mention what you did.
|
||||
- Ask only when needed; suggest ideas; mirror the user's style.
|
||||
- For substantial work, summarize clearly; follow final‑answer formatting.
|
||||
- Skip heavy formatting for simple confirmations.
|
||||
- Don't dump large files you've written; reference paths only.
|
||||
|
||||
@@ -13,7 +13,8 @@ import PROMPT_BEAST from "./prompt/beast.txt"
|
||||
import PROMPT_GEMINI from "./prompt/gemini.txt"
|
||||
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
|
||||
|
||||
import PROMPT_CODEX from "./prompt/codex_header.txt"
|
||||
import PROMPT_CODEX from "./prompt/codex.txt"
|
||||
import PROMPT_CODEX_INSTRUCTIONS from "./prompt/codex_header.txt"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { Flag } from "@/flag/flag"
|
||||
|
||||
@@ -24,7 +25,7 @@ export namespace SystemPrompt {
|
||||
}
|
||||
|
||||
export function instructions() {
|
||||
return PROMPT_CODEX.trim()
|
||||
return PROMPT_CODEX_INSTRUCTIONS.trim()
|
||||
}
|
||||
|
||||
export function provider(model: Provider.Model) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import z from "zod"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { Tool } from "./tool"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Bus } from "../bus"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Instance } from "../project/instance"
|
||||
@@ -95,6 +96,8 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
|
||||
throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`)
|
||||
}
|
||||
|
||||
// Read file and update time tracking (like edit tool does)
|
||||
await FileTime.assert(ctx.sessionID, filePath)
|
||||
const oldContent = await fs.readFile(filePath, "utf-8")
|
||||
let newContent = oldContent
|
||||
|
||||
@@ -199,6 +202,12 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
|
||||
changedFiles.push(change.filePath)
|
||||
break
|
||||
}
|
||||
|
||||
// Update file time tracking
|
||||
FileTime.read(ctx.sessionID, change.filePath)
|
||||
if (change.movePath) {
|
||||
FileTime.read(ctx.sessionID, change.movePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish file change events
|
||||
|
||||
@@ -6,7 +6,7 @@ Payload Format (JSON array):
|
||||
[{"tool": "read", "parameters": {"filePath": "src/index.ts", "limit": 350}},{"tool": "grep", "parameters": {"pattern": "Session\\.updatePart", "include": "src/**/*.ts"}},{"tool": "bash", "parameters": {"command": "git status", "description": "Shows working tree status"}}]
|
||||
|
||||
Notes:
|
||||
- 1–25 tool calls per batch
|
||||
- 1–20 tool calls per batch
|
||||
- All calls start in parallel; ordering NOT guaranteed
|
||||
- Partial failures do not stop other tool calls
|
||||
- Do NOT use the batch tool within another batch tool.
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { ApplyPatchTool } from "../../src/tool/apply_patch"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const baseCtx = {
|
||||
@@ -70,6 +71,8 @@ describe("tool.apply_patch freeform", () => {
|
||||
const deletePath = path.join(fixture.path, "delete.txt")
|
||||
await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8")
|
||||
await fs.writeFile(deletePath, "obsolete\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, modifyPath)
|
||||
FileTime.read(ctx.sessionID, deletePath)
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||
@@ -98,6 +101,7 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "multi.txt")
|
||||
await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
|
||||
@@ -118,6 +122,7 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "insert_only.txt")
|
||||
await fs.writeFile(target, "alpha\nomega\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
|
||||
|
||||
@@ -137,6 +142,7 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "no_newline.txt")
|
||||
await fs.writeFile(target, "no newline at end", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"
|
||||
@@ -160,6 +166,7 @@ describe("tool.apply_patch freeform", () => {
|
||||
const original = path.join(fixture.path, "old", "name.txt")
|
||||
await fs.mkdir(path.dirname(original), { recursive: true })
|
||||
await fs.writeFile(original, "old content\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, original)
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
|
||||
@@ -186,6 +193,7 @@ describe("tool.apply_patch freeform", () => {
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true })
|
||||
await fs.writeFile(original, "from\n", "utf-8")
|
||||
await fs.writeFile(destination, "existing\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, original)
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"
|
||||
@@ -286,6 +294,7 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "modify.txt")
|
||||
await fs.writeFile(target, "line1\nline2\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
|
||||
|
||||
@@ -322,6 +331,7 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "tail.txt")
|
||||
await fs.writeFile(target, "alpha\nlast\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"
|
||||
|
||||
@@ -340,6 +350,7 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "two_chunks.txt")
|
||||
await fs.writeFile(target, "a\nb\nc\nd\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"
|
||||
|
||||
@@ -358,6 +369,7 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "multi_ctx.txt")
|
||||
await fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"
|
||||
|
||||
@@ -377,6 +389,7 @@ describe("tool.apply_patch freeform", () => {
|
||||
const target = path.join(fixture.path, "eof_anchor.txt")
|
||||
// File has duplicate "marker" lines - one in middle, one at end
|
||||
await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
// With EOF anchor, should match the LAST "marker" line, not the first
|
||||
const patchText =
|
||||
@@ -441,6 +454,7 @@ EOF`
|
||||
const target = path.join(fixture.path, "trailing_ws.txt")
|
||||
// File has trailing spaces on some lines
|
||||
await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
// Patch doesn't have trailing spaces - should still match via rstrip pass
|
||||
const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||
@@ -461,6 +475,7 @@ EOF`
|
||||
const target = path.join(fixture.path, "leading_ws.txt")
|
||||
// File has leading spaces
|
||||
await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
// Patch without leading spaces - should match via trim pass
|
||||
const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||
@@ -484,6 +499,7 @@ EOF`
|
||||
const rightQuote = "\u201D"
|
||||
const emDash = "\u2014"
|
||||
await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
// Patch uses ASCII equivalents - should match via normalized pass
|
||||
// The replacement uses ASCII quotes from the patch (not preserving Unicode)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -25,4 +25,4 @@
|
||||
"typescript": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
"name": "@opencode-ai/script",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:"
|
||||
"@types/bun": "catalog:"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import { satisfies } from "semver"
|
||||
|
||||
const rootPkgPath = path.resolve(import.meta.dir, "../../../package.json")
|
||||
const rootPkg = await Bun.file(rootPkgPath).json()
|
||||
@@ -10,11 +9,8 @@ if (!expectedBunVersion) {
|
||||
throw new Error("packageManager field not found in root package.json")
|
||||
}
|
||||
|
||||
// relax version requirement
|
||||
const expectedBunVersionRange = `^${expectedBunVersion}`
|
||||
|
||||
if (!satisfies(process.versions.bun, expectedBunVersionRange)) {
|
||||
throw new Error(`This script requires bun@${expectedBunVersionRange}, but you are using bun@${process.versions.bun}`)
|
||||
if (process.versions.bun !== expectedBunVersion) {
|
||||
throw new Error(`This script requires bun@${expectedBunVersion}, but you are using bun@${process.versions.bun}`)
|
||||
}
|
||||
|
||||
const env = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -30,4 +30,4 @@
|
||||
"publishConfig": {
|
||||
"directory": "dist"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,20 +48,6 @@ export type EventServerInstanceDisposed = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventServerConnected = {
|
||||
type: "server.connected"
|
||||
properties: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type EventGlobalDisposed = {
|
||||
type: "global.disposed"
|
||||
properties: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type EventLspClientDiagnostics = {
|
||||
type: "lsp.client.diagnostics"
|
||||
properties: {
|
||||
@@ -860,13 +846,25 @@ export type EventPtyDeleted = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventGlobalDisposed = {
|
||||
type: "global.disposed"
|
||||
properties: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type EventServerConnected = {
|
||||
type: "server.connected"
|
||||
properties: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type Event =
|
||||
| EventInstallationUpdated
|
||||
| EventInstallationUpdateAvailable
|
||||
| EventProjectUpdated
|
||||
| EventServerInstanceDisposed
|
||||
| EventServerConnected
|
||||
| EventGlobalDisposed
|
||||
| EventLspClientDiagnostics
|
||||
| EventLspUpdated
|
||||
| EventFileEdited
|
||||
@@ -901,6 +899,8 @@ export type Event =
|
||||
| EventPtyUpdated
|
||||
| EventPtyExited
|
||||
| EventPtyDeleted
|
||||
| EventGlobalDisposed
|
||||
| EventServerConnected
|
||||
|
||||
export type GlobalEvent = {
|
||||
directory: string
|
||||
|
||||
+34
-34
@@ -5863,34 +5863,6 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.server.connected": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "server.connected"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.global.disposed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "global.disposed"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.lsp.client.diagnostics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8103,6 +8075,34 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.global.disposed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "global.disposed"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.server.connected": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "server.connected"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -8117,12 +8117,6 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.server.instance.disposed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.server.connected"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.global.disposed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.lsp.client.diagnostics"
|
||||
},
|
||||
@@ -8224,6 +8218,12 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.pty.deleted"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.global.disposed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.server.connected"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.28",
|
||||
"version": "1.1.26",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
gap: 0px;
|
||||
align-self: stretch;
|
||||
|
||||
[data-slot="accordion-item"] {
|
||||
@@ -10,6 +10,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0px;
|
||||
align-self: stretch;
|
||||
overflow: clip;
|
||||
|
||||
@@ -33,7 +34,6 @@
|
||||
|
||||
background-color: var(--surface-base);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: clip;
|
||||
color: var(--text-strong);
|
||||
transition: background-color 0.15s ease;
|
||||
@@ -59,9 +59,12 @@
|
||||
}
|
||||
|
||||
&[data-expanded] {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
[data-slot="accordion-trigger"] {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-left-radius: var(--radius-md);
|
||||
border-top-right-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
[data-slot="accordion-content"] {
|
||||
@@ -70,11 +73,68 @@
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
& + [data-slot="accordion-item"] {
|
||||
margin-top: 8px;
|
||||
|
||||
[data-slot="accordion-trigger"] {
|
||||
border-top-left-radius: var(--radius-md);
|
||||
border-top-right-radius: var(--radius-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:has(+ [data-slot="accordion-item"][data-expanded]) {
|
||||
margin-bottom: 8px;
|
||||
|
||||
&[data-closed] {
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
|
||||
[data-slot="accordion-trigger"] {
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-closed] + &[data-closed] {
|
||||
[data-slot="accordion-trigger"] {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0px;
|
||||
|
||||
&[data-closed] {
|
||||
[data-slot="accordion-trigger"] {
|
||||
border-top-left-radius: var(--radius-md);
|
||||
border-top-right-radius: var(--radius-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0px;
|
||||
|
||||
&[data-closed] {
|
||||
[data-slot="accordion-trigger"] {
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="accordion-content"] {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
/* animation: slideUp 250ms cubic-bezier(0.87, 0, 0.13, 1); */
|
||||
/**/
|
||||
/* &[data-expanded] { */
|
||||
/* animation: slideDown 250ms cubic-bezier(0.87, 0, 0.13, 1); */
|
||||
/* } */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,15 @@
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
padding: 4px 8px;
|
||||
white-space: nowrap;
|
||||
transition-property: background-color, border-color, color, box-shadow;
|
||||
transition-duration: 200ms;
|
||||
transition-timing-function: cubic-bezier(0.25, 0, 0.5, 1);
|
||||
|
||||
&[data-variant="primary"] {
|
||||
background-color: var(--icon-strong-base);
|
||||
border-color: var(--border-weak-base);
|
||||
border-color: var(--border-weaker-base);
|
||||
color: var(--icon-invert-base);
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
@@ -102,19 +106,15 @@
|
||||
}
|
||||
|
||||
&[data-size="small"] {
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
padding: 2px 10px;
|
||||
&[data-icon] {
|
||||
padding: 0 12px 0 4px;
|
||||
}
|
||||
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
gap: 4px;
|
||||
|
||||
/* text-12-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-size: var(--font-size-base);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
@@ -163,3 +163,39 @@
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="cycle-text"] {
|
||||
display: inline-flex;
|
||||
perspective: 400px;
|
||||
overflow: hidden;
|
||||
|
||||
[data-slot="char"] {
|
||||
display: inline-block;
|
||||
transform-style: preserve-3d;
|
||||
transform-origin: 50% 100%;
|
||||
transform: rotateX(0deg);
|
||||
opacity: 1;
|
||||
transition:
|
||||
transform 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-delay: calc(var(--i, 0) * 40ms);
|
||||
|
||||
/* Entry animation using @starting-style */
|
||||
@starting-style {
|
||||
transform: rotateX(90deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Exit animation when animating */
|
||||
&[data-animating="true"] [data-slot="char"] {
|
||||
transform: rotateX(-90deg);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Preserve spaces */
|
||||
[data-slot="space"] {
|
||||
display: inline-block;
|
||||
width: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-items: start;
|
||||
overflow: visible;
|
||||
|
||||
[data-slot="dialog-content"] {
|
||||
display: flex;
|
||||
@@ -40,21 +39,18 @@
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
min-height: 280px;
|
||||
overflow: auto;
|
||||
|
||||
/* Hide scrollbar */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* padding: 8px; */
|
||||
/* padding: 8px 8px 0 8px; */
|
||||
border: 1px solid
|
||||
light-dark(
|
||||
color-mix(in oklch, var(--border-base) 30%, transparent),
|
||||
color-mix(in oklch, var(--border-base) 50%, transparent)
|
||||
);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
background-clip: padding-box;
|
||||
box-shadow: var(--shadow-lg-border-base);
|
||||
box-shadow: var(--shadow-lg);
|
||||
|
||||
/* animation: contentHide 300ms ease-in forwards; */
|
||||
/**/
|
||||
@@ -112,7 +108,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 12px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
@@ -133,16 +130,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-size="large"] [data-slot="dialog-container"] {
|
||||
width: min(calc(100vw - 32px), 800px);
|
||||
height: min(calc(100vh - 32px), 600px);
|
||||
}
|
||||
|
||||
&[data-size="x-large"] [data-slot="dialog-container"] {
|
||||
width: min(calc(100vw - 32px), 960px);
|
||||
height: min(calc(100vh - 32px), 600px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes overlayShow {
|
||||
|
||||
@@ -6,7 +6,6 @@ export interface DialogProps extends ParentProps {
|
||||
title?: JSXElement
|
||||
description?: JSXElement
|
||||
action?: JSXElement
|
||||
size?: "normal" | "large" | "x-large"
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
fit?: boolean
|
||||
@@ -14,11 +13,10 @@ export interface DialogProps extends ParentProps {
|
||||
|
||||
export function Dialog(props: DialogProps) {
|
||||
return (
|
||||
<div data-component="dialog" data-fit={props.fit ? true : undefined} data-size={props.size || "normal"}>
|
||||
<div data-component="dialog" data-fit={props.fit ? true : undefined}>
|
||||
<div data-slot="dialog-container">
|
||||
<Kobalte.Content
|
||||
data-slot="dialog-content"
|
||||
data-no-header={!props.title && !props.action ? "" : undefined}
|
||||
classList={{
|
||||
...(props.classList ?? {}),
|
||||
[props.class ?? ""]: !!props.class,
|
||||
|
||||
@@ -2,26 +2,24 @@
|
||||
[data-component="dropdown-menu-sub-content"] {
|
||||
min-width: 8rem;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
|
||||
box-shadow: var(--shadow-xs-border);
|
||||
background-clip: padding-box;
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
padding: 4px;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 50;
|
||||
z-index: 100;
|
||||
transform-origin: var(--kb-menu-content-transform-origin);
|
||||
|
||||
animation: dropdownMenuContentHide 200ms cubic-bezier(0.25, 0, 0.5, 1) forwards;
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&[data-closed] {
|
||||
animation: dropdown-menu-close 0.15s ease-out;
|
||||
@starting-style {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
&[data-expanded] {
|
||||
animation: dropdown-menu-open 0.15s ease-out;
|
||||
pointer-events: auto;
|
||||
animation: dropdownMenuContentShow 200ms cubic-bezier(0.25, 0, 0.5, 1) forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,18 +36,23 @@
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-strong);
|
||||
|
||||
&[data-highlighted] {
|
||||
background: var(--surface-raised-base-hover);
|
||||
transition:
|
||||
background-color 200ms cubic-bezier(0.25, 0, 0.5, 1),
|
||||
color 200ms cubic-bezier(0.25, 0, 0.5, 1);
|
||||
outline: none;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
@@ -61,6 +64,8 @@
|
||||
[data-slot="dropdown-menu-sub-trigger"] {
|
||||
&[data-expanded] {
|
||||
background: var(--surface-raised-base-hover);
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,24 +107,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdown-menu-open {
|
||||
@keyframes dropdownMenuContentShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
transform: scaleY(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdown-menu-close {
|
||||
@keyframes dropdownMenuContentHide {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transform: scaleY(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
transform: scaleY(0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,6 @@
|
||||
|
||||
&[data-variant="ghost"] {
|
||||
background-color: transparent;
|
||||
/* color: var(--icon-base); */
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-base);
|
||||
@@ -85,25 +84,41 @@
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
|
||||
/* [data-slot="icon-svg"] { */
|
||||
/* color: var(--icon-hover); */
|
||||
/* } */
|
||||
}
|
||||
&:focus:not(:disabled) {
|
||||
background-color: var(--surface-focus);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
background-color: var(--surface-raised-base-active);
|
||||
/* [data-slot="icon-svg"] { */
|
||||
/* color: var(--icon-active); */
|
||||
/* } */
|
||||
}
|
||||
&:selected:not(:disabled) {
|
||||
background-color: var(--surface-raised-base-active);
|
||||
/* [data-slot="icon-svg"] { */
|
||||
/* color: var(--icon-selected); */
|
||||
/* } */
|
||||
}
|
||||
}
|
||||
|
||||
&[data-variant="weak"] {
|
||||
opacity: 0.8;
|
||||
background-color: transparent;
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-base);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 1;
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
}
|
||||
&:focus:not(:disabled) {
|
||||
opacity: 1;
|
||||
background-color: var(--surface-focus);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
opacity: 1;
|
||||
background-color: var(--surface-raised-base-active);
|
||||
}
|
||||
&:selected:not(:disabled) {
|
||||
opacity: 1;
|
||||
background-color: var(--surface-raised-base-active);
|
||||
}
|
||||
&:disabled {
|
||||
color: var(--icon-invert-base);
|
||||
@@ -125,6 +140,11 @@
|
||||
/* padding: 0 8px 0 6px; */
|
||||
gap: 8px;
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
/* text-12-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface IconButtonProps extends ComponentProps<typeof Kobalte> {
|
||||
icon: IconProps["name"]
|
||||
size?: "normal" | "large"
|
||||
iconSize?: IconProps["size"]
|
||||
variant?: "primary" | "secondary" | "ghost"
|
||||
variant?: "primary" | "secondary" | "ghost" | "weak"
|
||||
}
|
||||
|
||||
export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {
|
||||
|
||||
@@ -8,7 +8,7 @@ const icons = {
|
||||
"bubble-5": `<path d="M18.3327 9.99935C18.3327 5.57227 15.0919 2.91602 9.99935 2.91602C4.90676 2.91602 1.66602 5.57227 1.66602 9.99935C1.66602 11.1487 2.45505 13.1006 2.57637 13.3939C2.58707 13.4197 2.59766 13.4434 2.60729 13.4697C2.69121 13.6987 3.04209 14.9354 1.66602 16.7674C3.51787 17.6528 5.48453 16.1973 5.48453 16.1973C6.84518 16.9193 8.46417 17.0827 9.99935 17.0827C15.0919 17.0827 18.3327 14.4264 18.3327 9.99935Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
brain: `<path d="M13.332 8.7487C11.4911 8.7487 9.9987 7.25631 9.9987 5.41536M6.66536 11.2487C8.50631 11.2487 9.9987 12.7411 9.9987 14.582M9.9987 2.78209L9.9987 17.0658M16.004 15.0475C17.1255 14.5876 17.9154 13.4849 17.9154 12.1978C17.9154 11.3363 17.5615 10.5575 16.9913 9.9987C17.5615 9.43991 17.9154 8.66108 17.9154 7.79962C17.9154 6.21199 16.7136 4.90504 15.1702 4.73878C14.7858 3.21216 13.4039 2.08203 11.758 2.08203C11.1171 2.08203 10.5162 2.25337 9.9987 2.55275C9.48117 2.25337 8.88032 2.08203 8.23944 2.08203C6.59353 2.08203 5.21157 3.21216 4.82722 4.73878C3.28377 4.90504 2.08203 6.21199 2.08203 7.79962C2.08203 8.66108 2.43585 9.43991 3.00609 9.9987C2.43585 10.5575 2.08203 11.3363 2.08203 12.1978C2.08203 13.4849 2.87191 14.5876 3.99339 15.0475C4.46688 16.7033 5.9917 17.9154 7.79962 17.9154C8.61335 17.9154 9.36972 17.6698 9.9987 17.2488C10.6277 17.6698 11.384 17.9154 12.1978 17.9154C14.0057 17.9154 15.5305 16.7033 16.004 15.0475Z" stroke="currentColor"/>`,
|
||||
"bullet-list": `<path d="M9.58329 13.7497H17.0833M9.58329 6.24967H17.0833M6.24996 6.24967C6.24996 7.17015 5.50377 7.91634 4.58329 7.91634C3.66282 7.91634 2.91663 7.17015 2.91663 6.24967C2.91663 5.3292 3.66282 4.58301 4.58329 4.58301C5.50377 4.58301 6.24996 5.3292 6.24996 6.24967ZM6.24996 13.7497C6.24996 14.6701 5.50377 15.4163 4.58329 15.4163C3.66282 15.4163 2.91663 14.6701 2.91663 13.7497C2.91663 12.8292 3.66282 12.083 4.58329 12.083C5.50377 12.083 6.24996 12.8292 6.24996 13.7497Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"check-small": `<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"check-small": `<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="currentColor" stroke-linecap="square" stroke-width="1.25"/>`,
|
||||
"chevron-down": `<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"chevron-right": `<path d="M8.33301 13.3327L11.6663 9.99935L8.33301 6.66602" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"chevron-grabber-vertical": `<path d="M6.66675 12.4998L10.0001 15.8332L13.3334 12.4998M6.66675 7.49984L10.0001 4.1665L13.3334 7.49984" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
@@ -33,7 +33,7 @@ const icons = {
|
||||
mcp: `<g><path d="M0.972656 9.37176L9.5214 1.60019C10.7018 0.527151 12.6155 0.527151 13.7957 1.60019C14.9761 2.67321 14.9761 4.41295 13.7957 5.48599L7.3397 11.3552" stroke="currentColor" stroke-linecap="round"/><path d="M7.42871 11.2747L13.7957 5.48643C14.9761 4.41338 16.8898 4.41338 18.0702 5.48643L18.1147 5.52688C19.2951 6.59993 19.2951 8.33966 18.1147 9.4127L10.3831 16.4414C9.98966 16.7991 9.98966 17.379 10.3831 17.7366L11.9707 19.1799" stroke="currentColor" stroke-linecap="round"/><path d="M11.6587 3.54346L5.33619 9.29119C4.15584 10.3642 4.15584 12.1039 5.33619 13.177C6.51649 14.25 8.43019 14.25 9.61054 13.177L15.9331 7.42923" stroke="currentColor" stroke-linecap="round"/></g>`,
|
||||
glasses: `<path d="M0.416626 7.91667H1.66663M19.5833 7.91667H18.3333M11.866 7.57987C11.3165 7.26398 10.6793 7.08333 9.99996 7.08333C9.32061 7.08333 8.68344 7.26398 8.13389 7.57987M8.74996 10C8.74996 12.0711 7.07103 13.75 4.99996 13.75C2.92889 13.75 1.24996 12.0711 1.24996 10C1.24996 7.92893 2.92889 6.25 4.99996 6.25C7.07103 6.25 8.74996 7.92893 8.74996 10ZM18.75 10C18.75 12.0711 17.071 13.75 15 13.75C12.9289 13.75 11.25 12.0711 11.25 10C11.25 7.92893 12.9289 6.25 15 6.25C17.071 6.25 18.75 7.92893 18.75 10Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"magnifying-glass-menu": `<path d="M2.08325 10.0002H4.58325M2.08325 5.41683H5.41659M2.08325 14.5835H5.41659M16.4583 13.9585L18.7499 16.2502M17.9166 10.0002C17.9166 12.9917 15.4915 15.4168 12.4999 15.4168C9.50838 15.4168 7.08325 12.9917 7.08325 10.0002C7.08325 7.00862 9.50838 4.5835 12.4999 4.5835C15.4915 4.5835 17.9166 7.00862 17.9166 10.0002Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"window-cursor": `<path d="M17.9166 10.4167V3.75H2.08325V17.0833H10.4166M17.9166 13.5897L11.6666 11.6667L13.5897 17.9167L15.032 15.0321L17.9166 13.5897Z" stroke="currentColor" stroke-width="1.07143" stroke-linecap="square"/><path d="M5.00024 6.125C5.29925 6.12518 5.54126 6.36795 5.54126 6.66699C5.54108 6.96589 5.29914 7.20783 5.00024 7.20801C4.7012 7.20801 4.45843 6.966 4.45825 6.66699C4.45825 6.36784 4.70109 6.125 5.00024 6.125ZM7.91626 6.125C8.21541 6.125 8.45825 6.36784 8.45825 6.66699C8.45808 6.966 8.21531 7.20801 7.91626 7.20801C7.61736 7.20783 7.37542 6.96589 7.37524 6.66699C7.37524 6.36795 7.61726 6.12518 7.91626 6.125ZM10.8333 6.125C11.1324 6.125 11.3752 6.36784 11.3752 6.66699C11.3751 6.966 11.1323 7.20801 10.8333 7.20801C10.5342 7.20801 10.2914 6.966 10.2913 6.66699C10.2913 6.36784 10.5341 6.125 10.8333 6.125Z" fill="currentColor" stroke="currentColor" stroke-width="0.25" stroke-linecap="square"/>`,
|
||||
"window-cursor": `<path d="M17.9166 10.4167V3.75H2.08325V17.0833H10.4166M17.9166 13.5897L11.6666 11.6667L13.5897 17.9167L15.032 15.0321L17.9166 13.5897Z" stroke="currentColor" stroke-width="1.25" stroke-linecap="square"/><path d="M5.00024 6.125C5.29925 6.12518 5.54126 6.36795 5.54126 6.66699C5.54108 6.96589 5.29914 7.20783 5.00024 7.20801C4.7012 7.20801 4.45843 6.966 4.45825 6.66699C4.45825 6.36784 4.70109 6.125 5.00024 6.125ZM7.91626 6.125C8.21541 6.125 8.45825 6.36784 8.45825 6.66699C8.45808 6.966 8.21531 7.20801 7.91626 7.20801C7.61736 7.20783 7.37542 6.96589 7.37524 6.66699C7.37524 6.36795 7.61726 6.12518 7.91626 6.125ZM10.8333 6.125C11.1324 6.125 11.3752 6.36784 11.3752 6.66699C11.3751 6.966 11.1323 7.20801 10.8333 7.20801C10.5342 7.20801 10.2914 6.966 10.2913 6.66699C10.2913 6.36784 10.5341 6.125 10.8333 6.125Z" fill="currentColor" stroke="currentColor" stroke-width="0.25" stroke-linecap="square"/>`,
|
||||
task: `<path d="M9.99992 2.0835V17.9168M7.08325 3.75016H2.08325V16.2502H7.08325M12.9166 16.2502H17.9166V3.75016H12.9166" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
stop: `<rect x="5" y="5" width="10" height="10" fill="currentColor"/>`,
|
||||
"layout-left": `<path d="M2.91675 2.91699L2.91675 2.41699L2.41675 2.41699L2.41675 2.91699L2.91675 2.91699ZM17.0834 2.91699L17.5834 2.91699L17.5834 2.41699L17.0834 2.41699L17.0834 2.91699ZM17.0834 17.0837L17.0834 17.5837L17.5834 17.5837L17.5834 17.0837L17.0834 17.0837ZM2.91675 17.0837L2.41675 17.0837L2.41675 17.5837L2.91675 17.5837L2.91675 17.0837ZM7.41674 17.0837L7.41674 17.5837L8.41674 17.5837L8.41674 17.0837L7.91674 17.0837L7.41674 17.0837ZM8.41674 2.91699L8.41674 2.41699L7.41674 2.41699L7.41674 2.91699L7.91674 2.91699L8.41674 2.91699ZM2.91675 2.91699L2.91675 3.41699L17.0834 3.41699L17.0834 2.91699L17.0834 2.41699L2.91675 2.41699L2.91675 2.91699ZM17.0834 2.91699L16.5834 2.91699L16.5834 17.0837L17.0834 17.0837L17.5834 17.0837L17.5834 2.91699L17.0834 2.91699ZM17.0834 17.0837L17.0834 16.5837L2.91675 16.5837L2.91675 17.0837L2.91675 17.5837L17.0834 17.5837L17.0834 17.0837ZM2.91675 17.0837L3.41675 17.0837L3.41675 2.91699L2.91675 2.91699L2.41675 2.91699L2.41675 17.0837L2.91675 17.0837ZM7.91674 17.0837L8.41674 17.0837L8.41674 2.91699L7.91674 2.91699L7.41674 2.91699L7.41674 17.0837L7.91674 17.0837Z" fill="currentColor"/>`,
|
||||
@@ -53,7 +53,7 @@ const icons = {
|
||||
"dot-grid": `<path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" fill="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" fill="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" fill="currentColor"/><path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" stroke="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" stroke="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" stroke="currentColor"/>`,
|
||||
"circle-check": `<path d="M12.4987 7.91732L8.7487 12.5007L7.08203 10.834M17.9154 10.0007C17.9154 14.3729 14.371 17.9173 9.9987 17.9173C5.62644 17.9173 2.08203 14.3729 2.08203 10.0007C2.08203 5.6284 5.62644 2.08398 9.9987 2.08398C14.371 2.08398 17.9154 5.6284 17.9154 10.0007Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
copy: `<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>`,
|
||||
check: `<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
check: `<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square" stroke-width="1.25" />`,
|
||||
photo: `<path d="M16.6665 16.6666L11.6665 11.6666L9.99984 13.3333L6.6665 9.99996L3.08317 13.5833M2.9165 2.91663H17.0832V17.0833H2.9165V2.91663ZM13.3332 7.49996C13.3332 8.30537 12.6803 8.95829 11.8748 8.95829C11.0694 8.95829 10.4165 8.30537 10.4165 7.49996C10.4165 6.69454 11.0694 6.04163 11.8748 6.04163C12.6803 6.04163 13.3332 6.69454 13.3332 7.49996Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
share: `<path d="M10.0013 12.0846L10.0013 3.33464M13.7513 6.66797L10.0013 2.91797L6.2513 6.66797M17.0846 10.418V17.0846H2.91797V10.418" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
download: `<path d="M13.9583 10.6257L10 14.584L6.04167 10.6257M10 2.08398V13.959M16.25 17.9173H3.75" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
@@ -66,9 +66,6 @@ const icons = {
|
||||
dash: `<rect x="5" y="9.5" width="10" height="1" fill="currentColor"/>`,
|
||||
"cloud-upload": `<path d="M12.0833 16.25H15C17.0711 16.25 18.75 14.5711 18.75 12.5C18.75 10.5649 17.2843 8.97217 15.4025 8.77133C15.2 6.13103 12.8586 4.08333 10 4.08333C7.71532 4.08333 5.76101 5.49781 4.96501 7.49881C2.84892 7.90461 1.25 9.76559 1.25 11.6667C1.25 13.9813 3.30203 16.25 5.83333 16.25H7.91667M10 16.25V10.4167M12.0833 11.875L10 9.79167L7.91667 11.875" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
trash: `<path d="M4.58342 17.9134L4.58369 17.4134L4.22787 17.5384L4.22766 18.0384H4.58342V17.9134ZM15.4167 17.9134V18.0384H15.7725L15.7723 17.5384L15.4167 17.9134ZM2.08342 3.95508V3.45508H1.58342V3.95508H2.08342V4.45508V3.95508ZM17.9167 4.45508V4.95508H18.4167V4.45508H17.9167V3.95508V4.45508ZM4.16677 4.58008L3.66701 4.5996L4.22816 17.5379L4.72792 17.4934L5.22767 17.4489L4.66652 4.54055L4.16677 4.58008ZM4.58342 18.0384V17.9134H15.4167V18.0384V18.5384H4.58342V18.0384ZM15.4167 17.9134L15.8332 17.5379L16.2498 4.5996L15.7501 4.58008L15.2503 4.56055L14.8337 17.4989L15.4167 17.9134ZM15.8334 4.58008V4.08008H4.16677V4.58008V5.08008H15.8334V4.58008ZM2.08342 4.45508V4.95508H4.16677V4.58008V4.08008H2.08342V4.45508ZM15.8334 4.58008V5.08008H17.9167V4.45508V3.95508H15.8334V4.58008ZM6.83951 4.35149L7.432 4.55047C7.79251 3.47701 8.80699 2.70508 10.0001 2.70508V2.20508V1.70508C8.25392 1.70508 6.77335 2.83539 6.24702 4.15251L6.83951 4.35149ZM10.0001 2.20508V2.70508C11.1932 2.70508 12.2077 3.47701 12.5682 4.55047L13.1607 4.35149L13.7532 4.15251C13.2269 2.83539 11.7463 1.70508 10.0001 1.70508V2.20508Z" fill="currentColor"/>`,
|
||||
sliders: `<path d="M3.625 6.25H10.9375M16.375 13.75H10.5625M3.625 13.75H4.9375M11.125 6.25C11.125 4.79969 12.2997 3.625 13.75 3.625C15.2003 3.625 16.375 4.79969 16.375 6.25C16.375 7.70031 15.2003 8.875 13.75 8.875C12.2997 8.875 11.125 7.70031 11.125 6.25ZM10.375 13.75C10.375 15.2003 9.20031 16.375 7.75 16.375C6.29969 16.375 5.125 15.2003 5.125 13.75C5.125 12.2997 6.29969 11.125 7.75 11.125C9.20031 11.125 10.375 12.2997 10.375 13.75Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
keyboard: `<path d="M5.125 7.375V4.375H14.875V2.875M8.3125 13.9375H11.6875M8.125 13.9375H11.875M2.125 7.375H17.875V17.125H2.125V7.375ZM5.5 10.375H5.125V10.75H5.5V10.375ZM8.5 10.375H8.125V10.75H8.5V10.375ZM11.875 10.375H11.5V10.75H11.875V10.375ZM14.875 10.375H14.5V10.75H14.875V10.375ZM14.875 13.75H14.5V14.125H14.875V13.75ZM5.5 13.75H5.125V14.125H5.5V13.75Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
selector: `<path d="M6.66626 12.5033L9.99959 15.8366L13.3329 12.5033M6.66626 7.50326L9.99959 4.16992L13.3329 7.50326" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
}
|
||||
|
||||
export interface IconProps extends ComponentProps<"svg"> {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
[data-component="list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
padding: 0 12px;
|
||||
|
||||
@@ -82,12 +82,9 @@
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
mask: linear-gradient(to bottom, #ffff calc(100% - var(--bottom-fade)), #0000);
|
||||
animation: scroll;
|
||||
animation-timeline: --scroll;
|
||||
scroll-timeline: --scroll y;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createEffect, createSignal, For, onCleanup, type JSX, on, Show } from "
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Icon, type IconProps } from "./icon"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { ScrollFade } from "./scroll-fade"
|
||||
import { TextField } from "./text-field"
|
||||
|
||||
export interface ListSearchProps {
|
||||
@@ -133,7 +134,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
const index = selected ? all.indexOf(selected) : -1
|
||||
props.onKeyEvent?.(e, selected)
|
||||
|
||||
if (e.key === "Enter" && !e.isComposing) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
if (selected) handleSelect(selected, index)
|
||||
} else {
|
||||
@@ -197,11 +198,17 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
/>
|
||||
</div>
|
||||
<Show when={internalFilter()}>
|
||||
<IconButton icon="circle-x" variant="ghost" onClick={() => setInternalFilter("")} />
|
||||
<IconButton icon="circle-x" variant="weak" onClick={() => setInternalFilter("")} />
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<div ref={setScrollRef} data-slot="list-scroll">
|
||||
<ScrollFade
|
||||
ref={setScrollRef}
|
||||
direction="vertical"
|
||||
fadeStartSize={0}
|
||||
fadeEndSize={20}
|
||||
data-slot="list-scroll"
|
||||
>
|
||||
<Show
|
||||
when={flat().length > 0}
|
||||
fallback={
|
||||
@@ -260,7 +267,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollFade>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding-left: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
&[data-size="normal"] {
|
||||
width: 240px;
|
||||
gap: 4px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&[data-size="compact"] {
|
||||
@@ -36,6 +37,7 @@
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&[data-active] [data-slot="message-nav-tick-line"] {
|
||||
background-color: var(--icon-strong-base);
|
||||
@@ -54,7 +56,8 @@
|
||||
|
||||
[data-slot="message-nav-tick-button"]:hover [data-slot="message-nav-tick-line"] {
|
||||
width: 100%;
|
||||
background-color: var(--icon-strong-base);
|
||||
color: var(--text-strong);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[data-slot="message-nav-message-button"] {
|
||||
@@ -62,11 +65,12 @@
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
column-gap: 12px;
|
||||
cursor: default;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 4px 12px;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
@@ -79,16 +83,21 @@
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
|
||||
&:not(:hover) {
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[data-active] {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item"]:hover [data-slot="message-nav-message-button"] {
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
[data-slot="message-nav-item"]:active [data-slot="message-nav-message-button"] {
|
||||
background-color: var(--surface-base-active);
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item"]:active [data-slot="message-nav-title-preview"] {
|
||||
@@ -101,7 +110,7 @@
|
||||
|
||||
[data-slot="message-nav-tooltip-content"] {
|
||||
display: flex;
|
||||
padding: 4px 4px 6px 4px;
|
||||
padding: 4px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-md);
|
||||
@@ -119,4 +128,4 @@
|
||||
* {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { ComponentProps, For, Match, Show, splitProps, Switch } from "solid-js"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { Tooltip } from "@kobalte/core/tooltip"
|
||||
import { ScrollReveal } from "./scroll-reveal"
|
||||
|
||||
export function MessageNav(
|
||||
props: ComponentProps<"ul"> & {
|
||||
@@ -43,14 +44,16 @@ export function MessageNav(
|
||||
</Match>
|
||||
<Match when={local.size === "normal"}>
|
||||
<button data-slot="message-nav-message-button" onClick={handleClick} onKeyDown={handleKeyPress}>
|
||||
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
|
||||
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" class="-ml-1" />
|
||||
<div
|
||||
data-slot="message-nav-title-preview"
|
||||
data-active={message.id === local.current?.id || undefined}
|
||||
>
|
||||
<Show when={local.getLabel?.(message) ?? message.summary?.title} fallback="New message">
|
||||
{local.getLabel?.(message) ?? message.summary?.title}
|
||||
</Show>
|
||||
<ScrollReveal fadeEndSize={12}>
|
||||
<Show when={local.getLabel?.(message) ?? message.summary?.title} fallback="New message">
|
||||
{local.getLabel?.(message) ?? message.summary?.title}
|
||||
</Show>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</button>
|
||||
</Match>
|
||||
@@ -79,4 +82,4 @@ export function MessageNav(
|
||||
<Match when={local.size === "normal"}>{content()}</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--surface-weak);
|
||||
background: var(--surface-base);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
@@ -76,8 +76,7 @@
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
background: var(--surface-weak);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
background: var(--surface-base);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -114,22 +113,11 @@
|
||||
|
||||
[data-component="reasoning-part"] {
|
||||
width: 100%;
|
||||
color: var(--text-base);
|
||||
opacity: 0.8;
|
||||
line-height: var(--line-height-large);
|
||||
opacity: 0.5;
|
||||
|
||||
[data-component="markdown"] {
|
||||
margin-top: 24px;
|
||||
font-style: italic !important;
|
||||
|
||||
p:has(strong) {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 0;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -357,11 +357,6 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const toggleExpanded = () => {
|
||||
if (!canExpand()) return
|
||||
setExpanded((value) => !value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="user-message" data-expanded={expanded()} data-can-expand={canExpand()}>
|
||||
<Show when={attachments().length > 0}>
|
||||
@@ -393,29 +388,19 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={text()}>
|
||||
<div data-slot="user-message-text" ref={(el) => (textRef = el)} onClick={toggleExpanded}>
|
||||
<div data-slot="user-message-text" ref={(el) => (textRef = el)}>
|
||||
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
|
||||
<button
|
||||
data-slot="user-message-expand"
|
||||
type="button"
|
||||
aria-label={expanded() ? "Collapse message" : "Expand message"}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
toggleExpanded()
|
||||
}}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</button>
|
||||
<div data-slot="user-message-copy-wrapper">
|
||||
<Tooltip value={copied() ? "Copied!" : "Copy"} placement="top" gutter={8}>
|
||||
<IconButton
|
||||
icon={copied() ? "check" : "copy"}
|
||||
variant="secondary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleCopy()
|
||||
}}
|
||||
/>
|
||||
<IconButton icon={copied() ? "check" : "copy"} variant="secondary" onClick={handleCopy} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -992,7 +977,6 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
const diffComponent = useDiffComponent()
|
||||
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
|
||||
const filename = () => getFilename(props.input.filePath ?? "")
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
@@ -1000,12 +984,13 @@ ToolRegistry.register({
|
||||
trigger={
|
||||
<div data-component="edit-trigger">
|
||||
<div data-slot="message-part-title-area">
|
||||
<div data-slot="message-part-title">Edit {filename()}</div>
|
||||
<Show when={props.input.filePath?.includes("/")}>
|
||||
<div data-slot="message-part-path">
|
||||
<div data-slot="message-part-title">Edit</div>
|
||||
<div data-slot="message-part-path">
|
||||
<Show when={props.input.filePath?.includes("/")}>
|
||||
<span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
<span data-slot="message-part-filename">{getFilename(props.input.filePath ?? "")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="message-part-actions">
|
||||
<Show when={props.metadata.filediff}>
|
||||
@@ -1041,7 +1026,6 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
const codeComponent = useCodeComponent()
|
||||
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
|
||||
const filename = () => getFilename(props.input.filePath ?? "")
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
@@ -1049,12 +1033,13 @@ ToolRegistry.register({
|
||||
trigger={
|
||||
<div data-component="write-trigger">
|
||||
<div data-slot="message-part-title-area">
|
||||
<div data-slot="message-part-title">Write {filename()}</div>
|
||||
<Show when={props.input.filePath?.includes("/")}>
|
||||
<div data-slot="message-part-path">
|
||||
<div data-slot="message-part-title">Write</div>
|
||||
<div data-slot="message-part-path">
|
||||
<Show when={props.input.filePath?.includes("/")}>
|
||||
<span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
<span data-slot="message-part-filename">{getFilename(props.input.filePath ?? "")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="message-part-actions">{/* <DiffChanges diff={diff} /> */}</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
|
||||
transform-origin: var(--kb-popover-content-transform-origin);
|
||||
|
||||
[data-origin-top-right] {
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
[data-origin-top-left] {
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
[data-component="scroll-fade"] {
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&[data-direction="horizontal"] {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
||||
/* Both fades */
|
||||
&[data-fade-start][data-fade-end] {
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black var(--scroll-fade-start),
|
||||
black calc(100% - var(--scroll-fade-end)),
|
||||
transparent
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black var(--scroll-fade-start),
|
||||
black calc(100% - var(--scroll-fade-end)),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* Only start fade */
|
||||
&[data-fade-start]:not([data-fade-end]) {
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black var(--scroll-fade-start),
|
||||
black 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black var(--scroll-fade-start),
|
||||
black 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Only end fade */
|
||||
&:not([data-fade-start])[data-fade-end] {
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
black 0%,
|
||||
black calc(100% - var(--scroll-fade-end)),
|
||||
transparent
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
black 0%,
|
||||
black calc(100% - var(--scroll-fade-end)),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-direction="vertical"] {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
/* Both fades */
|
||||
&[data-fade-start][data-fade-end] {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
black var(--scroll-fade-start),
|
||||
black calc(100% - var(--scroll-fade-end)),
|
||||
transparent
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
black var(--scroll-fade-start),
|
||||
black calc(100% - var(--scroll-fade-end)),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* Only start fade */
|
||||
&[data-fade-start]:not([data-fade-end]) {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
black var(--scroll-fade-start),
|
||||
black 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
black var(--scroll-fade-start),
|
||||
black 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Only end fade */
|
||||
&:not([data-fade-start])[data-fade-end] {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
black 0%,
|
||||
black calc(100% - var(--scroll-fade-end)),
|
||||
transparent
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
black 0%,
|
||||
black calc(100% - var(--scroll-fade-end)),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
type JSX,
|
||||
createEffect,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
splitProps,
|
||||
} from "solid-js"
|
||||
import "./scroll-fade.css"
|
||||
|
||||
export interface ScrollFadeProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
direction?: "horizontal" | "vertical"
|
||||
fadeStartSize?: number
|
||||
fadeEndSize?: number
|
||||
trackTransformSelector?: string
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
}
|
||||
|
||||
export function ScrollFade(props: ScrollFadeProps) {
|
||||
const [local, others] = splitProps(props, [
|
||||
"children",
|
||||
"direction",
|
||||
"fadeStartSize",
|
||||
"fadeEndSize",
|
||||
"trackTransformSelector",
|
||||
"class",
|
||||
"style",
|
||||
"ref",
|
||||
])
|
||||
|
||||
const direction = () => local.direction ?? "vertical"
|
||||
const fadeStartSize = () => local.fadeStartSize ?? 20
|
||||
const fadeEndSize = () => local.fadeEndSize ?? 20
|
||||
|
||||
const getTransformOffset = (element: Element): number => {
|
||||
const style = getComputedStyle(element)
|
||||
const transform = style.transform
|
||||
if (!transform || transform === "none") return 0
|
||||
|
||||
const match = transform.match(/matrix(?:3d)?\(([^)]+)\)/)
|
||||
if (!match) return 0
|
||||
|
||||
const values = match[1].split(",").map((v) => parseFloat(v.trim()))
|
||||
const isHorizontal = direction() === "horizontal"
|
||||
|
||||
if (transform.startsWith("matrix3d")) {
|
||||
return isHorizontal ? -(values[12] || 0) : -(values[13] || 0)
|
||||
} else {
|
||||
return isHorizontal ? -(values[4] || 0) : -(values[5] || 0)
|
||||
}
|
||||
}
|
||||
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
|
||||
const [fadeStart, setFadeStart] = createSignal(0)
|
||||
const [fadeEnd, setFadeEnd] = createSignal(0)
|
||||
const [isScrollable, setIsScrollable] = createSignal(false)
|
||||
|
||||
let lastScrollPos = 0
|
||||
let lastTransformPos = 0
|
||||
let lastScrollSize = 0
|
||||
let lastClientSize = 0
|
||||
|
||||
const updateFade = () => {
|
||||
if (!containerRef) return
|
||||
|
||||
const isHorizontal = direction() === "horizontal"
|
||||
const scrollPos = isHorizontal ? containerRef.scrollLeft : containerRef.scrollTop
|
||||
const scrollSize = isHorizontal ? containerRef.scrollWidth : containerRef.scrollHeight
|
||||
const clientSize = isHorizontal ? containerRef.clientWidth : containerRef.clientHeight
|
||||
|
||||
let transformPos = 0
|
||||
if (local.trackTransformSelector) {
|
||||
const transformElement = containerRef.querySelector(local.trackTransformSelector)
|
||||
if (transformElement) {
|
||||
transformPos = getTransformOffset(transformElement)
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveScrollPos = Math.max(scrollPos, transformPos)
|
||||
|
||||
if (
|
||||
effectiveScrollPos === lastScrollPos &&
|
||||
transformPos === lastTransformPos &&
|
||||
scrollSize === lastScrollSize &&
|
||||
clientSize === lastClientSize
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
lastScrollPos = effectiveScrollPos
|
||||
lastTransformPos = transformPos
|
||||
lastScrollSize = scrollSize
|
||||
lastClientSize = clientSize
|
||||
|
||||
const maxScroll = scrollSize - clientSize
|
||||
const canScroll = maxScroll > 1
|
||||
|
||||
setIsScrollable(canScroll)
|
||||
|
||||
if (!canScroll) {
|
||||
setFadeStart(0)
|
||||
setFadeEnd(0)
|
||||
return
|
||||
}
|
||||
|
||||
const progress = maxScroll > 0 ? effectiveScrollPos / maxScroll : 0
|
||||
|
||||
const startProgress = Math.min(progress / 0.1, 1)
|
||||
setFadeStart(startProgress * fadeStartSize())
|
||||
|
||||
const endProgress = progress > 0.9 ? (1 - progress) / 0.1 : 1
|
||||
setFadeEnd(Math.max(0, endProgress) * fadeEndSize())
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!containerRef) return
|
||||
|
||||
updateFade()
|
||||
|
||||
containerRef.addEventListener("scroll", updateFade, { passive: true })
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
lastScrollSize = 0
|
||||
lastClientSize = 0
|
||||
updateFade()
|
||||
})
|
||||
resizeObserver.observe(containerRef)
|
||||
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
lastScrollSize = 0
|
||||
lastClientSize = 0
|
||||
requestAnimationFrame(updateFade)
|
||||
})
|
||||
mutationObserver.observe(containerRef, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
})
|
||||
|
||||
let rafId: number
|
||||
const pollScroll = () => {
|
||||
updateFade()
|
||||
rafId = requestAnimationFrame(pollScroll)
|
||||
}
|
||||
rafId = requestAnimationFrame(pollScroll)
|
||||
|
||||
onCleanup(() => {
|
||||
containerRef?.removeEventListener("scroll", updateFade)
|
||||
resizeObserver.disconnect()
|
||||
mutationObserver.disconnect()
|
||||
cancelAnimationFrame(rafId)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
local.children
|
||||
requestAnimationFrame(updateFade)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
containerRef = el
|
||||
local.ref?.(el)
|
||||
}}
|
||||
data-component="scroll-fade"
|
||||
data-direction={direction()}
|
||||
data-scrollable={isScrollable() || undefined}
|
||||
data-fade-start={fadeStart() > 0 || undefined}
|
||||
data-fade-end={fadeEnd() > 0 || undefined}
|
||||
class={local.class}
|
||||
style={{
|
||||
...(typeof local.style === "object" ? local.style : {}),
|
||||
"--scroll-fade-start": `${fadeStart()}px`,
|
||||
"--scroll-fade-end": `${fadeEnd()}px`,
|
||||
}}
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { type JSX, onCleanup, splitProps } from "solid-js"
|
||||
import { ScrollFade, type ScrollFadeProps } from "./scroll-fade"
|
||||
|
||||
const SCROLL_SPEED = 60
|
||||
const PAUSE_DURATION = 800
|
||||
|
||||
interface ScrollAnimationState {
|
||||
rafId: number | null
|
||||
startTime: number
|
||||
running: boolean
|
||||
}
|
||||
|
||||
const startScrollAnimation = (containerEl: HTMLElement): ScrollAnimationState | null => {
|
||||
containerEl.offsetHeight
|
||||
|
||||
const extraWidth = containerEl.scrollWidth - containerEl.clientWidth
|
||||
if (extraWidth <= 0) return null
|
||||
|
||||
const scrollDuration = (extraWidth / SCROLL_SPEED) * 1000
|
||||
const totalDuration = PAUSE_DURATION + scrollDuration + PAUSE_DURATION + scrollDuration + PAUSE_DURATION
|
||||
|
||||
const state: ScrollAnimationState = {
|
||||
rafId: null,
|
||||
startTime: performance.now(),
|
||||
running: true,
|
||||
}
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
if (!state.running) return
|
||||
|
||||
const elapsed = currentTime - state.startTime
|
||||
const progress = (elapsed % totalDuration) / totalDuration
|
||||
|
||||
const pausePercent = PAUSE_DURATION / totalDuration
|
||||
const scrollPercent = scrollDuration / totalDuration
|
||||
|
||||
const pauseEnd1 = pausePercent
|
||||
const scrollEnd1 = pauseEnd1 + scrollPercent
|
||||
const pauseEnd2 = scrollEnd1 + pausePercent
|
||||
const scrollEnd2 = pauseEnd2 + scrollPercent
|
||||
|
||||
let scrollPos = 0
|
||||
|
||||
if (progress < pauseEnd1) {
|
||||
scrollPos = 0
|
||||
} else if (progress < scrollEnd1) {
|
||||
const scrollProgress = (progress - pauseEnd1) / scrollPercent
|
||||
scrollPos = scrollProgress * extraWidth
|
||||
} else if (progress < pauseEnd2) {
|
||||
scrollPos = extraWidth
|
||||
} else if (progress < scrollEnd2) {
|
||||
const scrollProgress = (progress - pauseEnd2) / scrollPercent
|
||||
scrollPos = extraWidth * (1 - scrollProgress)
|
||||
} else {
|
||||
scrollPos = 0
|
||||
}
|
||||
|
||||
containerEl.scrollLeft = scrollPos
|
||||
state.rafId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
state.rafId = requestAnimationFrame(animate)
|
||||
return state
|
||||
}
|
||||
|
||||
const stopScrollAnimation = (state: ScrollAnimationState | null, containerEl?: HTMLElement) => {
|
||||
if (state) {
|
||||
state.running = false
|
||||
if (state.rafId !== null) {
|
||||
cancelAnimationFrame(state.rafId)
|
||||
}
|
||||
}
|
||||
if (containerEl) {
|
||||
containerEl.scrollLeft = 0
|
||||
}
|
||||
}
|
||||
|
||||
export interface ScrollRevealProps extends Omit<ScrollFadeProps, "direction"> {
|
||||
/** Delay before scroll animation starts on hover (ms). Default: 300 */
|
||||
hoverDelay?: number
|
||||
}
|
||||
|
||||
export function ScrollReveal(props: ScrollRevealProps) {
|
||||
const [local, others] = splitProps(props, ["children", "hoverDelay", "ref"])
|
||||
|
||||
const hoverDelay = () => local.hoverDelay ?? 300
|
||||
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let hoverTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let scrollAnimationState: ScrollAnimationState | null = null
|
||||
|
||||
const handleMouseEnter: JSX.EventHandler<HTMLDivElement, MouseEvent> = () => {
|
||||
hoverTimeout = setTimeout(() => {
|
||||
if (!containerRef) return
|
||||
|
||||
containerRef.offsetHeight
|
||||
|
||||
const isScrollable = containerRef.scrollWidth > containerRef.clientWidth + 1
|
||||
|
||||
if (isScrollable) {
|
||||
stopScrollAnimation(scrollAnimationState, containerRef)
|
||||
scrollAnimationState = startScrollAnimation(containerRef)
|
||||
}
|
||||
}, hoverDelay())
|
||||
}
|
||||
|
||||
const handleMouseLeave: JSX.EventHandler<HTMLDivElement, MouseEvent> = () => {
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout)
|
||||
hoverTimeout = undefined
|
||||
}
|
||||
stopScrollAnimation(scrollAnimationState, containerRef)
|
||||
scrollAnimationState = null
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout)
|
||||
}
|
||||
stopScrollAnimation(scrollAnimationState, containerRef)
|
||||
})
|
||||
|
||||
return (
|
||||
<ScrollFade
|
||||
ref={(el) => {
|
||||
containerRef = el
|
||||
local.ref?.(el)
|
||||
}}
|
||||
fadeStartSize={8}
|
||||
fadeEndSize={8}
|
||||
direction="horizontal"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
</ScrollFade>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
[data-component="select"] {
|
||||
[data-slot="select-select-trigger"] {
|
||||
padding: 0 4px 0 8px;
|
||||
display: flex;
|
||||
padding: 4px 8px !important;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: none;
|
||||
transition: background-color 200ms cubic-bezier(0.25, 0, 0.5, 1);
|
||||
|
||||
[data-slot="select-select-trigger-value"] {
|
||||
overflow: hidden;
|
||||
@@ -16,9 +20,10 @@
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-weak);
|
||||
transition: transform 0.1s ease-in-out;
|
||||
transition: transform 200ms cubic-bezier(0.25, 0, 0.5, 1);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[data-expanded] {
|
||||
&[data-variant="secondary"] {
|
||||
background-color: var(--button-secondary-hover);
|
||||
@@ -43,65 +48,29 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-trigger-style="settings"] {
|
||||
[data-slot="select-select-trigger"] {
|
||||
padding: 6px 6px 6px 12px;
|
||||
box-shadow: none;
|
||||
border-radius: 6px;
|
||||
min-width: 160px;
|
||||
height: 32px;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
background-color: transparent;
|
||||
|
||||
[data-slot="select-select-trigger-value"] {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
[data-slot="select-select-trigger-icon"] {
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-weak);
|
||||
background-color: var(--surface-raised-base);
|
||||
border-radius: 4px;
|
||||
transition: transform 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
&[data-slot="select-select-trigger"]:hover:not(:disabled),
|
||||
&[data-slot="select-select-trigger"][data-expanded],
|
||||
&[data-slot="select-select-trigger"][data-expanded]:hover:not(:disabled) {
|
||||
background-color: var(--input-base);
|
||||
box-shadow: var(--shadow-xs-border-base);
|
||||
}
|
||||
|
||||
&:not([data-expanded]):focus {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="select-content"] {
|
||||
min-width: 104px;
|
||||
min-width: 8rem;
|
||||
max-width: 23rem;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
padding: 4px;
|
||||
box-shadow: var(--shadow-xs-border);
|
||||
z-index: 60;
|
||||
z-index: 50;
|
||||
transform-origin: var(--kb-popper-content-transform-origin);
|
||||
pointer-events: none;
|
||||
|
||||
animation: selectContentHide 200ms cubic-bezier(0.25, 0, 0.5, 1) forwards;
|
||||
|
||||
@starting-style {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
&[data-expanded] {
|
||||
animation: select-open 0.15s ease-out;
|
||||
pointer-events: auto;
|
||||
animation: selectContentShow 200ms cubic-bezier(0.25, 0, 0.5, 1) forwards;
|
||||
}
|
||||
|
||||
[data-slot="select-select-content-list"] {
|
||||
@@ -121,18 +90,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* [data-slot="select-section"] { */
|
||||
/* } */
|
||||
|
||||
[data-slot="select-select-item"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
padding: 4px 8px;
|
||||
gap: 12px;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
/* text-12-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-size: var(--font-size-base);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
@@ -141,13 +112,13 @@
|
||||
color: var(--text-strong);
|
||||
|
||||
transition:
|
||||
background-color 0.2s ease-in-out,
|
||||
color 0.2s ease-in-out;
|
||||
background-color 200ms cubic-bezier(0.25, 0, 0.5, 1),
|
||||
color 200ms cubic-bezier(0.25, 0, 0.5, 1);
|
||||
outline: none;
|
||||
user-select: none;
|
||||
|
||||
&[data-highlighted] {
|
||||
background: var(--surface-raised-base-hover);
|
||||
&:hover {
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
}
|
||||
&[data-disabled] {
|
||||
background-color: var(--surface-raised-base);
|
||||
@@ -170,33 +141,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="select-content"][data-trigger-style="settings"] {
|
||||
min-width: 160px;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
|
||||
[data-slot="select-select-content-list"] {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
[data-slot="select-select-item"] {
|
||||
/* text-14-regular */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes select-open {
|
||||
@keyframes selectContentShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transform: scaleY(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes selectContentHide {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scaleY(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scaleY(0.95);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Select as Kobalte } from "@kobalte/core/select"
|
||||
import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js"
|
||||
import { createMemo, splitProps, type ComponentProps, type JSX } from "solid-js"
|
||||
import { pipe, groupBy, entries, map } from "remeda"
|
||||
import { Button, ButtonProps } from "./button"
|
||||
import { Icon } from "./icon"
|
||||
@@ -12,15 +12,12 @@ export type SelectProps<T> = Omit<ComponentProps<typeof Kobalte<T>>, "value" | "
|
||||
label?: (x: T) => string
|
||||
groupBy?: (x: T) => string
|
||||
onSelect?: (value: T | undefined) => void
|
||||
onHighlight?: (value: T | undefined) => (() => void) | void
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
children?: (item: T | undefined) => JSX.Element
|
||||
triggerStyle?: JSX.CSSProperties
|
||||
triggerVariant?: "settings"
|
||||
}
|
||||
|
||||
export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">) {
|
||||
export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
||||
const [local, others] = splitProps(props, [
|
||||
"class",
|
||||
"classList",
|
||||
@@ -31,42 +28,8 @@ export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">)
|
||||
"label",
|
||||
"groupBy",
|
||||
"onSelect",
|
||||
"onHighlight",
|
||||
"onOpenChange",
|
||||
"children",
|
||||
"triggerStyle",
|
||||
"triggerVariant",
|
||||
])
|
||||
|
||||
const state = {
|
||||
key: undefined as string | undefined,
|
||||
cleanup: undefined as (() => void) | void,
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
state.cleanup?.()
|
||||
state.cleanup = undefined
|
||||
state.key = undefined
|
||||
}
|
||||
|
||||
const keyFor = (item: T) => (local.value ? local.value(item) : (item as string))
|
||||
|
||||
const move = (item: T | undefined) => {
|
||||
if (!local.onHighlight) return
|
||||
if (!item) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
const key = keyFor(item)
|
||||
if (state.key === key) return
|
||||
state.cleanup?.()
|
||||
state.cleanup = local.onHighlight(item)
|
||||
state.key = key
|
||||
}
|
||||
|
||||
onCleanup(stop)
|
||||
|
||||
const grouped = createMemo(() => {
|
||||
const result = pipe(
|
||||
local.options,
|
||||
@@ -83,11 +46,10 @@ export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">)
|
||||
<Kobalte<T, { category: string; options: T[] }>
|
||||
{...others}
|
||||
data-component="select"
|
||||
data-trigger-style={local.triggerVariant}
|
||||
placement={local.triggerVariant === "settings" ? "bottom-end" : "bottom-start"}
|
||||
gutter={4}
|
||||
placement="bottom-start"
|
||||
value={local.current}
|
||||
options={grouped()}
|
||||
gutter={12}
|
||||
optionValue={(x) => (local.value ? local.value(x) : (x as string))}
|
||||
optionTextValue={(x) => (local.label ? local.label(x) : (x as string))}
|
||||
optionGroupChildren="options"
|
||||
@@ -97,14 +59,12 @@ export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">)
|
||||
)}
|
||||
itemComponent={(itemProps) => (
|
||||
<Kobalte.Item
|
||||
{...itemProps}
|
||||
data-slot="select-select-item"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
onPointerEnter={() => move(itemProps.item.rawValue)}
|
||||
onPointerMove={() => move(itemProps.item.rawValue)}
|
||||
{...itemProps}
|
||||
>
|
||||
<Kobalte.ItemLabel data-slot="select-select-item-label">
|
||||
{local.children
|
||||
@@ -114,17 +74,12 @@ export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">)
|
||||
: (itemProps.item.rawValue as string)}
|
||||
</Kobalte.ItemLabel>
|
||||
<Kobalte.ItemIndicator data-slot="select-select-item-indicator">
|
||||
<Icon name="check-small" size="small" />
|
||||
<Icon name="check" size="small" class="text-icon-strong-base" />
|
||||
</Kobalte.ItemIndicator>
|
||||
</Kobalte.Item>
|
||||
)}
|
||||
onChange={(v) => {
|
||||
local.onSelect?.(v ?? undefined)
|
||||
stop()
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
local.onOpenChange?.(open)
|
||||
if (!open) stop()
|
||||
}}
|
||||
>
|
||||
<Kobalte.Trigger
|
||||
@@ -133,7 +88,6 @@ export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">)
|
||||
as={Button}
|
||||
size={props.size}
|
||||
variant={props.variant}
|
||||
style={local.triggerStyle}
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
@@ -148,7 +102,7 @@ export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">)
|
||||
}}
|
||||
</Kobalte.Value>
|
||||
<Kobalte.Icon data-slot="select-select-trigger-icon">
|
||||
<Icon name={local.triggerVariant === "settings" ? "selector" : "chevron-down"} size="small" />
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Kobalte.Icon>
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Portal>
|
||||
@@ -158,7 +112,6 @@ export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">)
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
data-component="select-content"
|
||||
data-trigger-style={local.triggerVariant}
|
||||
>
|
||||
<Kobalte.Listbox data-slot="select-select-content-list" />
|
||||
</Kobalte.Content>
|
||||
|
||||
@@ -72,17 +72,17 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
<div data-slot="session-review-actions">
|
||||
<Show when={props.onDiffStyleChange}>
|
||||
<RadioGroup
|
||||
options={["unified", "split"] as const}
|
||||
options={["unified", "split"]}
|
||||
current={diffStyle()}
|
||||
value={(style) => style}
|
||||
label={(style) => (style === "unified" ? "Unified" : "Split")}
|
||||
onSelect={(style) => style && props.onDiffStyleChange?.(style)}
|
||||
onSelect={(style) => style && props.onDiffStyleChange?.(style as SessionReviewDiffStyle)}
|
||||
/>
|
||||
</Show>
|
||||
<Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
|
||||
<Switch>
|
||||
<Match when={open().length > 0}>Collapse all</Match>
|
||||
<Match when={true}>Expand all</Match>
|
||||
<Match when={open().length > 0}>Collapse</Match>
|
||||
<Match when={true}>Expand</Match>
|
||||
</Switch>
|
||||
</Button>
|
||||
{props.actions}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user