mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-26 08:34:35 +00:00
Compare commits
271 Commits
kit/effect
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cafce902f | ||
|
|
3bbd310edc | ||
|
|
733cadac39 | ||
|
|
292d2c9e52 | ||
|
|
ece4747d01 | ||
|
|
8acc1111f7 | ||
|
|
e806dffc26 | ||
|
|
f31c1d6712 | ||
|
|
571ece2490 | ||
|
|
0e89f59261 | ||
|
|
9d24790a29 | ||
|
|
b4e5e9c0b0 | ||
|
|
d500a8432a | ||
|
|
2d502d6ffe | ||
|
|
2ad190e482 | ||
|
|
16742af7f3 | ||
|
|
652313e036 | ||
|
|
1a4a6eabe2 | ||
|
|
193ebf1f1b | ||
|
|
a9bde4a13d | ||
|
|
31299c6968 | ||
|
|
ba244a6e62 | ||
|
|
cf61f7eb59 | ||
|
|
7cb690d7e5 | ||
|
|
8acccc64df | ||
|
|
31ad6e85ba | ||
|
|
ea04b23745 | ||
|
|
fd173392ac | ||
|
|
85243d2e5b | ||
|
|
222782b5d1 | ||
|
|
801a045ae8 | ||
|
|
05c3cfb2aa | ||
|
|
f54e4b60cc | ||
|
|
97c15a087d | ||
|
|
b90de755f9 | ||
|
|
a4dd023c79 | ||
|
|
8c0fae36a6 | ||
|
|
8864fdce2f | ||
|
|
9fee5bf807 | ||
|
|
9fe4e2a7d4 | ||
|
|
5179b87aef | ||
|
|
66a56551be | ||
|
|
62f20f4986 | ||
|
|
d9cefe21f8 | ||
|
|
ddb95646ec | ||
|
|
e250a67001 | ||
|
|
5a49a4c108 | ||
|
|
d4648e726b | ||
|
|
d844e3aa25 | ||
|
|
c1df8a52c8 | ||
|
|
caae1d9902 | ||
|
|
6ce3347e44 | ||
|
|
b794601142 | ||
|
|
29529b4891 | ||
|
|
30b51996d4 | ||
|
|
d284d8795d | ||
|
|
a1dcc7a140 | ||
|
|
7123aad5a8 | ||
|
|
91378ec7db | ||
|
|
85be6eaa7f | ||
|
|
a265da7a8d | ||
|
|
0c510b0e61 | ||
|
|
d6fc5f414b | ||
|
|
77fc88c8ad | ||
|
|
cafc2b204b | ||
|
|
36709aae5f | ||
|
|
fac0dd8862 | ||
|
|
73e107250d | ||
|
|
b746aec493 | ||
|
|
ad40b65b0b | ||
|
|
971383661a | ||
|
|
b0017bf1b9 | ||
|
|
0c0c6f3bdb | ||
|
|
b480a38d31 | ||
|
|
4167e25c7e | ||
|
|
1041ae91d1 | ||
|
|
898456a25c | ||
|
|
53d0b58ebf | ||
|
|
2b0baf97bd | ||
|
|
0dbfefa080 | ||
|
|
d1c49ba210 | ||
|
|
3ea72aec21 | ||
|
|
9717383823 | ||
|
|
5d9e780029 | ||
|
|
aa11fa865d | ||
|
|
9a64bdb539 | ||
|
|
71693cc24b | ||
|
|
700f57112a | ||
|
|
0a80ef4278 | ||
|
|
4f9667c4bb | ||
|
|
be142b00bd | ||
|
|
45c2573979 | ||
|
|
79e9d19019 | ||
|
|
958a80cc05 | ||
|
|
4647aa80ac | ||
|
|
a379eb3867 | ||
|
|
cbe1337f24 | ||
|
|
50f6aa3763 | ||
|
|
0dcdf5f529 | ||
|
|
fa9674edf9 | ||
|
|
96646b1ba9 | ||
|
|
09a6b5fe79 | ||
|
|
4586b41ffd | ||
|
|
35884defd8 | ||
|
|
f45e084b3e | ||
|
|
15dc33d1a3 | ||
|
|
1398674e53 | ||
|
|
afc4c831eb | ||
|
|
ec64ceabec | ||
|
|
680a2ff7c8 | ||
|
|
f9e35a3294 | ||
|
|
700d0fe3cc | ||
|
|
56644be95a | ||
|
|
414ae32760 | ||
|
|
00d3b831fc | ||
|
|
2f556e018d | ||
|
|
b85c0b8625 | ||
|
|
6701b651d9 | ||
|
|
8845f1a403 | ||
|
|
99889e561d | ||
|
|
b848b7ebae | ||
|
|
e837dcc1c5 | ||
|
|
024979f3fd | ||
|
|
bc608fb081 | ||
|
|
9838f56a6f | ||
|
|
98b3340cee | ||
|
|
5e684c6e80 | ||
|
|
2c1d8a90d5 | ||
|
|
8994cbfc0f | ||
|
|
42a773481e | ||
|
|
539b01f20f | ||
|
|
814a515a8a | ||
|
|
235a82aea9 | ||
|
|
9330bc5339 | ||
|
|
1238d1f61a | ||
|
|
1d3232b388 | ||
|
|
5c1bb5de86 | ||
|
|
7c5ed771c3 | ||
|
|
31c4a4fb47 | ||
|
|
037077285a | ||
|
|
41c77ccb33 | ||
|
|
546748a461 | ||
|
|
c9c93eac00 | ||
|
|
3f1a4abe6d | ||
|
|
431e0586ad | ||
|
|
fde201c286 | ||
|
|
d3debc191f | ||
|
|
34f43fff89 | ||
|
|
49623aa519 | ||
|
|
f1340472ec | ||
|
|
a8b28826a0 | ||
|
|
a03a2b6eab | ||
|
|
ad78b79b8a | ||
|
|
8c2844b2ee | ||
|
|
0c519acd1f | ||
|
|
9a006d8700 | ||
|
|
3a0bf2f39f | ||
|
|
b556979634 | ||
|
|
691644eeeb | ||
|
|
4aebaaf067 | ||
|
|
d6a2460444 | ||
|
|
77b3b46788 | ||
|
|
36dfe1646b | ||
|
|
6926dc26d1 | ||
|
|
eb74e4a6d2 | ||
|
|
85d8e143bf | ||
|
|
8e1b53b32c | ||
|
|
0a7dfc03ee | ||
|
|
4c27e7fc64 | ||
|
|
0f5626d2e4 | ||
|
|
5ea95451dd | ||
|
|
9239d877b9 | ||
|
|
fc68c24433 | ||
|
|
db9619dad6 | ||
|
|
84d9b38873 | ||
|
|
8035c3435b | ||
|
|
71e7603d71 | ||
|
|
40e49c5b49 | ||
|
|
afe9b97274 | ||
|
|
3b3549902d | ||
|
|
e9a9c75c1f | ||
|
|
2b171828b0 | ||
|
|
8dd817023a | ||
|
|
0d6c601365 | ||
|
|
5460bf9989 | ||
|
|
eb3bfffad4 | ||
|
|
e2d03ce38c | ||
|
|
32f9dc6383 | ||
|
|
c529529f84 | ||
|
|
13bac9c91a | ||
|
|
fe53af4819 | ||
|
|
e82c5a9a28 | ||
|
|
3236f228fb | ||
|
|
0e0e7a4a4b | ||
|
|
10a3d6c54e | ||
|
|
832b8e252e | ||
|
|
040f551c57 | ||
|
|
cc818f8032 | ||
|
|
d5337b41f4 | ||
|
|
9f7a76d6c0 | ||
|
|
6a16db4b92 | ||
|
|
9ad6588f3e | ||
|
|
fb6bf0b35e | ||
|
|
f80343b875 | ||
|
|
9b805e1cc4 | ||
|
|
2e0d5d2308 | ||
|
|
38e0dc9ccd | ||
|
|
40aeaa120d | ||
|
|
6a64177589 | ||
|
|
5dc47905a9 | ||
|
|
dc0044882c | ||
|
|
45ae7dc653 | ||
|
|
cfe0fc4ca0 | ||
|
|
f38b91e183 | ||
|
|
7088fc6c73 | ||
|
|
caf7d1fb5c | ||
|
|
9f930bd357 | ||
|
|
781cc0fb6f | ||
|
|
7644c891b4 | ||
|
|
357a3fc0eb | ||
|
|
f881bac010 | ||
|
|
1c77277179 | ||
|
|
dbd72df3a4 | ||
|
|
129fe1e350 | ||
|
|
214a6c6cf1 | ||
|
|
3f249aba6d | ||
|
|
d70a6b37e5 | ||
|
|
ba895b1a59 | ||
|
|
6c2efcb8db | ||
|
|
9e03d49637 | ||
|
|
5c6ec1caac | ||
|
|
44a251a406 | ||
|
|
be972907a3 | ||
|
|
07ff24f3aa | ||
|
|
5792a80a8c | ||
|
|
df44b41aaa | ||
|
|
db039db7f5 | ||
|
|
c1a3936b61 | ||
|
|
b1d537b49a | ||
|
|
9f5713e802 | ||
|
|
6c6868997a | ||
|
|
a7dda4c108 | ||
|
|
bc43bf378d | ||
|
|
3b669e7f2b | ||
|
|
e48da96886 | ||
|
|
20249bb723 | ||
|
|
205affa0eb | ||
|
|
7409ce8aec | ||
|
|
c075a5d694 | ||
|
|
34c676ba7a | ||
|
|
816a5f793f | ||
|
|
ca2099e69d | ||
|
|
1392d868b1 | ||
|
|
e993acec31 | ||
|
|
611e616010 | ||
|
|
b286c0ae3f | ||
|
|
81a61f8dbd | ||
|
|
752e449e38 | ||
|
|
5d419a0211 | ||
|
|
8b168981aa | ||
|
|
724dd665ec | ||
|
|
fc258ea74f | ||
|
|
abd9e195ac | ||
|
|
9d78b69cd3 | ||
|
|
a531f3f36d | ||
|
|
bb3382311d | ||
|
|
ad545d0cc9 | ||
|
|
ac244b1458 | ||
|
|
f202536b65 | ||
|
|
405cc3f610 | ||
|
|
878c1b8c2d |
6
.github/VOUCHED.td
vendored
6
.github/VOUCHED.td
vendored
@@ -10,6 +10,9 @@
|
||||
adamdotdevin
|
||||
-agusbasari29 AI PR slop
|
||||
ariane-emory
|
||||
-atharvau AI review spamming literally every PR
|
||||
-danieljoshuanazareth
|
||||
-danieljoshuanazareth
|
||||
edemaine
|
||||
-florianleibert
|
||||
fwang
|
||||
@@ -17,8 +20,9 @@ iamdavidhill
|
||||
jayair
|
||||
kitlangton
|
||||
kommander
|
||||
-opencode2026
|
||||
r44vc0rp
|
||||
rekram1-node
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
thdxr
|
||||
-OpenCode2026
|
||||
-OpenCodeEngineer bot that spams issues
|
||||
|
||||
24
.github/workflows/close-issues.yml
vendored
Normal file
24
.github/workflows/close-issues.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: close-issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 2 * * *" # Daily at 2:00 AM
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
close:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Close stale issues
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: bun script/github/close-issues.ts
|
||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -11,7 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
|
||||
2
.github/workflows/nix-hashes.yml
vendored
2
.github/workflows/nix-hashes.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
||||
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
||||
|
||||
# Extract hash from build log with portability
|
||||
HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
||||
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
||||
|
||||
if [ -z "$HASH" ]; then
|
||||
echo "::error::Failed to compute hash for ${SYSTEM}"
|
||||
|
||||
33
.github/workflows/stale-issues.yml
vendored
33
.github/workflows/stale-issues.yml
vendored
@@ -1,33 +0,0 @@
|
||||
name: stale-issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *" # Daily at 1:30 AM
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DAYS_BEFORE_STALE: 90
|
||||
DAYS_BEFORE_CLOSE: 7
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
|
||||
days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
|
||||
stale-issue-label: "stale"
|
||||
close-issue-message: |
|
||||
[automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
|
||||
|
||||
Feel free to reopen if you still need this!
|
||||
stale-issue-message: |
|
||||
[automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
|
||||
|
||||
It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
|
||||
remove-stale-when-updated: true
|
||||
exempt-issue-labels: "pinned,security,feature-request,on-hold"
|
||||
start-date: "2025-12-27"
|
||||
2
.github/workflows/vouch-manage-by-issue.yml
vendored
2
.github/workflows/vouch-manage-by-issue.yml
vendored
@@ -33,6 +33,6 @@ jobs:
|
||||
with:
|
||||
issue-id: ${{ github.event.issue.number }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
roles: admin,maintain
|
||||
roles: admin,maintain,write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
21
.opencode/command/changelog.md
Normal file
21
.opencode/command/changelog.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
model: opencode/kimi-k2.5
|
||||
---
|
||||
|
||||
create UPCOMING_CHANGELOG.md
|
||||
|
||||
it should have sections
|
||||
|
||||
```
|
||||
# TUI
|
||||
|
||||
# Desktop
|
||||
|
||||
# Core
|
||||
|
||||
# Misc
|
||||
```
|
||||
|
||||
go through each PR merged since the last tag
|
||||
|
||||
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.
|
||||
@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
|
||||
|
||||
---
|
||||
|
||||
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
||||
|
||||
---
|
||||
|
||||
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
|
||||
6
flake.lock
generated
6
flake.lock
generated
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1772091128,
|
||||
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=",
|
||||
"lastModified": 1773909469,
|
||||
"narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3f0336406035444b4a24b942788334af5f906259",
|
||||
"rev": "7149c06513f335be57f26fcbbbe34afda923882b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -496,7 +496,6 @@ async function subscribeSessionEvents() {
|
||||
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
|
||||
todoread: ["Todo", "\x1b[33m\x1b[1m"],
|
||||
bash: ["Bash", "\x1b[31m\x1b[1m"],
|
||||
edit: ["Edit", "\x1b[32m\x1b[1m"],
|
||||
glob: ["Glob", "\x1b[34m\x1b[1m"],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-P0RJfQF8APTYVGP6hLJRrOkRSl5nVDNxdcGcZECPPJE=",
|
||||
"aarch64-linux": "sha256-ZtMjTcd35X3JhJIdn3DilFsp7i/IZIcNaKZFnSzW/nk=",
|
||||
"aarch64-darwin": "sha256-Uw/okFDRxxKQMfEsj8MXuHyhpugxZGgIKtu89Getlz8=",
|
||||
"x86_64-darwin": "sha256-ZySIgT1HbWZWnaQ0W0eURKC43BTupRmmply92JDFPWA="
|
||||
"x86_64-linux": "sha256-0VwVhbOtK1r16cVSZcHaI/8fUPc6aYQiUnh7Q3bSHqs=",
|
||||
"aarch64-linux": "sha256-z5b234MIS0QqDYLopyaT2hd9CAtEbcSo28y0eMfPsBs=",
|
||||
"aarch64-darwin": "sha256-sn16mtZIhF9OSBrfAHpDCJO6Nt19mdoxvYAOnwWgwDk=",
|
||||
"x86_64-darwin": "sha256-FaZpwGuWzfypA28ct86xAnW2RuFFUiXjPkr5wVTLN/o="
|
||||
}
|
||||
}
|
||||
|
||||
11
package.json
11
package.json
@@ -4,7 +4,7 @@
|
||||
"description": "AI-powered development tool",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.10",
|
||||
"packageManager": "bun@1.3.11",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||
@@ -26,7 +26,7 @@
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/platform-node": "4.0.0-beta.35",
|
||||
"@types/bun": "1.3.9",
|
||||
"@types/bun": "1.3.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
@@ -46,7 +46,7 @@
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.35",
|
||||
"ai": "5.0.124",
|
||||
"ai": "6.0.138",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
"fuzzysort": "3.1.0",
|
||||
@@ -112,8 +112,7 @@
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
||||
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/provider-utils@4.0.21": "patches/@ai-sdk%2Fprovider-utils@4.0.21.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,9 +175,9 @@ export async function runTerminal(page: Page, input: { cmd: string; token: strin
|
||||
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function openPalette(page: Page) {
|
||||
export async function openPalette(page: Page, key = "K") {
|
||||
await defocus(page)
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
await page.keyboard.press(`${modKey}+${key}`)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openPalette } from "../actions"
|
||||
import { closeDialog, openPalette } from "../actions"
|
||||
|
||||
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
@@ -9,3 +9,12 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openPalette(page, "P")
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -108,7 +108,10 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
|
||||
await page.keyboard.type(draft)
|
||||
await wait(page, draft)
|
||||
|
||||
await edge(page, "start")
|
||||
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
|
||||
await prompt.fill("")
|
||||
await wait(page, "")
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
|
||||
@@ -119,7 +122,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, draft)
|
||||
await wait(page, "")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const initialKeybind = await keybindButton.textContent()
|
||||
expect(initialKeybind).toContain("P")
|
||||
expect(initialKeybind).toContain("K")
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
|
||||
import {
|
||||
defocus,
|
||||
cleanupSession,
|
||||
cleanupTestProject,
|
||||
closeSidebar,
|
||||
createTestProject,
|
||||
hoverSessionItem,
|
||||
openSidebar,
|
||||
waitSession,
|
||||
} from "../actions"
|
||||
import { projectSwitchSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
@@ -37,3 +47,72 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
||||
await cleanupSession({ sdk, sessionID: two.id })
|
||||
}
|
||||
})
|
||||
|
||||
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const slug = dirSlug(other)
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async () => {
|
||||
await openSidebar(page)
|
||||
|
||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||
const card = page.locator('[data-component="hover-card-content"]')
|
||||
|
||||
await expect(project).toBeVisible()
|
||||
await project.hover()
|
||||
await expect(card.getByText(/recent sessions/i)).toBeVisible()
|
||||
|
||||
await page.mouse.down()
|
||||
await expect(card).toHaveCount(0)
|
||||
await page.mouse.up()
|
||||
|
||||
await waitSession(page, { directory: other })
|
||||
await expect(card).toHaveCount(0)
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
||||
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const slug = dirSlug(other)
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async () => {
|
||||
await openSidebar(page)
|
||||
await defocus(page)
|
||||
|
||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||
|
||||
await expect(project).toBeVisible()
|
||||
|
||||
let hit = false
|
||||
for (let i = 0; i < 20; i++) {
|
||||
hit = await project.evaluate((el) => {
|
||||
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
|
||||
})
|
||||
if (hit) break
|
||||
await page.keyboard.press("Tab")
|
||||
}
|
||||
|
||||
expect(hit).toBe(true)
|
||||
|
||||
await page.keyboard.press("Enter")
|
||||
await waitSession(page, { directory: other })
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { runTerminal, waitTerminalReady } from "../actions"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { terminalSelector } from "../selectors"
|
||||
import { dropdownMenuContentSelector, terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
||||
|
||||
type State = {
|
||||
@@ -130,3 +130,39 @@ test("closing the active terminal tab falls back to the previous tab", async ({
|
||||
.toEqual({ count: 1, first: true })
|
||||
})
|
||||
})
|
||||
|
||||
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
|
||||
await withProject(async ({ directory, gotoSession }) => {
|
||||
const key = workspacePersistKey(directory, "terminal")
|
||||
const rename = `E2E term ${Date.now()}`
|
||||
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
|
||||
|
||||
await gotoSession()
|
||||
await open(page)
|
||||
|
||||
await expect(tab).toContainText(/Terminal 1/)
|
||||
await tab.click({ button: "right" })
|
||||
|
||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||
await expect(menu).toBeVisible()
|
||||
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
|
||||
await expect(menu).toHaveCount(0)
|
||||
|
||||
const input = page.locator('#terminal-panel input[type="text"]').first()
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(rename)
|
||||
await input.press("Enter")
|
||||
|
||||
await expect(input).toHaveCount(0)
|
||||
await expect(tab).toContainText(rename)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
return state?.all[0]?.title
|
||||
},
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toBe(rename)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.2",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -51,9 +51,11 @@
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solid-primitives/timer": "1.4.4",
|
||||
"@solid-primitives/websocket": "1.3.1",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
|
||||
@@ -6,9 +6,10 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||
import { File } from "@opencode-ai/ui/file"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { type Duration, Effect } from "effect"
|
||||
import {
|
||||
type Component,
|
||||
@@ -31,7 +32,7 @@ import { FileProvider } from "@/context/file"
|
||||
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||
import { GlobalSyncProvider } from "@/context/global-sync"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, useLanguage } from "@/context/language"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { NotificationProvider } from "@/context/notification"
|
||||
@@ -81,6 +82,11 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
|
||||
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
||||
}
|
||||
|
||||
function QueryProvider(props: ParentProps) {
|
||||
const client = new QueryClient()
|
||||
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
function AppShellProviders(props: ParentProps) {
|
||||
return (
|
||||
<SettingsProvider>
|
||||
@@ -124,7 +130,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function AppBaseProviders(props: ParentProps) {
|
||||
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
return (
|
||||
<MetaProvider>
|
||||
<Font />
|
||||
@@ -133,14 +139,16 @@ export function AppBaseProviders(props: ParentProps) {
|
||||
void window.api?.setTitlebar?.({ mode })
|
||||
}}
|
||||
>
|
||||
<LanguageProvider>
|
||||
<LanguageProvider locale={props.locale}>
|
||||
<UiI18nBridge>
|
||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||
<DialogProvider>
|
||||
<MarkedProviderWithNativeParser>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProviderWithNativeParser>
|
||||
</DialogProvider>
|
||||
<QueryProvider>
|
||||
<DialogProvider>
|
||||
<MarkedProviderWithNativeParser>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProviderWithNativeParser>
|
||||
</DialogProvider>
|
||||
</QueryProvider>
|
||||
</ErrorBoundary>
|
||||
</UiI18nBridge>
|
||||
</LanguageProvider>
|
||||
|
||||
@@ -55,7 +55,7 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string;
|
||||
<Tooltip value={props.tip} placement="top">
|
||||
<div
|
||||
classList={{
|
||||
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] bg-white/5 px-0.5 py-1 text-center": true,
|
||||
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
|
||||
"col-span-2": !!props.wide,
|
||||
}}
|
||||
>
|
||||
@@ -363,11 +363,7 @@ export function DebugBar() {
|
||||
return (
|
||||
<aside
|
||||
aria-label={language.t("debugBar.ariaLabel")}
|
||||
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||
style={{
|
||||
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
|
||||
"border-color": "color-mix(in srgb, white 14%, transparent)",
|
||||
}}
|
||||
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||
>
|
||||
<div class="grid grid-cols-5 gap-px font-mono">
|
||||
<Cell
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
@@ -9,13 +9,12 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { DialogSelectModel } from "./dialog-select-model"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
|
||||
export function DialogConnectProvider(props: { provider: string }) {
|
||||
@@ -35,15 +34,25 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
})
|
||||
|
||||
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
|
||||
const methods = createMemo(
|
||||
() =>
|
||||
globalSync.data.provider_auth[props.provider] ?? [
|
||||
{
|
||||
type: "api",
|
||||
label: language.t("provider.connect.method.apiKey"),
|
||||
},
|
||||
],
|
||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||
{
|
||||
type: "api" as const,
|
||||
label: language.t("provider.connect.method.apiKey"),
|
||||
},
|
||||
])
|
||||
const [auth] = createResource(
|
||||
() => props.provider,
|
||||
async () => {
|
||||
const cached = globalSync.data.provider_auth[props.provider]
|
||||
if (cached) return cached
|
||||
const res = await globalSDK.client.provider.auth()
|
||||
if (!alive.value) return fallback()
|
||||
globalSync.set("provider_auth", res.data ?? {})
|
||||
return res.data?.[props.provider] ?? fallback()
|
||||
},
|
||||
)
|
||||
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
|
||||
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
@@ -178,7 +187,11 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
index: 0,
|
||||
})
|
||||
|
||||
const prompts = createMemo(() => method()?.prompts ?? [])
|
||||
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
||||
const value = method()
|
||||
if (value?.type !== "oauth") return []
|
||||
return value.prompts ?? []
|
||||
})
|
||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||
if (!prompt.when) return true
|
||||
const actual = value[prompt.when.key]
|
||||
@@ -297,8 +310,12 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
listRef?.onKeyDown(e)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let auto = false
|
||||
createEffect(() => {
|
||||
if (auto) return
|
||||
if (loading()) return
|
||||
if (methods().length === 1) {
|
||||
auto = true
|
||||
selectMethod(0)
|
||||
}
|
||||
})
|
||||
@@ -574,6 +591,14 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.methodIndex === undefined}>
|
||||
<MethodSelection />
|
||||
</Match>
|
||||
|
||||
@@ -34,7 +34,6 @@ export type FormState = {
|
||||
apiKey: string
|
||||
models: ModelRow[]
|
||||
headers: HeaderRow[]
|
||||
saving: boolean
|
||||
err: {
|
||||
providerID?: string
|
||||
name?: string
|
||||
|
||||
@@ -16,7 +16,6 @@ describe("validateCustomProvider", () => {
|
||||
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
|
||||
{ row: "h1", key: "", value: "", err: {} },
|
||||
],
|
||||
saving: false,
|
||||
err: {},
|
||||
},
|
||||
t,
|
||||
@@ -60,7 +59,6 @@ describe("validateCustomProvider", () => {
|
||||
{ row: "h0", key: "Authorization", value: "one", err: {} },
|
||||
{ row: "h1", key: "authorization", value: "two", err: {} },
|
||||
],
|
||||
saving: false,
|
||||
err: {},
|
||||
},
|
||||
t,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { batch, For } from "solid-js"
|
||||
@@ -31,7 +32,6 @@ export function DialogCustomProvider(props: Props) {
|
||||
apiKey: "",
|
||||
models: [modelRow()],
|
||||
headers: [headerRow()],
|
||||
saving: false,
|
||||
err: {},
|
||||
})
|
||||
|
||||
@@ -116,48 +116,49 @@ export function DialogCustomProvider(props: Props) {
|
||||
return output.result
|
||||
}
|
||||
|
||||
const save = async (e: SubmitEvent) => {
|
||||
e.preventDefault()
|
||||
if (form.saving) return
|
||||
const saveMutation = useMutation(() => ({
|
||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||
|
||||
const result = validate()
|
||||
if (!result) return
|
||||
|
||||
setForm("saving", true)
|
||||
|
||||
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||
|
||||
const auth = result.key
|
||||
? globalSDK.client.auth.set({
|
||||
if (result.key) {
|
||||
await globalSDK.client.auth.set({
|
||||
providerID: result.providerID,
|
||||
auth: {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
},
|
||||
})
|
||||
: Promise.resolve()
|
||||
}
|
||||
|
||||
auth
|
||||
.then(() =>
|
||||
globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }),
|
||||
)
|
||||
.then(() => {
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
||||
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
||||
})
|
||||
await globalSync.updateConfig({
|
||||
provider: { [result.providerID]: result.config },
|
||||
disabled_providers: nextDisabled,
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
.finally(() => {
|
||||
setForm("saving", false)
|
||||
return result
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
||||
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
},
|
||||
}))
|
||||
|
||||
const save = (e: SubmitEvent) => {
|
||||
e.preventDefault()
|
||||
if (saveMutation.isPending) return
|
||||
|
||||
const result = validate()
|
||||
if (!result) return
|
||||
saveMutation.mutate(result)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -312,8 +313,14 @@ export function DialogCustomProvider(props: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
|
||||
{form.saving ? language.t("common.saving") : language.t("common.submit")}
|
||||
<Button
|
||||
class="w-auto self-start"
|
||||
type="submit"
|
||||
size="large"
|
||||
variant="primary"
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
@@ -28,7 +29,6 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
color: props.project.icon?.color || "pink",
|
||||
iconUrl: props.project.icon?.override || "",
|
||||
startup: props.project.commands?.start ?? "",
|
||||
saving: false,
|
||||
dragOver: false,
|
||||
iconHover: false,
|
||||
})
|
||||
@@ -71,38 +71,37 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
setStore("iconUrl", "")
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
const saveMutation = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||
const start = store.startup.trim()
|
||||
|
||||
await Promise.resolve()
|
||||
.then(async () => {
|
||||
setStore("saving", true)
|
||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await globalSDK.client.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
icon: { color: store.color, override: store.iconUrl },
|
||||
commands: { start },
|
||||
})
|
||||
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
|
||||
globalSync.project.meta(props.project.worktree, {
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await globalSDK.client.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
icon: { color: store.color, override: store.iconUrl || undefined },
|
||||
commands: { start: start || undefined },
|
||||
icon: { color: store.color, override: store.iconUrl },
|
||||
commands: { start },
|
||||
})
|
||||
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
|
||||
globalSync.project.meta(props.project.worktree, {
|
||||
name,
|
||||
icon: { color: store.color, override: store.iconUrl || undefined },
|
||||
commands: { start: start || undefined },
|
||||
})
|
||||
.finally(() => {
|
||||
setStore("saving", false)
|
||||
})
|
||||
dialog.close()
|
||||
},
|
||||
}))
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
if (saveMutation.isPending) return
|
||||
saveMutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -246,8 +245,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="large" disabled={store.saving}>
|
||||
{store.saving ? language.t("common.saving") : language.t("common.save")}
|
||||
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, createMemo, createSignal, Show } from "solid-js"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Component, createMemo, Show } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
@@ -17,7 +18,6 @@ export const DialogSelectMcp: Component = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
|
||||
const items = createMemo(() =>
|
||||
Object.entries(sync.data.mcp ?? {})
|
||||
@@ -25,10 +25,8 @@ export const DialogSelectMcp: Component = () => {
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
|
||||
const toggle = async (name: string) => {
|
||||
if (loading()) return
|
||||
setLoading(name)
|
||||
try {
|
||||
const toggle = useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
@@ -38,10 +36,8 @@ export const DialogSelectMcp: Component = () => {
|
||||
|
||||
const result = await sdk.client.mcp.status()
|
||||
if (result.data) sync.set("mcp", result.data)
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||
const totalCount = createMemo(() => items().length)
|
||||
@@ -59,7 +55,8 @@ export const DialogSelectMcp: Component = () => {
|
||||
filterKeys={["name", "status"]}
|
||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||
onSelect={(x) => {
|
||||
if (x) toggle(x.name)
|
||||
if (!x || toggle.isPending) return
|
||||
toggle.mutate(x.name)
|
||||
}}
|
||||
>
|
||||
{(i) => {
|
||||
@@ -83,7 +80,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
<Show when={statusLabel()}>
|
||||
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
||||
</Show>
|
||||
<Show when={loading() === i.name}>
|
||||
<Show when={toggle.isPending && toggle.variables === i.name}>
|
||||
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -92,7 +89,14 @@ export const DialogSelectMcp: Component = () => {
|
||||
</Show>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={toggle.isPending && toggle.variables === i.name}
|
||||
onChange={() => {
|
||||
if (toggle.isPending) return
|
||||
toggle.mutate(i.name)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
||||
@@ -186,7 +187,6 @@ export function DialogSelectServer() {
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined as boolean | undefined,
|
||||
@@ -198,7 +198,6 @@ export function DialogSelectServer() {
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
busy: false,
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
})
|
||||
@@ -209,7 +208,6 @@ export function DialogSelectServer() {
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined,
|
||||
@@ -224,10 +222,78 @@ export function DialogSelectServer() {
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
busy: false,
|
||||
})
|
||||
}
|
||||
|
||||
const addMutation = useMutation(() => ({
|
||||
mutationFn: async (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) {
|
||||
resetAdd()
|
||||
return
|
||||
}
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
http: { url: normalized },
|
||||
}
|
||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||
const result = await checkServerHealth(conn.http)
|
||||
if (!result.healthy) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
resetAdd()
|
||||
await select(conn, true)
|
||||
},
|
||||
}))
|
||||
|
||||
const editMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
|
||||
if (input.original.type !== "http") return
|
||||
const normalized = normalizeServerUrl(input.value)
|
||||
if (!normalized) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
const name = store.editServer.name.trim() || undefined
|
||||
const username = store.editServer.username || undefined
|
||||
const password = store.editServer.password || undefined
|
||||
const existingName = input.original.displayName
|
||||
if (
|
||||
normalized === input.original.http.url &&
|
||||
name === existingName &&
|
||||
username === input.original.http.username &&
|
||||
password === input.original.http.password
|
||||
) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
displayName: name,
|
||||
http: { url: normalized, username, password },
|
||||
}
|
||||
const result = await checkServerHealth(conn.http)
|
||||
if (!result.healthy) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (normalized === input.original.http.url) {
|
||||
server.add(conn)
|
||||
} else {
|
||||
replaceServer(input.original, conn)
|
||||
}
|
||||
|
||||
resetEdit()
|
||||
},
|
||||
}))
|
||||
|
||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||
const active = server.key
|
||||
const newConn = server.add(next)
|
||||
@@ -296,7 +362,7 @@ export function DialogSelectServer() {
|
||||
}
|
||||
|
||||
const handleAddChange = (value: string) => {
|
||||
if (store.addServer.adding) return
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { url: value, error: "" })
|
||||
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
@@ -304,12 +370,12 @@ export function DialogSelectServer() {
|
||||
}
|
||||
|
||||
const handleAddNameChange = (value: string) => {
|
||||
if (store.addServer.adding) return
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { name: value, error: "" })
|
||||
}
|
||||
|
||||
const handleAddUsernameChange = (value: string) => {
|
||||
if (store.addServer.adding) return
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { username: value, error: "" })
|
||||
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
@@ -317,7 +383,7 @@ export function DialogSelectServer() {
|
||||
}
|
||||
|
||||
const handleAddPasswordChange = (value: string) => {
|
||||
if (store.addServer.adding) return
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { password: value, error: "" })
|
||||
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
@@ -325,7 +391,7 @@ export function DialogSelectServer() {
|
||||
}
|
||||
|
||||
const handleEditChange = (value: string) => {
|
||||
if (store.editServer.busy) return
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { value, error: "" })
|
||||
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
@@ -333,12 +399,12 @@ export function DialogSelectServer() {
|
||||
}
|
||||
|
||||
const handleEditNameChange = (value: string) => {
|
||||
if (store.editServer.busy) return
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { name: value, error: "" })
|
||||
}
|
||||
|
||||
const handleEditUsernameChange = (value: string) => {
|
||||
if (store.editServer.busy) return
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { username: value, error: "" })
|
||||
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
@@ -346,85 +412,13 @@ export function DialogSelectServer() {
|
||||
}
|
||||
|
||||
const handleEditPasswordChange = (value: string) => {
|
||||
if (store.editServer.busy) return
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { password: value, error: "" })
|
||||
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleAdd(value: string) {
|
||||
if (store.addServer.adding) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) {
|
||||
resetAdd()
|
||||
return
|
||||
}
|
||||
|
||||
setStore("addServer", { adding: true, error: "" })
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
http: { url: normalized },
|
||||
}
|
||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||
const result = await checkServerHealth(conn.http)
|
||||
setStore("addServer", { adding: false })
|
||||
if (!result.healthy) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
resetAdd()
|
||||
await select(conn, true)
|
||||
}
|
||||
|
||||
async function handleEdit(original: ServerConnection.Any, value: string) {
|
||||
if (store.editServer.busy || original.type !== "http") return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
const name = store.editServer.name.trim() || undefined
|
||||
const username = store.editServer.username || undefined
|
||||
const password = store.editServer.password || undefined
|
||||
const existingName = original.displayName
|
||||
if (
|
||||
normalized === original.http.url &&
|
||||
name === existingName &&
|
||||
username === original.http.username &&
|
||||
password === original.http.password
|
||||
) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
setStore("editServer", { busy: true, error: "" })
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
displayName: name,
|
||||
http: { url: normalized, username, password },
|
||||
}
|
||||
const result = await checkServerHealth(conn.http)
|
||||
setStore("editServer", { busy: false })
|
||||
if (!result.healthy) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (normalized === original.http.url) {
|
||||
server.add(conn)
|
||||
} else {
|
||||
replaceServer(original, conn)
|
||||
}
|
||||
|
||||
resetEdit()
|
||||
}
|
||||
|
||||
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||
if (store.editServer.id) return "edit"
|
||||
if (store.addServer.showForm) return "add"
|
||||
@@ -464,23 +458,26 @@ export function DialogSelectServer() {
|
||||
password: conn.http.password ?? "",
|
||||
error: "",
|
||||
status: store.status[ServerConnection.key(conn)]?.healthy,
|
||||
busy: false,
|
||||
})
|
||||
}
|
||||
|
||||
const submitForm = () => {
|
||||
if (mode() === "add") {
|
||||
void handleAdd(store.addServer.url)
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { error: "" })
|
||||
addMutation.mutate(store.addServer.url)
|
||||
return
|
||||
}
|
||||
const original = editing()
|
||||
if (!original) return
|
||||
void handleEdit(original, store.editServer.value)
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { error: "" })
|
||||
editMutation.mutate({ original, value: store.editServer.value })
|
||||
}
|
||||
|
||||
const isFormMode = createMemo(() => mode() !== "list")
|
||||
const isAddMode = createMemo(() => mode() === "add")
|
||||
const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
|
||||
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
||||
|
||||
const formTitle = createMemo(() => {
|
||||
if (!isFormMode()) return language.t("dialog.server.title")
|
||||
|
||||
@@ -572,6 +572,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const open = recent()
|
||||
const seen = new Set(open)
|
||||
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
|
||||
if (!query.trim()) return [...agents, ...pinned]
|
||||
const paths = await files.searchFilesAndDirectories(query)
|
||||
const fileOptions: AtOption[] = paths
|
||||
.filter((path) => !seen.has(path))
|
||||
@@ -1043,7 +1044,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
editor: () => editorRef,
|
||||
isDialogActive: () => !!dialog.active,
|
||||
setDraggingType: (type) => setStore("draggingType", type),
|
||||
@@ -1388,11 +1389,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
class="hidden"
|
||||
onChange={(e) => {
|
||||
const list = e.currentTarget.files
|
||||
if (list) {
|
||||
for (const file of Array.from(list)) {
|
||||
void addAttachment(file)
|
||||
}
|
||||
}
|
||||
if (list) void addAttachments(Array.from(list))
|
||||
e.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
@@ -1501,7 +1498,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()!.provider.id}
|
||||
id={local.model.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
@@ -1533,7 +1530,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()!.provider.id}
|
||||
id={local.model.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
|
||||
@@ -71,6 +71,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
|
||||
const addAttachment = (file: File) => add(file)
|
||||
|
||||
const addAttachments = async (files: File[], toast = true) => {
|
||||
let found = false
|
||||
|
||||
for (const file of files) {
|
||||
const ok = await add(file, false)
|
||||
if (ok) found = true
|
||||
}
|
||||
|
||||
if (!found && files.length > 0 && toast) warn()
|
||||
return found
|
||||
}
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
const current = prompt.current()
|
||||
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||
@@ -84,18 +96,14 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const items = Array.from(clipboardData.items)
|
||||
const fileItems = items.filter((item) => item.kind === "file")
|
||||
const files = Array.from(clipboardData.items).flatMap((item) => {
|
||||
if (item.kind !== "file") return []
|
||||
const file = item.getAsFile()
|
||||
return file ? [file] : []
|
||||
})
|
||||
|
||||
if (fileItems.length > 0) {
|
||||
let found = false
|
||||
for (const item of fileItems) {
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
const ok = await add(file, false)
|
||||
if (ok) found = true
|
||||
}
|
||||
if (!found) warn()
|
||||
if (files.length > 0) {
|
||||
await addAttachments(files)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -169,12 +177,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const dropped = event.dataTransfer?.files
|
||||
if (!dropped) return
|
||||
|
||||
let found = false
|
||||
for (const file of Array.from(dropped)) {
|
||||
const ok = await add(file, false)
|
||||
if (ok) found = true
|
||||
}
|
||||
if (!found && dropped.length > 0) warn()
|
||||
await addAttachments(Array.from(dropped))
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -191,6 +194,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
|
||||
return {
|
||||
addAttachment,
|
||||
addAttachments,
|
||||
removeAttachment,
|
||||
handlePaste,
|
||||
}
|
||||
|
||||
@@ -49,6 +49,32 @@ describe("buildRequestParts", () => {
|
||||
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps multiple uploaded attachments in order", () => {
|
||||
const result = buildRequestParts({
|
||||
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
|
||||
context: [],
|
||||
images: [
|
||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
{
|
||||
type: "image",
|
||||
id: "img_2",
|
||||
filename: "b.pdf",
|
||||
mime: "application/pdf",
|
||||
dataUrl: "data:application/pdf;base64,BBB",
|
||||
},
|
||||
],
|
||||
text: "check these",
|
||||
messageID: "msg_multi",
|
||||
sessionID: "ses_multi",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
|
||||
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
|
||||
})
|
||||
|
||||
test("deduplicates context files when prompt already includes same path", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("prompt-input history", () => {
|
||||
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
|
||||
const value = "a\nb\nc"
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
|
||||
@@ -135,11 +135,14 @@ describe("prompt-input history", () => {
|
||||
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
|
||||
|
||||
@@ -27,7 +27,7 @@ export function canNavigateHistoryAtCursor(direction: "up" | "down", text: strin
|
||||
const atStart = position === 0
|
||||
const atEnd = position === text.length
|
||||
if (inHistory) return atStart || atEnd
|
||||
if (direction === "up") return position === 0
|
||||
if (direction === "up") return position === 0 && text.length === 0
|
||||
return position === text.length
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
|
||||
|
||||
function user(id: string): Message {
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
sessionID: "session-1",
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
function assistant(id: string, parentID: string): Message {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
sessionID: "session-1",
|
||||
parentID,
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
describe("findAssistantMessages", () => {
|
||||
test("normal ordering: assistant after user in array → found via forward scan", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("clock skew: assistant before user in array → found via backward scan", () => {
|
||||
// When client clock is ahead, user ID sorts after assistant ID,
|
||||
// so assistant appears earlier in the ID-sorted message array
|
||||
const messages = [assistant("a1", "u1"), user("u1")]
|
||||
const result = findAssistantMessages(messages, 1, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("no assistant messages → returns empty array", () => {
|
||||
const messages = [user("u1"), user("u2")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("multiple assistant messages with matching parentID → all found", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].id).toBe("a1")
|
||||
expect(result[1].id).toBe("a2")
|
||||
})
|
||||
|
||||
test("does not return assistant messages with different parentID", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("stops forward scan at next user message", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("stops backward scan at previous user message", () => {
|
||||
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
|
||||
const result = findAssistantMessages(messages, 3, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("invalid index returns empty array", () => {
|
||||
const messages = [user("u1")]
|
||||
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
|
||||
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -267,14 +267,14 @@ export function SessionContextTab() {
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
class="@container h-full pb-10"
|
||||
class="@container h-full"
|
||||
viewportRef={(el) => {
|
||||
scroll = el
|
||||
restoreScroll()
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div class="px-6 pt-4 flex flex-col gap-10">
|
||||
<div class="px-6 pt-4 pb-10 flex flex-col gap-10">
|
||||
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
|
||||
<For each={stats}>
|
||||
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { focusTerminalById } from "@/pages/session/helpers"
|
||||
@@ -134,6 +135,7 @@ export function SessionHeader() {
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const { params, view } = useSessionLayout()
|
||||
@@ -151,6 +153,10 @@ export function SessionHeader() {
|
||||
})
|
||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||
const os = createMemo(() => detectOS(platform))
|
||||
const search = createMemo(() => platform.platform !== "desktop" || settings.general.showSearch())
|
||||
const tree = createMemo(() => platform.platform !== "desktop" || settings.general.showFileTree())
|
||||
const term = createMemo(() => platform.platform !== "desktop" || settings.general.showTerminal())
|
||||
const status = createMemo(() => platform.platform !== "desktop" || settings.general.showStatus())
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
finder: true,
|
||||
@@ -267,35 +273,37 @@ export function SessionHeader() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={centerMount()}>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
|
||||
onClick={() => command.trigger("file.open")}
|
||||
aria-label={language.t("session.header.searchFiles")}
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center overflow-visible">
|
||||
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
|
||||
{language.t("session.header.search.placeholder", {
|
||||
project: name(),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={search()}>
|
||||
<Show when={centerMount()}>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
|
||||
onClick={() => command.trigger("file.open")}
|
||||
aria-label={language.t("session.header.searchFiles")}
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center overflow-visible">
|
||||
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
|
||||
{language.t("session.header.search.placeholder", {
|
||||
project: name(),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={hotkey()}>
|
||||
{(keybind) => (
|
||||
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
|
||||
{keybind()}
|
||||
</Keybind>
|
||||
)}
|
||||
</Show>
|
||||
</Button>
|
||||
</Portal>
|
||||
)}
|
||||
<Show when={hotkey()}>
|
||||
{(keybind) => (
|
||||
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
|
||||
{keybind()}
|
||||
</Keybind>
|
||||
)}
|
||||
</Show>
|
||||
</Button>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={rightMount()}>
|
||||
{(mount) => (
|
||||
@@ -415,24 +423,28 @@ export function SessionHeader() {
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1">
|
||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopover />
|
||||
</Tooltip>
|
||||
<TooltipKeybind
|
||||
title={language.t("command.terminal.toggle")}
|
||||
keybind={command.keybind("terminal.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
||||
onClick={toggleTerminal}
|
||||
aria-label={language.t("command.terminal.toggle")}
|
||||
aria-expanded={view().terminal.opened()}
|
||||
aria-controls="terminal-panel"
|
||||
<Show when={status()}>
|
||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopover />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={term()}>
|
||||
<TooltipKeybind
|
||||
title={language.t("command.terminal.toggle")}
|
||||
keybind={command.keybind("terminal.toggle")}
|
||||
>
|
||||
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
||||
onClick={toggleTerminal}
|
||||
aria-label={language.t("command.terminal.toggle")}
|
||||
aria-expanded={view().terminal.opened()}
|
||||
aria-controls="terminal-panel"
|
||||
>
|
||||
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
|
||||
<div class="hidden md:flex items-center gap-1 shrink-0">
|
||||
<TooltipKeybind
|
||||
@@ -451,30 +463,32 @@ export function SessionHeader() {
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
<TooltipKeybind
|
||||
title={language.t("command.fileTree.toggle")}
|
||||
keybind={command.keybind("fileTree.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={() => layout.fileTree.toggle()}
|
||||
aria-label={language.t("command.fileTree.toggle")}
|
||||
aria-expanded={layout.fileTree.opened()}
|
||||
aria-controls="file-tree-panel"
|
||||
<Show when={tree()}>
|
||||
<TooltipKeybind
|
||||
title={language.t("command.fileTree.toggle")}
|
||||
keybind={command.keybind("fileTree.toggle")}
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4">
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
|
||||
classList={{
|
||||
"text-icon-strong": layout.fileTree.opened(),
|
||||
"text-icon-weak": !layout.fileTree.opened(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={() => layout.fileTree.toggle()}
|
||||
aria-label={language.t("command.fileTree.toggle")}
|
||||
aria-expanded={layout.fileTree.opened()}
|
||||
aria-controls="file-tree-panel"
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4">
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
|
||||
classList={{
|
||||
"text-icon-strong": layout.fileTree.opened(),
|
||||
"text-icon-weak": !layout.fileTree.opened(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
})
|
||||
let input: HTMLInputElement | undefined
|
||||
let blurFrame: number | undefined
|
||||
let editRequested = false
|
||||
|
||||
const isDefaultTitle = () => {
|
||||
const number = props.terminal.titleNumber
|
||||
@@ -168,8 +169,14 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
left: `${store.menuPosition.x}px`,
|
||||
top: `${store.menuPosition.y}px`,
|
||||
}}
|
||||
onCloseAutoFocus={(e) => {
|
||||
if (!editRequested) return
|
||||
e.preventDefault()
|
||||
editRequested = false
|
||||
requestAnimationFrame(() => edit())
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item onSelect={edit}>
|
||||
<DropdownMenu.Item onSelect={() => (editRequested = true)}>
|
||||
<Icon name="edit" class="w-4 h-4 mr-2" />
|
||||
{language.t("common.rename")}
|
||||
</DropdownMenu.Item>
|
||||
|
||||
@@ -1,27 +1,41 @@
|
||||
import { Component, Show, createMemo, createResource, type JSX } from "solid-js"
|
||||
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings, monoFontFamily } from "@/context/settings"
|
||||
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "./link"
|
||||
import { SettingsList } from "./settings-list"
|
||||
|
||||
let demoSoundState = {
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
timeout: undefined as NodeJS.Timeout | undefined,
|
||||
run: 0,
|
||||
}
|
||||
|
||||
type ThemeOption = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
|
||||
|
||||
function loadFont() {
|
||||
font ??= import("@opencode-ai/ui/font-loader")
|
||||
return font
|
||||
}
|
||||
|
||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||
const stopDemoSound = () => {
|
||||
demoSoundState.run += 1
|
||||
if (demoSoundState.cleanup) {
|
||||
demoSoundState.cleanup()
|
||||
}
|
||||
@@ -29,12 +43,19 @@ const stopDemoSound = () => {
|
||||
demoSoundState.cleanup = undefined
|
||||
}
|
||||
|
||||
const playDemoSound = (src: string | undefined) => {
|
||||
const playDemoSound = (id: string | undefined) => {
|
||||
stopDemoSound()
|
||||
if (!src) return
|
||||
if (!id) return
|
||||
|
||||
const run = ++demoSoundState.run
|
||||
demoSoundState.timeout = setTimeout(() => {
|
||||
demoSoundState.cleanup = playSound(src)
|
||||
void playSoundById(id).then((cleanup) => {
|
||||
if (demoSoundState.run !== run) {
|
||||
cleanup?.()
|
||||
return
|
||||
}
|
||||
demoSoundState.cleanup = cleanup
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
|
||||
@@ -44,11 +65,16 @@ export const SettingsGeneral: Component = () => {
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
|
||||
onMount(() => {
|
||||
void theme.loadThemes()
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
})
|
||||
|
||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
const check = () => {
|
||||
if (!platform.checkUpdate) return
|
||||
@@ -104,9 +130,7 @@ export const SettingsGeneral: Component = () => {
|
||||
.finally(() => setStore("checking", false))
|
||||
}
|
||||
|
||||
const themeOptions = createMemo(() =>
|
||||
Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
|
||||
)
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||
{ value: "system", label: language.t("theme.scheme.system") },
|
||||
@@ -143,7 +167,7 @@ export const SettingsGeneral: Component = () => {
|
||||
] as const
|
||||
const fontOptionsList = [...fontOptions]
|
||||
|
||||
const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const
|
||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||
|
||||
const soundSelectProps = (
|
||||
@@ -158,7 +182,7 @@ export const SettingsGeneral: Component = () => {
|
||||
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
||||
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
if (!option) return
|
||||
playDemoSound(option.src)
|
||||
playDemoSound(option.id === "none" ? undefined : option.id)
|
||||
},
|
||||
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
if (!option) return
|
||||
@@ -169,7 +193,7 @@ export const SettingsGeneral: Component = () => {
|
||||
}
|
||||
setEnabled(true)
|
||||
set(option.id)
|
||||
playDemoSound(option.src)
|
||||
playDemoSound(option.id)
|
||||
},
|
||||
variant: "secondary" as const,
|
||||
size: "small" as const,
|
||||
@@ -253,6 +277,74 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
)
|
||||
|
||||
const AdvancedSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.advanced")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showFileTree.title")}
|
||||
description={language.t("settings.general.row.showFileTree.description")}
|
||||
>
|
||||
<div data-action="settings-show-file-tree">
|
||||
<Switch
|
||||
checked={settings.general.showFileTree()}
|
||||
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showNavigation.title")}
|
||||
description={language.t("settings.general.row.showNavigation.description")}
|
||||
>
|
||||
<div data-action="settings-show-navigation">
|
||||
<Switch
|
||||
checked={settings.general.showNavigation()}
|
||||
onChange={(checked) => settings.general.setShowNavigation(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showSearch.title")}
|
||||
description={language.t("settings.general.row.showSearch.description")}
|
||||
>
|
||||
<div data-action="settings-show-search">
|
||||
<Switch
|
||||
checked={settings.general.showSearch()}
|
||||
onChange={(checked) => settings.general.setShowSearch(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showTerminal.title")}
|
||||
description={language.t("settings.general.row.showTerminal.description")}
|
||||
>
|
||||
<div data-action="settings-show-terminal">
|
||||
<Switch
|
||||
checked={settings.general.showTerminal()}
|
||||
onChange={(checked) => settings.general.setShowTerminal(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showStatus.title")}
|
||||
description={language.t("settings.general.row.showStatus.description")}
|
||||
>
|
||||
<div data-action="settings-show-status">
|
||||
<Switch
|
||||
checked={settings.general.showStatus()}
|
||||
onChange={(checked) => settings.general.setShowStatus(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
const AppearanceSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
||||
@@ -321,6 +413,9 @@ export const SettingsGeneral: Component = () => {
|
||||
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => language.t(o.label)}
|
||||
onHighlight={(option) => {
|
||||
void loadFont().then((x) => x.ensureMonoFont(option?.value))
|
||||
}}
|
||||
onSelect={(option) => option && settings.appearance.setFont(option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
@@ -561,6 +656,10 @@ export const SettingsGeneral: Component = () => {
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<Show when={desktop()}>
|
||||
<AdvancedSection />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
@@ -15,7 +16,6 @@ import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
import { DialogSelectServer } from "./dialog-select-server"
|
||||
|
||||
const pollMs = 10_000
|
||||
|
||||
@@ -53,11 +53,15 @@ const listServersByHealth = (
|
||||
})
|
||||
}
|
||||
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled()) {
|
||||
setStatus(reconcile({}))
|
||||
return
|
||||
}
|
||||
const list = servers()
|
||||
let dead = false
|
||||
|
||||
@@ -130,41 +134,30 @@ const useDefaultServerKey = (
|
||||
}
|
||||
}
|
||||
|
||||
const useMcpToggle = (input: {
|
||||
sync: ReturnType<typeof useSync>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) => {
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
const useMcpToggleMutation = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
const toggle = async (name: string) => {
|
||||
if (loading()) return
|
||||
setLoading(name)
|
||||
|
||||
try {
|
||||
const status = input.sync.data.mcp[name]
|
||||
await (status?.status === "connected"
|
||||
? input.sdk.client.mcp.disconnect({ name })
|
||||
: input.sdk.client.mcp.connect({ name }))
|
||||
const result = await input.sdk.client.mcp.status()
|
||||
if (result.data) input.sync.set("mcp", result.data)
|
||||
} catch (err) {
|
||||
return useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
||||
const result = await sdk.client.mcp.status()
|
||||
if (result.data) sync.set("mcp", result.data)
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.language.t("common.requestFailed"),
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, toggle }
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export function StatusPopover() {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
@@ -172,6 +165,12 @@ export function StatusPopover() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [shown, setShown] = createSignal(false)
|
||||
let dialogRun = 0
|
||||
let dialogDead = false
|
||||
onCleanup(() => {
|
||||
dialogDead = true
|
||||
dialogRun += 1
|
||||
})
|
||||
const servers = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
@@ -179,9 +178,9 @@ export function StatusPopover() {
|
||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||
})
|
||||
const health = useServerHealth(servers)
|
||||
const health = useServerHealth(servers, shown)
|
||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||
const mcp = useMcpToggle({ sync, sdk, language })
|
||||
const toggleMcp = useMcpToggleMutation()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
@@ -310,7 +309,13 @@ export function StatusPopover() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
|
||||
onClick={() => {
|
||||
const run = ++dialogRun
|
||||
void import("./dialog-select-server").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{language.t("status.popover.action.manageServers")}
|
||||
</Button>
|
||||
@@ -337,8 +342,11 @@ export function StatusPopover() {
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
onClick={() => mcp.toggle(name)}
|
||||
disabled={mcp.loading() === name}
|
||||
onClick={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
}}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
@@ -354,8 +362,11 @@ export function StatusPopover() {
|
||||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={mcp.loading() === name}
|
||||
onChange={() => mcp.toggle(name)}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
onChange={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
|
||||
import { withAlpha } from "@opencode-ai/ui/theme/color"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
|
||||
import type { HexColor } from "@opencode-ai/ui/theme/types"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
||||
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
||||
|
||||
@@ -5,12 +5,13 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme } from "@opencode-ai/ui/theme"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
@@ -40,6 +41,7 @@ export function Titlebar() {
|
||||
const platform = usePlatform()
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
@@ -78,6 +80,7 @@ export function Titlebar() {
|
||||
const canBack = createMemo(() => history.index > 0)
|
||||
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
||||
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||
const nav = createMemo(() => platform.platform !== "desktop" || settings.general.showNavigation())
|
||||
|
||||
const back = () => {
|
||||
const next = backPath(history)
|
||||
@@ -252,7 +255,7 @@ export function Titlebar() {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={hasProjects()}>
|
||||
<Show when={hasProjects() && nav()}>
|
||||
<div
|
||||
class="flex items-center gap-0 transition-transform"
|
||||
classList={{
|
||||
@@ -287,6 +290,9 @@ export function Titlebar() {
|
||||
</div>
|
||||
</div>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
|
||||
BETA
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||
|
||||
@@ -32,6 +32,25 @@ describe("command keybind helpers", () => {
|
||||
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
|
||||
})
|
||||
|
||||
test("matchKeybind supports bracket keys", () => {
|
||||
const keybinds = parseKeybind("mod+alt+[, mod+alt+]")
|
||||
const prev = keybinds[0]
|
||||
const next = keybinds[1]
|
||||
|
||||
expect(
|
||||
matchKeybind(
|
||||
keybinds,
|
||||
new KeyboardEvent("keydown", { key: "[", ctrlKey: prev?.ctrl, metaKey: prev?.meta, altKey: true }),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
matchKeybind(
|
||||
keybinds,
|
||||
new KeyboardEvent("keydown", { key: "]", ctrlKey: next?.ctrl, metaKey: next?.meta, altKey: true }),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("formatKeybind returns human readable output", () => {
|
||||
const display = formatKeybind("ctrl+alt+arrowup")
|
||||
|
||||
@@ -40,4 +59,11 @@ describe("command keybind helpers", () => {
|
||||
expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
|
||||
expect(formatKeybind("none")).toBe("")
|
||||
})
|
||||
|
||||
test("formatKeybind prefers the first combo", () => {
|
||||
const display = formatKeybind("mod+k,mod+p")
|
||||
|
||||
expect(display.includes("K") || display.includes("k")).toBe(true)
|
||||
expect(display.includes("P") || display.includes("p")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,17 +9,7 @@ import type {
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import {
|
||||
createContext,
|
||||
getOwner,
|
||||
Match,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type ParentProps,
|
||||
Switch,
|
||||
untrack,
|
||||
useContext,
|
||||
} from "solid-js"
|
||||
import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -80,6 +70,8 @@ function createGlobalSync() {
|
||||
|
||||
let active = true
|
||||
let projectWritten = false
|
||||
let bootedAt = 0
|
||||
let bootingRoot = false
|
||||
|
||||
onCleanup(() => {
|
||||
active = false
|
||||
@@ -258,6 +250,11 @@ function createGlobalSync() {
|
||||
const sdk = sdkFor(directory)
|
||||
await bootstrapDirectory({
|
||||
directory,
|
||||
global: {
|
||||
config: globalStore.config,
|
||||
project: globalStore.project,
|
||||
provider: globalStore.provider,
|
||||
},
|
||||
sdk,
|
||||
store: child[0],
|
||||
setStore: child[1],
|
||||
@@ -278,15 +275,20 @@ function createGlobalSync() {
|
||||
const unsub = globalSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const event = e.details
|
||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||
|
||||
if (directory === "global") {
|
||||
applyGlobalEvent({
|
||||
event,
|
||||
project: globalStore.project,
|
||||
refresh: queue.refresh,
|
||||
refresh: () => {
|
||||
if (recent) return
|
||||
queue.refresh()
|
||||
},
|
||||
setGlobalProject: setProjects,
|
||||
})
|
||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||
if (recent) return
|
||||
for (const directory of Object.keys(children.children)) {
|
||||
queue.push(directory)
|
||||
}
|
||||
@@ -325,17 +327,19 @@ function createGlobalSync() {
|
||||
})
|
||||
|
||||
async function bootstrap() {
|
||||
await bootstrapGlobal({
|
||||
globalSDK: globalSDK.client,
|
||||
connectErrorTitle: language.t("dialog.server.add.error"),
|
||||
connectErrorDescription: language.t("error.globalSync.connectFailed", {
|
||||
url: globalSDK.url,
|
||||
}),
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
translate: language.t,
|
||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||
setGlobalStore: setBootStore,
|
||||
})
|
||||
bootingRoot = true
|
||||
try {
|
||||
await bootstrapGlobal({
|
||||
globalSDK: globalSDK.client,
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
translate: language.t,
|
||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||
setGlobalStore: setBootStore,
|
||||
})
|
||||
bootedAt = Date.now()
|
||||
} finally {
|
||||
bootingRoot = false
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -392,13 +396,7 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
|
||||
|
||||
export function GlobalSyncProvider(props: ParentProps) {
|
||||
const value = createGlobalSync()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={value.ready}>
|
||||
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
||||
}
|
||||
|
||||
export function useGlobalSync() {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { retry } from "@opencode-ai/util/retry"
|
||||
import { batch } from "solid-js"
|
||||
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { cmp, normalizeProviderList } from "./utils"
|
||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
|
||||
type GlobalStore = {
|
||||
@@ -31,73 +31,102 @@ type GlobalStore = {
|
||||
reload: undefined | "pending" | "complete"
|
||||
}
|
||||
|
||||
function waitForPaint() {
|
||||
return new Promise<void>((resolve) => {
|
||||
let done = false
|
||||
const finish = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(finish, 50)
|
||||
if (typeof requestAnimationFrame !== "function") return
|
||||
requestAnimationFrame(() => {
|
||||
clearTimeout(timer)
|
||||
finish()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function errors(list: PromiseSettledResult<unknown>[]) {
|
||||
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
|
||||
}
|
||||
|
||||
function runAll(list: Array<() => Promise<unknown>>) {
|
||||
return Promise.allSettled(list.map((item) => item()))
|
||||
}
|
||||
|
||||
function showErrors(input: {
|
||||
errors: unknown[]
|
||||
title: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
formatMoreCount: (count: number) => string
|
||||
}) {
|
||||
if (input.errors.length === 0) return
|
||||
const message = formatServerError(input.errors[0], input.translate)
|
||||
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.title,
|
||||
description: message + more,
|
||||
})
|
||||
}
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
globalSDK: OpencodeClient
|
||||
connectErrorTitle: string
|
||||
connectErrorDescription: string
|
||||
requestFailedTitle: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
formatMoreCount: (count: number) => string
|
||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||
}) {
|
||||
const health = await input.globalSDK.global
|
||||
.health()
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!health?.healthy) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.connectErrorTitle,
|
||||
description: input.connectErrorDescription,
|
||||
})
|
||||
input.setGlobalStore("ready", true)
|
||||
return
|
||||
}
|
||||
|
||||
const tasks = [
|
||||
retry(() =>
|
||||
input.globalSDK.path.get().then((x) => {
|
||||
input.setGlobalStore("path", x.data!)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.global.config.get().then((x) => {
|
||||
input.setGlobalStore("config", x.data!)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.project.list().then((x) => {
|
||||
const projects = (x.data ?? [])
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
input.setGlobalStore("project", projects)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.provider.list().then((x) => {
|
||||
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.provider.auth().then((x) => {
|
||||
input.setGlobalStore("provider_auth", x.data ?? {})
|
||||
}),
|
||||
),
|
||||
const fast = [
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.path.get().then((x) => {
|
||||
input.setGlobalStore("path", x.data!)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.global.config.get().then((x) => {
|
||||
input.setGlobalStore("config", x.data!)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.provider.list().then((x) => {
|
||||
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
const results = await Promise.allSettled(tasks)
|
||||
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
|
||||
if (errors.length) {
|
||||
const message = formatServerError(errors[0], input.translate)
|
||||
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.requestFailedTitle,
|
||||
description: message + more,
|
||||
})
|
||||
}
|
||||
const slow = [
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.project.list().then((x) => {
|
||||
const projects = (x.data ?? [])
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
input.setGlobalStore("project", projects)
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
showErrors({
|
||||
errors: errors(await runAll(fast)),
|
||||
title: input.requestFailedTitle,
|
||||
translate: input.translate,
|
||||
formatMoreCount: input.formatMoreCount,
|
||||
})
|
||||
await waitForPaint()
|
||||
showErrors({
|
||||
errors: errors(await runAll(slow)),
|
||||
title: input.requestFailedTitle,
|
||||
translate: input.translate,
|
||||
formatMoreCount: input.formatMoreCount,
|
||||
})
|
||||
input.setGlobalStore("ready", true)
|
||||
}
|
||||
|
||||
@@ -111,6 +140,10 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
|
||||
}, {})
|
||||
}
|
||||
|
||||
function projectID(directory: string, projects: Project[]) {
|
||||
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
|
||||
}
|
||||
|
||||
export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
sdk: OpencodeClient
|
||||
@@ -119,88 +152,130 @@ export async function bootstrapDirectory(input: {
|
||||
vcsCache: VcsCache
|
||||
loadSessions: (directory: string) => Promise<void> | void
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
}) {
|
||||
if (input.store.status !== "complete") input.setStore("status", "loading")
|
||||
|
||||
const blockingRequests = {
|
||||
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
|
||||
provider: () =>
|
||||
input.sdk.provider.list().then((x) => {
|
||||
input.setStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
|
||||
config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
|
||||
global: {
|
||||
config: Config
|
||||
project: Project[]
|
||||
provider: ProviderListResponse
|
||||
}
|
||||
}) {
|
||||
const loading = input.store.status !== "complete"
|
||||
const seededProject = projectID(input.directory, input.global.project)
|
||||
if (seededProject) input.setStore("project", seededProject)
|
||||
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
|
||||
input.setStore("provider", input.global.provider)
|
||||
}
|
||||
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
||||
input.setStore("config", input.global.config)
|
||||
}
|
||||
if (loading) input.setStore("status", "partial")
|
||||
|
||||
try {
|
||||
await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
|
||||
} catch (err) {
|
||||
console.error("Failed to bootstrap instance", err)
|
||||
const fast = [
|
||||
() =>
|
||||
seededProject
|
||||
? Promise.resolve()
|
||||
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
||||
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
|
||||
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.path.get().then((x) => {
|
||||
input.setStore("path", x.data!)
|
||||
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
)
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.question.list().then((x) => {
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
const slow = [
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.provider.list().then((x) => {
|
||||
input.setStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
|
||||
() => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),
|
||||
]
|
||||
|
||||
const errs = errors(await runAll(fast))
|
||||
if (errs.length > 0) {
|
||||
console.error("Failed to bootstrap instance", errs[0])
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
description: formatServerError(errs[0], input.translate),
|
||||
})
|
||||
input.setStore("status", "partial")
|
||||
return
|
||||
}
|
||||
|
||||
if (input.store.status !== "complete") input.setStore("status", "partial")
|
||||
await waitForPaint()
|
||||
const slowErrs = errors(await runAll(slow))
|
||||
if (slowErrs.length > 0) {
|
||||
console.error("Failed to finish bootstrap instance", slowErrs[0])
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(slowErrs[0], input.translate),
|
||||
})
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
|
||||
input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
|
||||
input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
|
||||
input.loadSessions(input.directory),
|
||||
input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
|
||||
input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next?.branch) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
)
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
input.sdk.question.list().then((x) => {
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
]).then(() => {
|
||||
input.setStore("status", "complete")
|
||||
})
|
||||
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
|
||||
}
|
||||
|
||||
@@ -494,8 +494,10 @@ describe("applyDirectoryEvent", () => {
|
||||
})
|
||||
|
||||
test("updates vcs branch in store and cache", () => {
|
||||
const [store, setStore] = createStore(baseState())
|
||||
const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
|
||||
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
|
||||
const [cacheStore, setCacheStore] = createStore({
|
||||
value: { branch: "main", default_branch: "main" } as State["vcs"],
|
||||
})
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
|
||||
@@ -511,8 +513,8 @@ describe("applyDirectoryEvent", () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.vcs).toEqual({ branch: "feature/test" })
|
||||
expect(cacheStore.value).toEqual({ branch: "feature/test" })
|
||||
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||
})
|
||||
|
||||
test("routes disposal and lsp events to side-effect handlers", () => {
|
||||
|
||||
@@ -15,6 +15,8 @@ import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
export function applyGlobalEvent(input: {
|
||||
event: { type: string; properties?: unknown }
|
||||
project: Project[]
|
||||
@@ -211,6 +213,7 @@ export function applyDirectoryEvent(input: {
|
||||
}
|
||||
case "message.part.updated": {
|
||||
const part = (event.properties as { part: Part }).part
|
||||
if (SKIP_PARTS.has(part.type)) break
|
||||
const parts = input.store.part[part.messageID]
|
||||
if (!parts) {
|
||||
input.setStore("part", part.messageID, [part])
|
||||
@@ -268,9 +271,9 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "vcs.branch.updated": {
|
||||
const props = event.properties as { branch: string }
|
||||
const props = event.properties as { branch?: string }
|
||||
if (input.store.vcs?.branch === props.branch) break
|
||||
const next = { branch: props.branch }
|
||||
const next = { ...input.store.vcs, branch: props.branch }
|
||||
input.setStore("vcs", next)
|
||||
if (input.vcsCache) input.vcsCache.setStore("value", next)
|
||||
break
|
||||
|
||||
35
packages/app/src/context/global-sync/utils.test.ts
Normal file
35
packages/app/src/context/global-sync/utils.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Agent } from "@opencode-ai/sdk/v2/client"
|
||||
import { normalizeAgentList } from "./utils"
|
||||
|
||||
const agent = (name = "build") =>
|
||||
({
|
||||
name,
|
||||
mode: "primary",
|
||||
permission: {},
|
||||
options: {},
|
||||
}) as Agent
|
||||
|
||||
describe("normalizeAgentList", () => {
|
||||
test("keeps array payloads", () => {
|
||||
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
|
||||
})
|
||||
|
||||
test("wraps a single agent payload", () => {
|
||||
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
|
||||
})
|
||||
|
||||
test("extracts agents from keyed objects", () => {
|
||||
expect(
|
||||
normalizeAgentList({
|
||||
build: agent("build"),
|
||||
docs: agent("docs"),
|
||||
}),
|
||||
).toEqual([agent("build"), agent("docs")])
|
||||
})
|
||||
|
||||
test("drops invalid payloads", () => {
|
||||
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
|
||||
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,21 @@
|
||||
import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
function isAgent(input: unknown): input is Agent {
|
||||
if (!input || typeof input !== "object") return false
|
||||
const item = input as { name?: unknown; mode?: unknown }
|
||||
if (typeof item.name !== "string") return false
|
||||
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
|
||||
}
|
||||
|
||||
export function normalizeAgentList(input: unknown): Agent[] {
|
||||
if (Array.isArray(input)) return input.filter(isAgent)
|
||||
if (isAgent(input)) return [input]
|
||||
if (!input || typeof input !== "object") return []
|
||||
return Object.values(input).filter(isAgent)
|
||||
}
|
||||
|
||||
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
|
||||
return {
|
||||
...input,
|
||||
|
||||
@@ -1,42 +1,10 @@
|
||||
import * as i18n from "@solid-primitives/i18n"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { dict as zh } from "@/i18n/zh"
|
||||
import { dict as zht } from "@/i18n/zht"
|
||||
import { dict as ko } from "@/i18n/ko"
|
||||
import { dict as de } from "@/i18n/de"
|
||||
import { dict as es } from "@/i18n/es"
|
||||
import { dict as fr } from "@/i18n/fr"
|
||||
import { dict as da } from "@/i18n/da"
|
||||
import { dict as ja } from "@/i18n/ja"
|
||||
import { dict as pl } from "@/i18n/pl"
|
||||
import { dict as ru } from "@/i18n/ru"
|
||||
import { dict as ar } from "@/i18n/ar"
|
||||
import { dict as no } from "@/i18n/no"
|
||||
import { dict as br } from "@/i18n/br"
|
||||
import { dict as th } from "@/i18n/th"
|
||||
import { dict as bs } from "@/i18n/bs"
|
||||
import { dict as tr } from "@/i18n/tr"
|
||||
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
|
||||
import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
|
||||
import { dict as uiKo } from "@opencode-ai/ui/i18n/ko"
|
||||
import { dict as uiDe } from "@opencode-ai/ui/i18n/de"
|
||||
import { dict as uiEs } from "@opencode-ai/ui/i18n/es"
|
||||
import { dict as uiFr } from "@opencode-ai/ui/i18n/fr"
|
||||
import { dict as uiDa } from "@opencode-ai/ui/i18n/da"
|
||||
import { dict as uiJa } from "@opencode-ai/ui/i18n/ja"
|
||||
import { dict as uiPl } from "@opencode-ai/ui/i18n/pl"
|
||||
import { dict as uiRu } from "@opencode-ai/ui/i18n/ru"
|
||||
import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
|
||||
import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
|
||||
import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
|
||||
import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
|
||||
import { dict as uiBs } from "@opencode-ai/ui/i18n/bs"
|
||||
import { dict as uiTr } from "@opencode-ai/ui/i18n/tr"
|
||||
|
||||
export type Locale =
|
||||
| "en"
|
||||
@@ -59,6 +27,7 @@ export type Locale =
|
||||
|
||||
type RawDictionary = typeof en & typeof uiEn
|
||||
type Dictionary = i18n.Flatten<RawDictionary>
|
||||
type Source = { dict: Record<string, string> }
|
||||
|
||||
function cookie(locale: Locale) {
|
||||
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
|
||||
@@ -125,24 +94,43 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
|
||||
}
|
||||
|
||||
const base = i18n.flatten({ ...en, ...uiEn })
|
||||
const DICT: Record<Locale, Dictionary> = {
|
||||
en: base,
|
||||
zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) },
|
||||
zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) },
|
||||
ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
|
||||
de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) },
|
||||
es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) },
|
||||
fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) },
|
||||
da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) },
|
||||
ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) },
|
||||
pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) },
|
||||
ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) },
|
||||
ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) },
|
||||
no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) },
|
||||
br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) },
|
||||
th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) },
|
||||
bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) },
|
||||
tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) },
|
||||
const dicts = new Map<Locale, Dictionary>([["en", base]])
|
||||
|
||||
const merge = (app: Promise<Source>, ui: Promise<Source>) =>
|
||||
Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary)
|
||||
|
||||
const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
|
||||
zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")),
|
||||
zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")),
|
||||
ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")),
|
||||
de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")),
|
||||
es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")),
|
||||
fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")),
|
||||
da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")),
|
||||
ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
|
||||
pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
|
||||
ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
|
||||
ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
|
||||
no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
|
||||
br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
|
||||
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
|
||||
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
|
||||
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
|
||||
}
|
||||
|
||||
function loadDict(locale: Locale) {
|
||||
const hit = dicts.get(locale)
|
||||
if (hit) return Promise.resolve(hit)
|
||||
if (locale === "en") return Promise.resolve(base)
|
||||
const load = loaders[locale]
|
||||
return load().then((next: Dictionary) => {
|
||||
dicts.set(locale, next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function loadLocaleDict(locale: Locale) {
|
||||
return loadDict(locale).then(() => undefined)
|
||||
}
|
||||
|
||||
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
||||
@@ -168,27 +156,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
|
||||
{ locale: "tr", match: (language) => language.startsWith("tr") },
|
||||
]
|
||||
|
||||
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
|
||||
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
|
||||
zh,
|
||||
zht,
|
||||
ko,
|
||||
de,
|
||||
es,
|
||||
fr,
|
||||
da,
|
||||
ja,
|
||||
pl,
|
||||
ru,
|
||||
ar,
|
||||
no,
|
||||
br,
|
||||
th,
|
||||
bs,
|
||||
tr,
|
||||
}
|
||||
void PARITY_CHECK
|
||||
|
||||
function detectLocale(): Locale {
|
||||
if (typeof navigator !== "object") return "en"
|
||||
|
||||
@@ -203,27 +170,48 @@ function detectLocale(): Locale {
|
||||
return "en"
|
||||
}
|
||||
|
||||
function normalizeLocale(value: string): Locale {
|
||||
export function normalizeLocale(value: string): Locale {
|
||||
return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
|
||||
}
|
||||
|
||||
function readStoredLocale() {
|
||||
if (typeof localStorage !== "object") return
|
||||
try {
|
||||
const raw = localStorage.getItem("opencode.global.dat:language")
|
||||
if (!raw) return
|
||||
const next = JSON.parse(raw) as { locale?: string }
|
||||
if (typeof next?.locale !== "string") return
|
||||
return normalizeLocale(next.locale)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const warm = readStoredLocale() ?? detectLocale()
|
||||
if (warm !== "en") void loadDict(warm)
|
||||
|
||||
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
|
||||
name: "Language",
|
||||
init: () => {
|
||||
init: (props: { locale?: Locale }) => {
|
||||
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("language", ["language.v1"]),
|
||||
createStore({
|
||||
locale: detectLocale() as Locale,
|
||||
locale: initial,
|
||||
}),
|
||||
)
|
||||
|
||||
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
||||
console.log("locale", locale())
|
||||
const intl = createMemo(() => INTL[locale()])
|
||||
|
||||
const dict = createMemo<Dictionary>(() => DICT[locale()])
|
||||
const [dict] = createResource(locale, loadDict, {
|
||||
initialValue: dicts.get(initial) ?? base,
|
||||
})
|
||||
|
||||
const t = i18n.translator(dict, i18n.resolveTemplate)
|
||||
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
|
||||
key: keyof Dictionary,
|
||||
params?: Record<string, string | number | boolean>,
|
||||
) => string
|
||||
|
||||
const label = (value: Locale) => t(LABEL_KEY[value])
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { playSound, soundSrc } from "@/utils/sound"
|
||||
import { playSoundById } from "@/utils/sound"
|
||||
|
||||
type NotificationBase = {
|
||||
directory?: string
|
||||
@@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
if (session.parentID) return
|
||||
|
||||
if (settings.sounds.agentEnabled()) {
|
||||
playSound(soundSrc(settings.sounds.agent()))
|
||||
void playSoundById(settings.sounds.agent())
|
||||
}
|
||||
|
||||
append({
|
||||
@@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
if (session?.parentID) return
|
||||
|
||||
if (settings.sounds.errorsEnabled()) {
|
||||
playSound(soundSrc(settings.sounds.errors()))
|
||||
void playSoundById(settings.sounds.errors())
|
||||
}
|
||||
|
||||
const error = "error" in event.properties ? event.properties.error : undefined
|
||||
|
||||
@@ -23,6 +23,11 @@ export interface Settings {
|
||||
autoSave: boolean
|
||||
releaseNotes: boolean
|
||||
followup: "queue" | "steer"
|
||||
showFileTree: boolean
|
||||
showNavigation: boolean
|
||||
showSearch: boolean
|
||||
showStatus: boolean
|
||||
showTerminal: boolean
|
||||
showReasoningSummaries: boolean
|
||||
shellToolPartsExpanded: boolean
|
||||
editToolPartsExpanded: boolean
|
||||
@@ -47,6 +52,11 @@ const defaultSettings: Settings = {
|
||||
autoSave: true,
|
||||
releaseNotes: true,
|
||||
followup: "steer",
|
||||
showFileTree: false,
|
||||
showNavigation: false,
|
||||
showSearch: false,
|
||||
showStatus: false,
|
||||
showTerminal: false,
|
||||
showReasoningSummaries: false,
|
||||
shellToolPartsExpanded: true,
|
||||
editToolPartsExpanded: false,
|
||||
@@ -104,6 +114,13 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
|
||||
return createMemo(() => read() ?? fallback)
|
||||
}
|
||||
|
||||
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
|
||||
|
||||
function loadFont() {
|
||||
font ??= import("@opencode-ai/ui/font-loader")
|
||||
return font
|
||||
}
|
||||
|
||||
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
||||
name: "Settings",
|
||||
init: () => {
|
||||
@@ -111,7 +128,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
|
||||
const id = store.appearance?.font ?? defaultSettings.appearance.font
|
||||
if (id !== defaultSettings.appearance.font) {
|
||||
void loadFont().then((x) => x.ensureMonoFont(id))
|
||||
}
|
||||
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id))
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -132,6 +153,26 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setFollowup(value: "queue" | "steer") {
|
||||
setStore("general", "followup", value)
|
||||
},
|
||||
showFileTree: withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree),
|
||||
setShowFileTree(value: boolean) {
|
||||
setStore("general", "showFileTree", value)
|
||||
},
|
||||
showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation),
|
||||
setShowNavigation(value: boolean) {
|
||||
setStore("general", "showNavigation", value)
|
||||
},
|
||||
showSearch: withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch),
|
||||
setShowSearch(value: boolean) {
|
||||
setStore("general", "showSearch", value)
|
||||
},
|
||||
showStatus: withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus),
|
||||
setShowStatus(value: boolean) {
|
||||
setStore("general", "showStatus", value)
|
||||
},
|
||||
showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
|
||||
setShowTerminal(value: boolean) {
|
||||
setStore("general", "showTerminal", value)
|
||||
},
|
||||
showReasoningSummaries: withFallback(
|
||||
() => store.general?.showReasoningSummaries,
|
||||
defaultSettings.general.showReasoningSummaries,
|
||||
|
||||
@@ -14,6 +14,8 @@ import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
function sortParts(parts: Part[]) {
|
||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
@@ -178,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
return globalSync.child(directory)
|
||||
}
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const messagePageSize = 200
|
||||
const initialMessagePageSize = 80
|
||||
const historyMessagePageSize = 200
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
@@ -336,7 +339,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
batch(() => {
|
||||
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
|
||||
for (const p of next.part) {
|
||||
input.setStore("part", p.id, p.part)
|
||||
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
|
||||
if (filtered.length) input.setStore("part", p.id, filtered)
|
||||
}
|
||||
setMeta("limit", key, message.length)
|
||||
setMeta("cursor", key, next.cursor)
|
||||
@@ -460,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
|
||||
if (cached && hasSession && !opts?.force) return
|
||||
|
||||
const limit = meta.limit[key] ?? messagePageSize
|
||||
const limit = meta.limit[key] ?? initialMessagePageSize
|
||||
const sessionReq =
|
||||
hasSession && !opts?.force
|
||||
? Promise.resolve()
|
||||
@@ -557,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const step = count ?? messagePageSize
|
||||
const step = count ?? historyMessagePageSize
|
||||
if (meta.loading[key]) return
|
||||
if (meta.complete[key]) return
|
||||
const before = meta.cursor[key]
|
||||
|
||||
@@ -1,45 +1,18 @@
|
||||
import { dict as ar } from "@/i18n/ar"
|
||||
import { dict as br } from "@/i18n/br"
|
||||
import { dict as bs } from "@/i18n/bs"
|
||||
import { dict as da } from "@/i18n/da"
|
||||
import { dict as de } from "@/i18n/de"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { dict as es } from "@/i18n/es"
|
||||
import { dict as fr } from "@/i18n/fr"
|
||||
import { dict as ja } from "@/i18n/ja"
|
||||
import { dict as ko } from "@/i18n/ko"
|
||||
import { dict as no } from "@/i18n/no"
|
||||
import { dict as pl } from "@/i18n/pl"
|
||||
import { dict as ru } from "@/i18n/ru"
|
||||
import { dict as th } from "@/i18n/th"
|
||||
import { dict as tr } from "@/i18n/tr"
|
||||
import { dict as zh } from "@/i18n/zh"
|
||||
import { dict as zht } from "@/i18n/zht"
|
||||
const template = "Terminal {{number}}"
|
||||
|
||||
const numbered = Array.from(
|
||||
new Set([
|
||||
en["terminal.title.numbered"],
|
||||
ar["terminal.title.numbered"],
|
||||
br["terminal.title.numbered"],
|
||||
bs["terminal.title.numbered"],
|
||||
da["terminal.title.numbered"],
|
||||
de["terminal.title.numbered"],
|
||||
es["terminal.title.numbered"],
|
||||
fr["terminal.title.numbered"],
|
||||
ja["terminal.title.numbered"],
|
||||
ko["terminal.title.numbered"],
|
||||
no["terminal.title.numbered"],
|
||||
pl["terminal.title.numbered"],
|
||||
ru["terminal.title.numbered"],
|
||||
th["terminal.title.numbered"],
|
||||
tr["terminal.title.numbered"],
|
||||
zh["terminal.title.numbered"],
|
||||
zht["terminal.title.numbered"],
|
||||
]),
|
||||
)
|
||||
const numbered = [
|
||||
template,
|
||||
"محطة طرفية {{number}}",
|
||||
"Терминал {{number}}",
|
||||
"ターミナル {{number}}",
|
||||
"터미널 {{number}}",
|
||||
"เทอร์มินัล {{number}}",
|
||||
"终端 {{number}}",
|
||||
"終端機 {{number}}",
|
||||
]
|
||||
|
||||
export function defaultTitle(number: number) {
|
||||
return en["terminal.title.numbered"].replace("{{number}}", String(number))
|
||||
return template.replace("{{number}}", String(number))
|
||||
}
|
||||
|
||||
export function isDefaultTitle(title: string, number: number) {
|
||||
|
||||
@@ -22,7 +22,7 @@ export function useProviders() {
|
||||
const providers = () => {
|
||||
if (dir()) {
|
||||
const [projectStore] = globalSync.child(dir())
|
||||
return projectStore.provider
|
||||
if (projectStore.provider.all.length > 0) return projectStore.provider
|
||||
}
|
||||
return globalSync.data.provider
|
||||
}
|
||||
|
||||
@@ -722,8 +722,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "تحميل مهارة بالاسم",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة",
|
||||
"settings.permissions.tool.todoread.title": "قراءة المهام",
|
||||
"settings.permissions.tool.todoread.description": "قراءة قائمة المهام",
|
||||
"settings.permissions.tool.todowrite.title": "كتابة المهام",
|
||||
"settings.permissions.tool.todowrite.description": "تحديث قائمة المهام",
|
||||
"settings.permissions.tool.webfetch.title": "جلب الويب",
|
||||
|
||||
@@ -732,8 +732,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem",
|
||||
"settings.permissions.tool.todoread.title": "Ler Tarefas",
|
||||
"settings.permissions.tool.todoread.description": "Ler a lista de tarefas",
|
||||
"settings.permissions.tool.todowrite.title": "Escrever Tarefas",
|
||||
"settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas",
|
||||
"settings.permissions.tool.webfetch.title": "Buscar Web",
|
||||
|
||||
@@ -806,8 +806,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera",
|
||||
"settings.permissions.tool.todoread.title": "Čitanje liste zadataka",
|
||||
"settings.permissions.tool.todoread.description": "Čitanje liste zadataka",
|
||||
"settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka",
|
||||
"settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka",
|
||||
"settings.permissions.tool.webfetch.title": "Web preuzimanje",
|
||||
|
||||
@@ -800,8 +800,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler",
|
||||
"settings.permissions.tool.todoread.title": "Læs To-do",
|
||||
"settings.permissions.tool.todoread.description": "Læs to-do listen",
|
||||
"settings.permissions.tool.todowrite.title": "Skriv To-do",
|
||||
"settings.permissions.tool.todowrite.description": "Opdater to-do listen",
|
||||
"settings.permissions.tool.webfetch.title": "Webhentning",
|
||||
|
||||
@@ -743,8 +743,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Eine Fähigkeit nach Namen laden",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen",
|
||||
"settings.permissions.tool.todoread.title": "Todo lesen",
|
||||
"settings.permissions.tool.todoread.description": "Die Todo-Liste lesen",
|
||||
"settings.permissions.tool.todowrite.title": "Todo schreiben",
|
||||
"settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren",
|
||||
"settings.permissions.tool.webfetch.title": "Web-Abruf",
|
||||
|
||||
@@ -23,6 +23,8 @@ export const dict = {
|
||||
|
||||
"command.sidebar.toggle": "Toggle sidebar",
|
||||
"command.project.open": "Open project",
|
||||
"command.project.previous": "Previous project",
|
||||
"command.project.next": "Next project",
|
||||
"command.provider.connect": "Connect provider",
|
||||
"command.server.switch": "Switch server",
|
||||
"command.settings.open": "Open settings",
|
||||
@@ -274,7 +276,7 @@ export const dict = {
|
||||
"prompt.context.includeActiveFile": "Include active file",
|
||||
"prompt.context.removeActiveFile": "Remove active file from context",
|
||||
"prompt.context.removeFile": "Remove file from context",
|
||||
"prompt.action.attachFile": "Add file",
|
||||
"prompt.action.attachFile": "Add files",
|
||||
"prompt.attachment.remove": "Remove attachment",
|
||||
"prompt.action.send": "Send",
|
||||
"prompt.action.stop": "Stop",
|
||||
@@ -533,6 +535,8 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.action": "Create Git repository",
|
||||
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
|
||||
"session.review.noChanges": "No changes",
|
||||
"session.review.noUncommittedChanges": "No uncommitted changes yet",
|
||||
"session.review.noBranchChanges": "No branch changes yet",
|
||||
|
||||
"session.files.selectToOpen": "Select a file to open",
|
||||
"session.files.all": "All files",
|
||||
@@ -713,6 +717,7 @@ export const dict = {
|
||||
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
||||
|
||||
"settings.general.section.appearance": "Appearance",
|
||||
"settings.general.section.advanced": "Advanced",
|
||||
"settings.general.section.notifications": "System notifications",
|
||||
"settings.general.section.updates": "Updates",
|
||||
"settings.general.section.sounds": "Sound effects",
|
||||
@@ -733,6 +738,16 @@ export const dict = {
|
||||
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
|
||||
"settings.general.row.followup.option.queue": "Queue",
|
||||
"settings.general.row.followup.option.steer": "Steer",
|
||||
"settings.general.row.showFileTree.title": "File tree",
|
||||
"settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions",
|
||||
"settings.general.row.showNavigation.title": "Navigation controls",
|
||||
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
|
||||
"settings.general.row.showSearch.title": "Command palette",
|
||||
"settings.general.row.showSearch.description": "Show the search and command palette button in the desktop title bar",
|
||||
"settings.general.row.showTerminal.title": "Terminal",
|
||||
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||
"settings.general.row.showStatus.title": "Server status",
|
||||
"settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
|
||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||
@@ -898,8 +913,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Load a skill by name",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Run language server queries",
|
||||
"settings.permissions.tool.todoread.title": "Todo Read",
|
||||
"settings.permissions.tool.todoread.description": "Read the todo list",
|
||||
"settings.permissions.tool.todowrite.title": "Todo Write",
|
||||
"settings.permissions.tool.todowrite.description": "Update the todo list",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
|
||||
@@ -813,8 +813,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Cargar una habilidad por nombre",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje",
|
||||
"settings.permissions.tool.todoread.title": "Leer Todo",
|
||||
"settings.permissions.tool.todoread.description": "Leer la lista de tareas",
|
||||
"settings.permissions.tool.todowrite.title": "Escribir Todo",
|
||||
"settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
|
||||
@@ -741,8 +741,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Charger une compétence par son nom",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage",
|
||||
"settings.permissions.tool.todoread.title": "Lire Todo",
|
||||
"settings.permissions.tool.todoread.description": "Lire la liste de tâches",
|
||||
"settings.permissions.tool.todowrite.title": "Écrire Todo",
|
||||
"settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches",
|
||||
"settings.permissions.tool.webfetch.title": "Récupération Web",
|
||||
|
||||
@@ -727,8 +727,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "名前によるスキルの読み込み",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "言語サーバークエリの実行",
|
||||
"settings.permissions.tool.todoread.title": "Todo読み込み",
|
||||
"settings.permissions.tool.todoread.description": "Todoリストの読み込み",
|
||||
"settings.permissions.tool.todowrite.title": "Todo書き込み",
|
||||
"settings.permissions.tool.todowrite.description": "Todoリストの更新",
|
||||
"settings.permissions.tool.webfetch.title": "Web取得",
|
||||
|
||||
@@ -726,8 +726,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "이름으로 기술 로드",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행",
|
||||
"settings.permissions.tool.todoread.title": "할 일 읽기",
|
||||
"settings.permissions.tool.todoread.description": "할 일 목록 읽기",
|
||||
"settings.permissions.tool.todowrite.title": "할 일 쓰기",
|
||||
"settings.permissions.tool.todowrite.description": "할 일 목록 업데이트",
|
||||
"settings.permissions.tool.webfetch.title": "웹 가져오기",
|
||||
|
||||
@@ -807,8 +807,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Last en ferdighet etter navn",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler",
|
||||
"settings.permissions.tool.todoread.title": "Les gjøremål",
|
||||
"settings.permissions.tool.todoread.description": "Les gjøremålslisten",
|
||||
"settings.permissions.tool.todowrite.title": "Skriv gjøremål",
|
||||
"settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten",
|
||||
"settings.permissions.tool.webfetch.title": "Webhenting",
|
||||
|
||||
@@ -729,8 +729,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Ładowanie umiejętności według nazwy",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego",
|
||||
"settings.permissions.tool.todoread.title": "Odczyt Todo",
|
||||
"settings.permissions.tool.todoread.description": "Odczyt listy zadań",
|
||||
"settings.permissions.tool.todowrite.title": "Zapis Todo",
|
||||
"settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań",
|
||||
"settings.permissions.tool.webfetch.title": "Pobieranie z sieci",
|
||||
|
||||
@@ -808,8 +808,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Загрузка навыка по имени",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Запросы к языковому серверу",
|
||||
"settings.permissions.tool.todoread.title": "Todo Read",
|
||||
"settings.permissions.tool.todoread.description": "Чтение списка задач",
|
||||
"settings.permissions.tool.todowrite.title": "Todo Write",
|
||||
"settings.permissions.tool.todowrite.description": "Обновление списка задач",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
|
||||
@@ -796,8 +796,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา",
|
||||
"settings.permissions.tool.todoread.title": "อ่านรายการงาน",
|
||||
"settings.permissions.tool.todoread.description": "อ่านรายการงาน",
|
||||
"settings.permissions.tool.todowrite.title": "เขียนรายการงาน",
|
||||
"settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน",
|
||||
"settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ",
|
||||
|
||||
@@ -816,8 +816,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Ada göre bir beceri yükle",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır",
|
||||
"settings.permissions.tool.todoread.title": "Görev Oku",
|
||||
"settings.permissions.tool.todoread.description": "Görev listesini oku",
|
||||
"settings.permissions.tool.todowrite.title": "Görev Yaz",
|
||||
"settings.permissions.tool.todowrite.description": "Görev listesini güncelle",
|
||||
"settings.permissions.tool.webfetch.title": "Web Getir",
|
||||
|
||||
@@ -795,8 +795,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "按名称加载技能",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "运行语言服务器查询",
|
||||
"settings.permissions.tool.todoread.title": "读取待办",
|
||||
"settings.permissions.tool.todoread.description": "读取待办列表",
|
||||
"settings.permissions.tool.todowrite.title": "更新待办",
|
||||
"settings.permissions.tool.todowrite.description": "更新待办列表",
|
||||
"settings.permissions.tool.webfetch.title": "网页获取",
|
||||
|
||||
@@ -790,8 +790,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "按名稱載入技能",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "執行語言伺服器查詢",
|
||||
"settings.permissions.tool.todoread.title": "讀取待辦",
|
||||
"settings.permissions.tool.todoread.description": "讀取待辦清單",
|
||||
"settings.permissions.tool.todowrite.title": "更新待辦",
|
||||
"settings.permissions.tool.todowrite.description": "更新待辦清單",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { AppBaseProviders, AppInterface } from "./app"
|
||||
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
||||
export { useCommand } from "./context/command"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
||||
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
|
||||
export { ServerConnection } from "./context/server"
|
||||
export { handleNotificationClick } from "./utils/notification-click"
|
||||
|
||||
@@ -2,8 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { createMemo, createResource, type ParentProps, Show } from "solid-js"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { createEffect, createMemo, type ParentProps, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { LocalProvider } from "@/context/local"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
@@ -11,10 +10,18 @@ import { SyncProvider, useSync } from "@/context/sync"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const sync = useSync()
|
||||
const slug = createMemo(() => base64Encode(props.directory))
|
||||
|
||||
createEffect(() => {
|
||||
const next = sync.data.path.directory
|
||||
if (!next || next === props.directory) return
|
||||
const path = location.pathname.slice(slug().length + 1)
|
||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||
})
|
||||
|
||||
return (
|
||||
<DataProvider
|
||||
data={sync.data}
|
||||
@@ -29,50 +36,31 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const params = useParams()
|
||||
const location = useLocation()
|
||||
const language = useLanguage()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const navigate = useNavigate()
|
||||
let invalid = ""
|
||||
|
||||
const [resolved] = createResource(
|
||||
() => {
|
||||
if (params.dir) return [location.pathname, params.dir] as const
|
||||
},
|
||||
async ([pathname, b64Dir]) => {
|
||||
const directory = decode64(b64Dir)
|
||||
const resolved = createMemo(() => {
|
||||
if (!params.dir) return ""
|
||||
return decode64(params.dir) ?? ""
|
||||
})
|
||||
|
||||
if (!directory) {
|
||||
if (invalid === params.dir) return
|
||||
invalid = b64Dir
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: language.t("directory.error.invalidUrl"),
|
||||
})
|
||||
navigate("/", { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
return await globalSDK
|
||||
.createClient({
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
.path.get()
|
||||
.then((x) => {
|
||||
const next = x.data?.directory ?? directory
|
||||
invalid = ""
|
||||
if (next === directory) return next
|
||||
const path = pathname.slice(b64Dir.length + 1)
|
||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||
})
|
||||
.catch(() => {
|
||||
invalid = ""
|
||||
return directory
|
||||
})
|
||||
},
|
||||
)
|
||||
createEffect(() => {
|
||||
const dir = params.dir
|
||||
if (!dir) return
|
||||
if (resolved()) {
|
||||
invalid = ""
|
||||
return
|
||||
}
|
||||
if (invalid === dir) return
|
||||
invalid = dir
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: language.t("directory.error.invalidUrl"),
|
||||
})
|
||||
navigate("/", { replace: true })
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={resolved()} keyed>
|
||||
|
||||
@@ -113,6 +113,14 @@ export default function Home() {
|
||||
</ul>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={!sync.ready}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
|
||||
<Button class="px-3" onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<Icon name="folder-add-left" size="large" />
|
||||
|
||||
@@ -49,21 +49,16 @@ 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 { playSoundById } from "@/utils/sound"
|
||||
import { createAim } from "@/utils/aim"
|
||||
import { setNavigate } from "@/utils/notification-click"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
|
||||
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 { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { Titlebar } from "@/components/titlebar"
|
||||
import { useServer } from "@/context/server"
|
||||
@@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) {
|
||||
const pageReady = createMemo(() => ready())
|
||||
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
let dialogRun = 0
|
||||
let dialogDead = false
|
||||
|
||||
const params = useParams()
|
||||
const globalSDK = useGlobalSDK()
|
||||
@@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) {
|
||||
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
|
||||
}
|
||||
})
|
||||
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
|
||||
const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
|
||||
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
|
||||
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
|
||||
system: "theme.scheme.system",
|
||||
@@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
dialogDead = true
|
||||
dialogRun += 1
|
||||
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
||||
clearTimeout(sortNowTimeout)
|
||||
if (sortNowInterval) clearInterval(sortNowInterval)
|
||||
@@ -211,13 +210,22 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
onMount(() => {
|
||||
const stop = () => setState("sizing", false)
|
||||
const blur = () => reset()
|
||||
const hide = () => {
|
||||
if (document.visibilityState !== "hidden") return
|
||||
reset()
|
||||
}
|
||||
window.addEventListener("pointerup", stop)
|
||||
window.addEventListener("pointercancel", stop)
|
||||
window.addEventListener("blur", stop)
|
||||
window.addEventListener("blur", blur)
|
||||
document.addEventListener("visibilitychange", hide)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("pointerup", stop)
|
||||
window.removeEventListener("pointercancel", stop)
|
||||
window.removeEventListener("blur", stop)
|
||||
window.removeEventListener("blur", blur)
|
||||
document.removeEventListener("visibilitychange", hide)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -237,6 +245,12 @@ export default function Layout(props: ParentProps) {
|
||||
navLeave.current = undefined
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
disarm()
|
||||
setState("hoverSession", undefined)
|
||||
setHoverProject(undefined)
|
||||
}
|
||||
|
||||
const arm = () => {
|
||||
if (layout.sidebar.opened()) return
|
||||
if (state.hoverProject === undefined) return
|
||||
@@ -305,8 +319,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const clearSidebarHoverState = () => {
|
||||
if (layout.sidebar.opened()) return
|
||||
setState("hoverSession", undefined)
|
||||
setHoverProject(undefined)
|
||||
reset()
|
||||
}
|
||||
|
||||
const navigateWithSidebarReset = (href: string) => {
|
||||
@@ -322,10 +335,9 @@ export default function Layout(props: ParentProps) {
|
||||
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
|
||||
const nextThemeId = ids[nextIndex]
|
||||
theme.setTheme(nextThemeId)
|
||||
const nextTheme = theme.themes()[nextThemeId]
|
||||
showToast({
|
||||
title: language.t("toast.theme.title"),
|
||||
description: nextTheme?.name ?? nextThemeId,
|
||||
description: theme.name(nextThemeId),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -480,7 +492,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
if (e.details.type === "permission.asked") {
|
||||
if (settings.sounds.permissionsEnabled()) {
|
||||
playSound(soundSrc(settings.sounds.permissions()))
|
||||
void playSoundById(settings.sounds.permissions())
|
||||
}
|
||||
if (settings.notifications.permissions()) {
|
||||
void platform.notify(title, description, href)
|
||||
@@ -936,6 +948,28 @@ export default function Layout(props: ParentProps) {
|
||||
navigateToSession(session)
|
||||
}
|
||||
|
||||
function navigateProjectByOffset(offset: number) {
|
||||
const projects = layout.projects.list()
|
||||
if (projects.length === 0) return
|
||||
|
||||
const current = currentProject()?.worktree
|
||||
const fallback = currentDir() ? projectRoot(currentDir()) : undefined
|
||||
const active = current ?? fallback
|
||||
const index = active ? projects.findIndex((project) => project.worktree === active) : -1
|
||||
|
||||
const target =
|
||||
index === -1
|
||||
? offset > 0
|
||||
? projects[0]
|
||||
: projects[projects.length - 1]
|
||||
: projects[(index + offset + projects.length) % projects.length]
|
||||
if (!target) return
|
||||
|
||||
// warm up child store to prevent flicker
|
||||
globalSync.child(target.worktree)
|
||||
openProject(target.worktree)
|
||||
}
|
||||
|
||||
function navigateSessionByUnseen(offset: number) {
|
||||
const sessions = currentSessions()
|
||||
if (sessions.length === 0) return
|
||||
@@ -1002,6 +1036,20 @@ export default function Layout(props: ParentProps) {
|
||||
keybind: "mod+o",
|
||||
onSelect: () => chooseProject(),
|
||||
},
|
||||
{
|
||||
id: "project.previous",
|
||||
title: language.t("command.project.previous"),
|
||||
category: language.t("command.category.project"),
|
||||
keybind: "mod+alt+arrowup",
|
||||
onSelect: () => navigateProjectByOffset(-1),
|
||||
},
|
||||
{
|
||||
id: "project.next",
|
||||
title: language.t("command.project.next"),
|
||||
category: language.t("command.category.project"),
|
||||
keybind: "mod+alt+arrowdown",
|
||||
onSelect: () => navigateProjectByOffset(1),
|
||||
},
|
||||
{
|
||||
id: "provider.connect",
|
||||
title: language.t("command.provider.connect"),
|
||||
@@ -1104,10 +1152,10 @@ export default function Layout(props: ParentProps) {
|
||||
},
|
||||
]
|
||||
|
||||
for (const [id, definition] of availableThemeEntries()) {
|
||||
for (const [id] of availableThemeEntries()) {
|
||||
commands.push({
|
||||
id: `theme.set.${id}`,
|
||||
title: language.t("command.theme.set", { theme: definition.name ?? id }),
|
||||
title: language.t("command.theme.set", { theme: theme.name(id) }),
|
||||
category: language.t("command.category.theme"),
|
||||
onSelect: () => theme.commitPreview(),
|
||||
onHighlight: () => {
|
||||
@@ -1158,15 +1206,27 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
function connectProvider() {
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-select-provider").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
|
||||
function openServer() {
|
||||
dialog.show(() => <DialogSelectServer />)
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-select-server").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectServer />)
|
||||
})
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
dialog.show(() => <DialogSettings />)
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-settings").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSettings />)
|
||||
})
|
||||
}
|
||||
|
||||
function projectRoot(directory: string) {
|
||||
@@ -1393,7 +1453,13 @@ export default function Layout(props: ParentProps) {
|
||||
layout.sidebar.toggleWorkspaces(project.worktree)
|
||||
}
|
||||
|
||||
const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />)
|
||||
const showEditProjectDialog = (project: LocalProject) => {
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-edit-project").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogEditProject project={project} />)
|
||||
})
|
||||
}
|
||||
|
||||
async function chooseProject() {
|
||||
function resolve(result: string | string[] | null) {
|
||||
@@ -1414,10 +1480,14 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
resolve(result)
|
||||
} else {
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-select-directory").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(
|
||||
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1750,6 +1820,9 @@ export default function Layout(props: ParentProps) {
|
||||
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
|
||||
})
|
||||
|
||||
const side = createMemo(() => Math.max(layout.sidebar.width(), 244))
|
||||
const panel = createMemo(() => Math.max(side() - 64, 0))
|
||||
|
||||
const loadedSessionDirs = new Set<string>()
|
||||
|
||||
createEffect(
|
||||
@@ -1941,6 +2014,10 @@ export default function Layout(props: ParentProps) {
|
||||
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
|
||||
onProjectMouseLeave: (worktree) => aim.leave(worktree),
|
||||
onProjectFocus: (worktree) => aim.activate(worktree),
|
||||
onHoverOpenChanged: (worktree, hoverOpen) => {
|
||||
if (!hoverOpen && state.hoverProject && state.hoverProject !== worktree) return
|
||||
setState("hoverProject", hoverOpen ? worktree : undefined)
|
||||
},
|
||||
navigateToProject,
|
||||
openSidebar: () => layout.sidebar.open(),
|
||||
closeProject,
|
||||
@@ -2022,7 +2099,7 @@ export default function Layout(props: ParentProps) {
|
||||
"max-w-full overflow-hidden": panelProps.mobile,
|
||||
}}
|
||||
style={{
|
||||
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
|
||||
width: panelProps.mobile ? undefined : `${panel()}px`,
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
@@ -2085,9 +2162,11 @@ export default function Layout(props: ParentProps) {
|
||||
variant="ghost"
|
||||
data-action="project-menu"
|
||||
data-project={slug()}
|
||||
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
|
||||
classList={{
|
||||
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
|
||||
"opacity-100": panelProps.mobile || merged(),
|
||||
"opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
|
||||
!panelProps.mobile && !merged(),
|
||||
}}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
@@ -2312,7 +2391,7 @@ export default function Layout(props: ParentProps) {
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
|
||||
style={{ width: `${side()}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
@@ -2327,26 +2406,29 @@ export default function Layout(props: ParentProps) {
|
||||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div onPointerDown={() => setState("sizing", true)}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
collapseThreshold={244}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
onCollapse={layout.sidebar.close}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</nav>
|
||||
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div
|
||||
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
|
||||
style={{ left: `${side()}px` }}
|
||||
onPointerDown={() => setState("sizing", true)}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
@@ -2386,7 +2468,7 @@ export default function Layout(props: ParentProps) {
|
||||
!state.sizing,
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
|
||||
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<main
|
||||
@@ -2433,7 +2515,7 @@ export default function Layout(props: ParentProps) {
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
|
||||
style={{ left: `calc(4rem + ${panel()}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
</div>
|
||||
|
||||
@@ -104,7 +104,7 @@ const SessionRow = (props: {
|
||||
}): JSX.Element => (
|
||||
<A
|
||||
href={`/${props.slug}/session/${props.session.id}`}
|
||||
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
onPointerDown={props.warmPress}
|
||||
onPointerEnter={props.warmHover}
|
||||
onPointerLeave={props.cancelHoverPrefetch}
|
||||
@@ -115,30 +115,26 @@ const SessionRow = (props: {
|
||||
props.clearHoverProjectSoon()
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<div
|
||||
class="shrink-0 size-6 flex items-center justify-center"
|
||||
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
||||
>
|
||||
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
|
||||
<Match when={props.isWorking()}>
|
||||
<Spinner class="size-[15px]" />
|
||||
</Match>
|
||||
<Match when={props.hasPermissions()}>
|
||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||
</Match>
|
||||
<Match when={props.hasError()}>
|
||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
</Match>
|
||||
<Match when={props.unseenCount() > 0}>
|
||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
|
||||
{props.session.title}
|
||||
</span>
|
||||
<div
|
||||
class="shrink-0 size-6 flex items-center justify-center"
|
||||
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
||||
>
|
||||
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
|
||||
<Match when={props.isWorking()}>
|
||||
<Spinner class="size-[15px]" />
|
||||
</Match>
|
||||
<Match when={props.hasPermissions()}>
|
||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||
</Match>
|
||||
<Match when={props.hasError()}>
|
||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
</Match>
|
||||
<Match when={props.unseenCount() > 0}>
|
||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
|
||||
</A>
|
||||
)
|
||||
|
||||
@@ -157,34 +153,49 @@ const SessionHoverPreview = (props: {
|
||||
messageLabel: (message: Message) => string | undefined
|
||||
onMessageSelect: (message: Message) => void
|
||||
trigger: JSX.Element
|
||||
}): JSX.Element => (
|
||||
<HoverCard
|
||||
openDelay={1000}
|
||||
closeDelay={props.sidebarHovering() ? 600 : 0}
|
||||
placement="right-start"
|
||||
gutter={16}
|
||||
shift={-2}
|
||||
trigger={props.trigger}
|
||||
open={props.hoverSession() === props.session.id}
|
||||
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
|
||||
>
|
||||
<Show
|
||||
when={props.hoverReady()}
|
||||
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
|
||||
}): JSX.Element => {
|
||||
let ref: HTMLDivElement | undefined
|
||||
|
||||
return (
|
||||
<HoverCard
|
||||
openDelay={1000}
|
||||
closeDelay={props.sidebarHovering() ? 600 : 0}
|
||||
placement="right-start"
|
||||
gutter={16}
|
||||
shift={-2}
|
||||
trigger={
|
||||
<div ref={ref} class="min-w-0 w-full">
|
||||
{props.trigger}
|
||||
</div>
|
||||
}
|
||||
open={props.hoverSession() === props.session.id}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
props.setHoverSession(undefined)
|
||||
return
|
||||
}
|
||||
if (!ref?.matches(":hover")) return
|
||||
props.setHoverSession(props.session.id)
|
||||
}}
|
||||
>
|
||||
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
|
||||
<MessageNav
|
||||
messages={props.hoverMessages() ?? []}
|
||||
current={undefined}
|
||||
getLabel={props.messageLabel}
|
||||
onMessageSelect={props.onMessageSelect}
|
||||
size="normal"
|
||||
class="w-60"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</HoverCard>
|
||||
)
|
||||
<Show
|
||||
when={props.hoverReady()}
|
||||
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
|
||||
>
|
||||
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
|
||||
<MessageNav
|
||||
messages={props.hoverMessages() ?? []}
|
||||
current={undefined}
|
||||
getLabel={props.messageLabel}
|
||||
onMessageSelect={props.onMessageSelect}
|
||||
size="normal"
|
||||
class="w-60"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</HoverCard>
|
||||
)
|
||||
}
|
||||
|
||||
export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
const params = useParams()
|
||||
@@ -298,62 +309,71 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
data-session-id={props.session.id}
|
||||
class="group/session relative w-full rounded-md cursor-default pl-2 pr-3 transition-colors
|
||||
class="group/session relative w-full min-w-0 rounded-md cursor-default pl-2 pr-3 transition-colors
|
||||
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
||||
>
|
||||
<Show
|
||||
when={hoverEnabled()}
|
||||
fallback={
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
|
||||
{item}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<SessionHoverPreview
|
||||
mobile={props.mobile}
|
||||
nav={props.nav}
|
||||
hoverSession={props.hoverSession}
|
||||
session={props.session}
|
||||
sidebarHovering={props.sidebarHovering}
|
||||
hoverReady={hoverReady}
|
||||
hoverMessages={hoverMessages}
|
||||
language={language}
|
||||
isActive={isActive}
|
||||
slug={props.slug}
|
||||
setHoverSession={props.setHoverSession}
|
||||
messageLabel={messageLabel}
|
||||
onMessageSelect={(message) => {
|
||||
if (!isActive())
|
||||
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<Show
|
||||
when={hoverEnabled()}
|
||||
fallback={
|
||||
<Tooltip
|
||||
placement={props.mobile ? "bottom" : "right"}
|
||||
value={props.session.title}
|
||||
gutter={10}
|
||||
class="min-w-0 w-full"
|
||||
>
|
||||
{item}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<SessionHoverPreview
|
||||
mobile={props.mobile}
|
||||
nav={props.nav}
|
||||
hoverSession={props.hoverSession}
|
||||
session={props.session}
|
||||
sidebarHovering={props.sidebarHovering}
|
||||
hoverReady={hoverReady}
|
||||
hoverMessages={hoverMessages}
|
||||
language={language}
|
||||
isActive={isActive}
|
||||
slug={props.slug}
|
||||
setHoverSession={props.setHoverSession}
|
||||
messageLabel={messageLabel}
|
||||
onMessageSelect={(message) => {
|
||||
if (!isActive())
|
||||
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
|
||||
|
||||
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
|
||||
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
|
||||
}}
|
||||
trigger={item}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
||||
classList={{
|
||||
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
|
||||
"w-0 opacity-0 pointer-events-none": !props.mobile,
|
||||
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
|
||||
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
|
||||
}}
|
||||
trigger={item}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
|
||||
classList={{
|
||||
"opacity-100 pointer-events-auto": !!props.mobile,
|
||||
"opacity-0 pointer-events-none": !props.mobile,
|
||||
"group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
|
||||
"group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
|
||||
}}
|
||||
>
|
||||
<Tooltip value={language.t("common.archive")} placement="top">
|
||||
<IconButton
|
||||
icon="archive"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md"
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.session)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
>
|
||||
<Tooltip value={language.t("common.archive")} placement="top">
|
||||
<IconButton
|
||||
icon="archive"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md"
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.session)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -375,30 +395,26 @@ export const NewSessionItem = (props: {
|
||||
<A
|
||||
href={`/${props.slug}/session`}
|
||||
end
|
||||
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
onClick={() => {
|
||||
props.setHoverSession(undefined)
|
||||
if (layout.sidebar.opened()) return
|
||||
props.clearHoverProjectSoon()
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<div class="shrink-0 size-6 flex items-center justify-center">
|
||||
<Icon name="new-session" size="small" class="text-icon-weak" />
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
|
||||
{label}
|
||||
</span>
|
||||
<div class="shrink-0 size-6 flex items-center justify-center">
|
||||
<Icon name="new-session" size="small" class="text-icon-weak" />
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span>
|
||||
</A>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
|
||||
<div class="group/session relative w-full min-w-0 rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
|
||||
<Show
|
||||
when={!tooltip()}
|
||||
fallback={
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10}>
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10} class="min-w-0 w-full">
|
||||
{item}
|
||||
</Tooltip>
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export type ProjectSidebarContext = {
|
||||
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
||||
onProjectMouseLeave: (worktree: string) => void
|
||||
onProjectFocus: (worktree: string) => void
|
||||
onHoverOpenChanged: (worktree: string, hovered: boolean) => void
|
||||
navigateToProject: (directory: string) => void
|
||||
openSidebar: () => void
|
||||
closeProject: (directory: string) => void
|
||||
@@ -109,8 +110,14 @@ const ProjectTile = (props: {
|
||||
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button === 0 && !event.ctrlKey) {
|
||||
props.setOpen(false)
|
||||
props.setSuppressHover(true)
|
||||
return
|
||||
}
|
||||
if (!props.overlay()) return
|
||||
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
|
||||
props.setOpen(false)
|
||||
props.setSuppressHover(true)
|
||||
event.preventDefault()
|
||||
}}
|
||||
@@ -130,12 +137,11 @@ const ProjectTile = (props: {
|
||||
props.onProjectFocus(props.project.worktree)
|
||||
}}
|
||||
onClick={() => {
|
||||
props.setOpen(false)
|
||||
if (props.selected()) {
|
||||
props.setSuppressHover(true)
|
||||
layout.sidebar.toggle()
|
||||
return
|
||||
}
|
||||
props.setSuppressHover(false)
|
||||
props.navigateToProject(props.project.worktree)
|
||||
}}
|
||||
onBlur={() => props.setOpen(false)}
|
||||
@@ -192,7 +198,6 @@ const ProjectPreviewPanel = (props: {
|
||||
projectChildren: Accessor<Map<string, string[]>>
|
||||
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
|
||||
workspaceChildren: (directory: string) => Map<string, string[]>
|
||||
setOpen: (value: boolean) => void
|
||||
ctx: ProjectSidebarContext
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}): JSX.Element => (
|
||||
@@ -259,7 +264,7 @@ const ProjectPreviewPanel = (props: {
|
||||
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
|
||||
onClick={() => {
|
||||
props.ctx.openSidebar()
|
||||
props.setOpen(false)
|
||||
props.ctx.onHoverOpenChanged(props.project.worktree, false)
|
||||
if (props.selected()) return
|
||||
props.ctx.navigateToProject(props.project.worktree)
|
||||
}}
|
||||
@@ -284,28 +289,16 @@ export const SortableProject = (props: {
|
||||
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
||||
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
|
||||
const [state, setState] = createStore({
|
||||
open: false,
|
||||
menu: false,
|
||||
suppressHover: false,
|
||||
})
|
||||
|
||||
const isHoverProject = () => props.ctx.hoverProject() === props.project.worktree
|
||||
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
|
||||
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
|
||||
const active = createMemo(
|
||||
() => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree),
|
||||
)
|
||||
const active = createMemo(() => state.menu || (preview() ? isHoverProject() : overlay() && isHoverProject()))
|
||||
|
||||
createEffect(() => {
|
||||
if (preview()) return
|
||||
if (!state.open) return
|
||||
setState("open", false)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!selected()) return
|
||||
if (!state.open) return
|
||||
setState("open", false)
|
||||
})
|
||||
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
|
||||
|
||||
const label = (directory: string) => {
|
||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
||||
@@ -346,7 +339,7 @@ export const SortableProject = (props: {
|
||||
workspacesEnabled={props.ctx.workspacesEnabled}
|
||||
closeProject={props.ctx.closeProject}
|
||||
setMenu={(value) => setState("menu", value)}
|
||||
setOpen={(value) => setState("open", value)}
|
||||
setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)}
|
||||
setSuppressHover={(value) => setState("suppressHover", value)}
|
||||
language={language}
|
||||
/>
|
||||
@@ -357,7 +350,7 @@ export const SortableProject = (props: {
|
||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||
<Show when={preview() && !selected()} fallback={tile()}>
|
||||
<HoverCard
|
||||
open={!state.suppressHover && state.open && !state.menu}
|
||||
open={!state.suppressHover && hoverOpen() && !state.menu}
|
||||
openDelay={0}
|
||||
closeDelay={0}
|
||||
placement="right-start"
|
||||
@@ -366,7 +359,7 @@ export const SortableProject = (props: {
|
||||
onOpenChange={(value) => {
|
||||
if (state.menu) return
|
||||
if (value && state.suppressHover) return
|
||||
setState("open", value)
|
||||
props.ctx.onHoverOpenChanged(props.project.worktree, value)
|
||||
if (value) props.ctx.setHoverSession(undefined)
|
||||
}}
|
||||
>
|
||||
@@ -381,7 +374,6 @@ export const SortableProject = (props: {
|
||||
projectChildren={projectChildren}
|
||||
workspaceSessions={workspaceSessions}
|
||||
workspaceChildren={workspaceChildren}
|
||||
setOpen={(value) => setState("open", value)}
|
||||
ctx={props.ctx}
|
||||
language={language}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import {
|
||||
batch,
|
||||
onCleanup,
|
||||
@@ -40,7 +41,13 @@ import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
||||
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
||||
import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||
import {
|
||||
createOpenReviewFile,
|
||||
createSessionTabs,
|
||||
createSizing,
|
||||
focusTerminalById,
|
||||
shouldFocusTerminalOnKeyDown,
|
||||
} from "@/pages/session/helpers"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
@@ -57,6 +64,9 @@ import { formatServerError } from "@/utils/server-errors"
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
const emptyFollowups: (FollowupDraft & { id: string })[] = []
|
||||
|
||||
type ChangeMode = "git" | "branch" | "session" | "turn"
|
||||
type VcsMode = "git" | "branch"
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
sessionID: () => string | undefined
|
||||
messagesReady: () => boolean
|
||||
@@ -239,14 +249,19 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
|
||||
if (added <= 0) return
|
||||
if (growth <= 0) return
|
||||
|
||||
if (opts?.prefetch) {
|
||||
const current = turnStart()
|
||||
preserveScroll(() => setTurnStart(current + growth))
|
||||
return
|
||||
}
|
||||
|
||||
if (turnStart() !== start) return
|
||||
|
||||
const reveal = !opts?.prefetch
|
||||
const currentRendered = renderedUserMessages().length
|
||||
const base = Math.max(beforeRendered, currentRendered)
|
||||
const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
|
||||
const nextStart = Math.max(0, afterVisible - target)
|
||||
preserveScroll(() => setTurnStart(nextStart))
|
||||
const target = Math.min(afterVisible, base + turnBatch)
|
||||
preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
|
||||
}
|
||||
|
||||
const onScrollerScroll = () => {
|
||||
@@ -327,10 +342,7 @@ export default function Page() {
|
||||
})
|
||||
|
||||
const [ui, setUi] = createStore({
|
||||
git: false,
|
||||
pendingMessage: undefined as string | undefined,
|
||||
restoring: undefined as string | undefined,
|
||||
reverting: false,
|
||||
reviewSnap: false,
|
||||
scrollGesture: 0,
|
||||
scroll: {
|
||||
@@ -415,15 +427,16 @@ export default function Page() {
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasReview = createMemo(() => reviewCount() > 0)
|
||||
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasSessionReview = createMemo(() => sessionCount() > 0)
|
||||
const canReview = createMemo(() => !!params.id)
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab,
|
||||
review: reviewTab,
|
||||
hasReview,
|
||||
hasReview: canReview,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openedTabs = tabState.openedTabs
|
||||
@@ -446,6 +459,12 @@ export default function Page() {
|
||||
if (!id) return false
|
||||
return sync.session.history.loading(id)
|
||||
})
|
||||
const diffsReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
if (!hasSessionReview()) return true
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
@@ -499,14 +518,24 @@ export default function Page() {
|
||||
const [store, setStore] = createStore({
|
||||
messageId: undefined as string | undefined,
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
changes: "session" as "session" | "turn",
|
||||
changes: "git" as ChangeMode,
|
||||
newSessionWorktree: "main",
|
||||
deferRender: false,
|
||||
})
|
||||
|
||||
const [vcs, setVcs] = createStore({
|
||||
diff: {
|
||||
git: [] as FileDiff[],
|
||||
branch: [] as FileDiff[],
|
||||
},
|
||||
ready: {
|
||||
git: false,
|
||||
branch: false,
|
||||
},
|
||||
})
|
||||
|
||||
const [followup, setFollowup] = createStore({
|
||||
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
|
||||
sending: {} as Record<string, string | undefined>,
|
||||
failed: {} as Record<string, string | undefined>,
|
||||
paused: {} as Record<string, boolean | undefined>,
|
||||
edit: {} as Record<
|
||||
@@ -531,6 +560,68 @@ export default function Page() {
|
||||
let refreshTimer: number | undefined
|
||||
let diffFrame: number | undefined
|
||||
let diffTimer: number | undefined
|
||||
const vcsTask = new Map<VcsMode, Promise<void>>()
|
||||
const vcsRun = new Map<VcsMode, number>()
|
||||
|
||||
const bumpVcs = (mode: VcsMode) => {
|
||||
const next = (vcsRun.get(mode) ?? 0) + 1
|
||||
vcsRun.set(mode, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const resetVcs = (mode?: VcsMode) => {
|
||||
const list = mode ? [mode] : (["git", "branch"] as const)
|
||||
list.forEach((item) => {
|
||||
bumpVcs(item)
|
||||
vcsTask.delete(item)
|
||||
setVcs("diff", item, [])
|
||||
setVcs("ready", item, false)
|
||||
})
|
||||
}
|
||||
|
||||
const loadVcs = (mode: VcsMode, force = false) => {
|
||||
if (sync.project?.vcs !== "git") return Promise.resolve()
|
||||
if (!force && vcs.ready[mode]) return Promise.resolve()
|
||||
|
||||
if (force) {
|
||||
if (vcsTask.has(mode)) bumpVcs(mode)
|
||||
vcsTask.delete(mode)
|
||||
setVcs("ready", mode, false)
|
||||
}
|
||||
|
||||
const current = vcsTask.get(mode)
|
||||
if (current) return current
|
||||
|
||||
const run = bumpVcs(mode)
|
||||
|
||||
const task = sdk.client.vcs
|
||||
.diff({ mode })
|
||||
.then((result) => {
|
||||
if (vcsRun.get(mode) !== run) return
|
||||
setVcs("diff", mode, result.data ?? [])
|
||||
setVcs("ready", mode, true)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (vcsRun.get(mode) !== run) return
|
||||
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||
setVcs("diff", mode, [])
|
||||
setVcs("ready", mode, true)
|
||||
})
|
||||
.finally(() => {
|
||||
if (vcsTask.get(mode) === task) vcsTask.delete(mode)
|
||||
})
|
||||
|
||||
vcsTask.set(mode, task)
|
||||
return task
|
||||
}
|
||||
|
||||
const refreshVcs = () => {
|
||||
resetVcs()
|
||||
const mode = untrack(vcsMode)
|
||||
if (!mode) return
|
||||
if (!untrack(wantsReview)) return
|
||||
void loadVcs(mode, true)
|
||||
}
|
||||
|
||||
createComputed((prev) => {
|
||||
const open = desktopReviewOpen()
|
||||
@@ -546,7 +637,42 @@ export default function Page() {
|
||||
}, desktopReviewOpen())
|
||||
|
||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
||||
const changesOptions = createMemo<ChangeMode[]>(() => {
|
||||
const list: ChangeMode[] = []
|
||||
if (sync.project?.vcs === "git") list.push("git")
|
||||
if (
|
||||
sync.project?.vcs === "git" &&
|
||||
sync.data.vcs?.branch &&
|
||||
sync.data.vcs?.default_branch &&
|
||||
sync.data.vcs.branch !== sync.data.vcs.default_branch
|
||||
) {
|
||||
list.push("branch")
|
||||
}
|
||||
list.push("session", "turn")
|
||||
return list
|
||||
})
|
||||
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
||||
if (store.changes === "git" || store.changes === "branch") return store.changes
|
||||
})
|
||||
const reviewDiffs = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.diff.git
|
||||
if (store.changes === "branch") return vcs.diff.branch
|
||||
if (store.changes === "session") return diffs()
|
||||
return turnDiffs()
|
||||
})
|
||||
const reviewCount = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.diff.git.length
|
||||
if (store.changes === "branch") return vcs.diff.branch.length
|
||||
if (store.changes === "session") return sessionCount()
|
||||
return turnDiffs().length
|
||||
})
|
||||
const hasReview = createMemo(() => reviewCount() > 0)
|
||||
const reviewReady = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.ready.git
|
||||
if (store.changes === "branch") return vcs.ready.branch
|
||||
if (store.changes === "session") return !hasSessionReview() || diffsReady()
|
||||
return true
|
||||
})
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.newSessionWorktree === "create") return "create"
|
||||
@@ -612,13 +738,7 @@ export default function Page() {
|
||||
scrollToMessage(msgs[targetIndex], "auto")
|
||||
}
|
||||
|
||||
const diffsReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
if (!hasReview()) return true
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
const reviewEmptyKey = createMemo(() => {
|
||||
const sessionEmptyKey = createMemo(() => {
|
||||
const project = sync.project
|
||||
if (project && !project.vcs) return "session.review.noVcs"
|
||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||
@@ -644,25 +764,24 @@ export default function Page() {
|
||||
globalSync.set("project", [...list, next])
|
||||
}
|
||||
|
||||
const gitMutation = useMutation(() => ({
|
||||
mutationFn: () => sdk.client.project.initGit(),
|
||||
onSuccess: (x) => {
|
||||
if (!x.data) return
|
||||
upsert(x.data)
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
function initGit() {
|
||||
if (ui.git) return
|
||||
setUi("git", true)
|
||||
void sdk.client.project
|
||||
.initGit()
|
||||
.then((x) => {
|
||||
if (!x.data) return
|
||||
upsert(x.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setUi("git", false)
|
||||
})
|
||||
if (gitMutation.isPending) return
|
||||
gitMutation.mutate()
|
||||
}
|
||||
|
||||
let inputRef!: HTMLDivElement
|
||||
@@ -741,13 +860,46 @@ export default function Page() {
|
||||
sessionKey,
|
||||
() => {
|
||||
setStore("messageId", undefined)
|
||||
setStore("changes", "session")
|
||||
setStore("changes", "git")
|
||||
setUi("pendingMessage", undefined)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sdk.directory,
|
||||
() => {
|
||||
resetVcs()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
|
||||
(next, prev) => {
|
||||
if (prev === undefined || same(next, prev)) return
|
||||
refreshVcs()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const stopVcs = sdk.event.listen((evt) => {
|
||||
if (evt.details.type !== "file.watcher.updated") return
|
||||
const props =
|
||||
typeof evt.details.properties === "object" && evt.details.properties
|
||||
? (evt.details.properties as Record<string, unknown>)
|
||||
: undefined
|
||||
const file = typeof props?.file === "string" ? props.file : undefined
|
||||
if (!file || file.startsWith(".git/")) return
|
||||
refreshVcs()
|
||||
})
|
||||
onCleanup(stopVcs)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => params.dir,
|
||||
@@ -854,7 +1006,7 @@ export default function Page() {
|
||||
// Prefer the open terminal over the composer when it can take focus
|
||||
if (view().terminal.opened()) {
|
||||
const id = terminal.active()
|
||||
if (id && focusTerminalById(id)) return
|
||||
if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return
|
||||
}
|
||||
|
||||
// Only treat explicit scroll keys as potential "user scroll" gestures.
|
||||
@@ -870,6 +1022,40 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
||||
const wantsReview = createMemo(() =>
|
||||
isDesktop()
|
||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||
: store.mobileTab === "changes",
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const list = changesOptions()
|
||||
if (list.includes(store.changes)) return
|
||||
const next = list[0]
|
||||
if (!next) return
|
||||
setStore("changes", next)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const mode = vcsMode()
|
||||
if (!mode) return
|
||||
if (!wantsReview()) return
|
||||
void loadVcs(mode)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sync.data.session_status[params.id ?? ""]?.type,
|
||||
(next, prev) => {
|
||||
const mode = vcsMode()
|
||||
if (!mode) return
|
||||
if (!wantsReview()) return
|
||||
if (next !== "idle" || prev === undefined || prev === "idle") return
|
||||
void loadVcs(mode, true)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const fileTreeTab = () => layout.fileTree.tab()
|
||||
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
|
||||
@@ -916,21 +1102,23 @@ export default function Page() {
|
||||
loadFile: file.load,
|
||||
})
|
||||
|
||||
const changesOptions = ["session", "turn"] as const
|
||||
const changesOptionsList = [...changesOptions]
|
||||
|
||||
const changesTitle = () => {
|
||||
if (!hasReview()) {
|
||||
if (!canReview()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = (option: ChangeMode) => {
|
||||
if (option === "git") return language.t("ui.sessionReview.title.git")
|
||||
if (option === "branch") return language.t("ui.sessionReview.title.branch")
|
||||
if (option === "session") return language.t("ui.sessionReview.title")
|
||||
return language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={changesOptionsList}
|
||||
options={changesOptions()}
|
||||
current={store.changes}
|
||||
label={(option) =>
|
||||
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
label={label}
|
||||
onSelect={(option) => option && setStore("changes", option)}
|
||||
variant="ghost"
|
||||
size="small"
|
||||
@@ -939,20 +1127,34 @@ export default function Page() {
|
||||
)
|
||||
}
|
||||
|
||||
const emptyTurn = () => (
|
||||
const empty = (text: string) => (
|
||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||
if (store.changes === "turn") return emptyTurn()
|
||||
const reviewEmptyText = createMemo(() => {
|
||||
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
|
||||
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
|
||||
if (store.changes === "turn") return language.t("session.review.noChanges")
|
||||
return language.t(sessionEmptyKey())
|
||||
})
|
||||
|
||||
if (hasReview() && !diffsReady()) {
|
||||
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||
if (store.changes === "git" || store.changes === "branch") {
|
||||
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||
return empty(reviewEmptyText())
|
||||
}
|
||||
|
||||
if (store.changes === "turn") {
|
||||
return empty(reviewEmptyText())
|
||||
}
|
||||
|
||||
if (hasSessionReview() && !diffsReady()) {
|
||||
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||
}
|
||||
|
||||
if (reviewEmptyKey() === "session.review.noVcs") {
|
||||
if (sessionEmptyKey() === "session.review.noVcs") {
|
||||
return (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -961,8 +1163,8 @@ export default function Page() {
|
||||
{language.t("session.review.noVcs.createGit.description")}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="large" disabled={ui.git} onClick={initGit}>
|
||||
{ui.git
|
||||
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
|
||||
{gitMutation.isPending
|
||||
? language.t("session.review.noVcs.createGit.actionLoading")
|
||||
: language.t("session.review.noVcs.createGit.action")}
|
||||
</Button>
|
||||
@@ -972,7 +1174,7 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1076,7 +1278,7 @@ export default function Page() {
|
||||
const pending = tree.pendingDiff
|
||||
if (!pending) return
|
||||
if (!tree.reviewScroll) return
|
||||
if (!diffsReady()) return
|
||||
if (!reviewReady()) return
|
||||
|
||||
const attempt = (count: number) => {
|
||||
if (tree.pendingDiff !== pending) return
|
||||
@@ -1117,10 +1319,7 @@ export default function Page() {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
|
||||
const wants = isDesktop()
|
||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||
: store.mobileTab === "changes"
|
||||
if (!wants) return
|
||||
if (!wantsReview()) return
|
||||
if (sync.data.session_diff[id] !== undefined) return
|
||||
if (sync.status === "loading") return
|
||||
|
||||
@@ -1129,13 +1328,7 @@ export default function Page() {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() =>
|
||||
[
|
||||
sessionKey(),
|
||||
isDesktop()
|
||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||
: store.mobileTab === "changes",
|
||||
] as const,
|
||||
() => [sessionKey(), wantsReview()] as const,
|
||||
([key, wants]) => {
|
||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
||||
@@ -1177,8 +1370,6 @@ export default function Page() {
|
||||
on(
|
||||
() => sdk.directory,
|
||||
() => {
|
||||
void file.tree.list("")
|
||||
|
||||
const tab = activeFileTab()
|
||||
if (!tab) return
|
||||
const path = file.pathFromTab(tab)
|
||||
@@ -1379,10 +1570,40 @@ export default function Page() {
|
||||
return followup.edit[id]
|
||||
})
|
||||
|
||||
const followupMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
||||
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
|
||||
if (!item) return
|
||||
|
||||
if (input.manual) setFollowup("paused", input.sessionID, undefined)
|
||||
setFollowup("failed", input.sessionID, undefined)
|
||||
|
||||
const ok = await sendFollowupDraft({
|
||||
client: sdk.client,
|
||||
sync,
|
||||
globalSync,
|
||||
draft: item,
|
||||
optimisticBusy: item.sessionDirectory === sdk.directory,
|
||||
}).catch((err) => {
|
||||
setFollowup("failed", input.sessionID, input.id)
|
||||
fail(err)
|
||||
return false
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
|
||||
if (input.manual) resumeScroll()
|
||||
},
|
||||
}))
|
||||
|
||||
const followupBusy = (sessionID: string) =>
|
||||
followupMutation.isPending && followupMutation.variables?.sessionID === sessionID
|
||||
|
||||
const sendingFollowup = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
return followup.sending[id]
|
||||
if (!followupBusy(id)) return
|
||||
return followupMutation.variables?.id
|
||||
})
|
||||
|
||||
const queueEnabled = createMemo(() => {
|
||||
@@ -1422,37 +1643,15 @@ export default function Page() {
|
||||
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
||||
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
||||
if (!item) return Promise.resolve()
|
||||
if (followup.sending[sessionID]) return Promise.resolve()
|
||||
if (followupBusy(sessionID)) return Promise.resolve()
|
||||
|
||||
if (opts?.manual) setFollowup("paused", sessionID, undefined)
|
||||
setFollowup("sending", sessionID, id)
|
||||
setFollowup("failed", sessionID, undefined)
|
||||
|
||||
return sendFollowupDraft({
|
||||
client: sdk.client,
|
||||
sync,
|
||||
globalSync,
|
||||
draft: item,
|
||||
optimisticBusy: item.sessionDirectory === sdk.directory,
|
||||
})
|
||||
.then((ok) => {
|
||||
if (ok === false) return
|
||||
setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
|
||||
if (opts?.manual) resumeScroll()
|
||||
})
|
||||
.catch((err) => {
|
||||
setFollowup("failed", sessionID, id)
|
||||
fail(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setFollowup("sending", sessionID, (value) => (value === id ? undefined : value))
|
||||
})
|
||||
return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual })
|
||||
}
|
||||
|
||||
const editFollowup = (id: string) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (followup.sending[sessionID]) return
|
||||
if (followupBusy(sessionID)) return
|
||||
|
||||
const item = queuedFollowups().find((entry) => entry.id === id)
|
||||
if (!item) return
|
||||
@@ -1475,6 +1674,74 @@ export default function Page() {
|
||||
const halt = (sessionID: string) =>
|
||||
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
|
||||
|
||||
const revertMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||
const prev = prompt.current().slice()
|
||||
const last = info()?.revert
|
||||
const value = draft(input.messageID)
|
||||
batch(() => {
|
||||
roll(input.sessionID, { messageID: input.messageID })
|
||||
prompt.set(value)
|
||||
})
|
||||
await halt(input.sessionID)
|
||||
.then(() => sdk.client.session.revert(input))
|
||||
.then((result) => {
|
||||
if (result.data) merge(result.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
batch(() => {
|
||||
roll(input.sessionID, last)
|
||||
prompt.set(prev)
|
||||
})
|
||||
fail(err)
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const restoreMutation = useMutation(() => ({
|
||||
mutationFn: async (id: string) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const next = userMessages().find((item) => item.id > id)
|
||||
const prev = prompt.current().slice()
|
||||
const last = info()?.revert
|
||||
|
||||
batch(() => {
|
||||
roll(sessionID, next ? { messageID: next.id } : undefined)
|
||||
if (next) {
|
||||
prompt.set(draft(next.id))
|
||||
return
|
||||
}
|
||||
prompt.reset()
|
||||
})
|
||||
|
||||
const task = !next
|
||||
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
|
||||
: halt(sessionID).then(() =>
|
||||
sdk.client.session.revert({
|
||||
sessionID,
|
||||
messageID: next.id,
|
||||
}),
|
||||
)
|
||||
|
||||
await task
|
||||
.then((result) => {
|
||||
if (result.data) merge(result.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
batch(() => {
|
||||
roll(sessionID, last)
|
||||
prompt.set(prev)
|
||||
})
|
||||
fail(err)
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
|
||||
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
|
||||
|
||||
const fork = (input: { sessionID: string; messageID: string }) => {
|
||||
const value = draft(input.messageID)
|
||||
const dir = base64Encode(sdk.directory)
|
||||
@@ -1496,77 +1763,13 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const revert = (input: { sessionID: string; messageID: string }) => {
|
||||
if (ui.reverting || ui.restoring) return
|
||||
const prev = prompt.current().slice()
|
||||
const last = info()?.revert
|
||||
const value = draft(input.messageID)
|
||||
batch(() => {
|
||||
setUi("reverting", true)
|
||||
roll(input.sessionID, { messageID: input.messageID })
|
||||
prompt.set(value)
|
||||
})
|
||||
return halt(input.sessionID)
|
||||
.then(() => sdk.client.session.revert(input))
|
||||
.then((result) => {
|
||||
if (result.data) merge(result.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
batch(() => {
|
||||
roll(input.sessionID, last)
|
||||
prompt.set(prev)
|
||||
})
|
||||
fail(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setUi("reverting", false)
|
||||
})
|
||||
if (reverting()) return
|
||||
return revertMutation.mutateAsync(input)
|
||||
}
|
||||
|
||||
const restore = (id: string) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID || ui.restoring || ui.reverting) return
|
||||
|
||||
const next = userMessages().find((item) => item.id > id)
|
||||
const prev = prompt.current().slice()
|
||||
const last = info()?.revert
|
||||
|
||||
batch(() => {
|
||||
setUi("restoring", id)
|
||||
setUi("reverting", true)
|
||||
roll(sessionID, next ? { messageID: next.id } : undefined)
|
||||
if (next) {
|
||||
prompt.set(draft(next.id))
|
||||
return
|
||||
}
|
||||
prompt.reset()
|
||||
})
|
||||
|
||||
const task = !next
|
||||
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
|
||||
: halt(sessionID).then(() =>
|
||||
sdk.client.session.revert({
|
||||
sessionID,
|
||||
messageID: next.id,
|
||||
}),
|
||||
)
|
||||
|
||||
return task
|
||||
.then((result) => {
|
||||
if (result.data) merge(result.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
batch(() => {
|
||||
roll(sessionID, last)
|
||||
prompt.set(prev)
|
||||
})
|
||||
fail(err)
|
||||
})
|
||||
.finally(() => {
|
||||
batch(() => {
|
||||
setUi("restoring", (value) => (value === id ? undefined : value))
|
||||
setUi("reverting", false)
|
||||
})
|
||||
})
|
||||
if (!params.id || reverting()) return
|
||||
return restoreMutation.mutateAsync(id)
|
||||
}
|
||||
|
||||
const rolled = createMemo(() => {
|
||||
@@ -1585,7 +1788,7 @@ export default function Page() {
|
||||
|
||||
const item = queuedFollowups()[0]
|
||||
if (!item) return
|
||||
if (followup.sending[sessionID]) return
|
||||
if (followupBusy(sessionID)) return
|
||||
if (followup.failed[sessionID] === item.id) return
|
||||
if (followup.paused[sessionID]) return
|
||||
if (composer.blocked()) return
|
||||
@@ -1621,6 +1824,9 @@ export default function Page() {
|
||||
sessionID: () => params.id,
|
||||
messagesReady,
|
||||
visibleUserMessages,
|
||||
historyMore,
|
||||
historyLoading,
|
||||
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
||||
turnStart: historyWindow.turnStart,
|
||||
currentMessageId: () => store.messageId,
|
||||
pendingMessage: () => ui.pendingMessage,
|
||||
@@ -1692,7 +1898,7 @@ export default function Page() {
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id}>
|
||||
<Show when={lastUserMessage()}>
|
||||
<Show when={messagesReady()}>
|
||||
<MessageTimeline
|
||||
mobileChanges={mobileChanges()}
|
||||
mobileFallback={reviewContent({
|
||||
@@ -1780,8 +1986,8 @@ export default function Page() {
|
||||
rolled().length > 0
|
||||
? {
|
||||
items: rolled(),
|
||||
restoring: ui.restoring,
|
||||
disabled: ui.reverting,
|
||||
restoring: restoring(),
|
||||
disabled: reverting(),
|
||||
onRestore: restore,
|
||||
}
|
||||
: undefined
|
||||
@@ -1808,6 +2014,12 @@ export default function Page() {
|
||||
</div>
|
||||
|
||||
<SessionSidePanel
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanel}
|
||||
activeDiff={tree.activeDiff}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -24,7 +26,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
custom: cached?.custom ?? ([] as string[]),
|
||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||
editing: false,
|
||||
sending: false,
|
||||
collapsed: false,
|
||||
})
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
@@ -35,6 +37,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const picked = createMemo(() => store.answers[store.tab]?.length ?? 0)
|
||||
|
||||
const summary = createMemo(() => {
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
@@ -43,6 +46,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
|
||||
const last = createMemo(() => store.tab >= total() - 1)
|
||||
|
||||
const fold = () => setStore("collapsed", (value) => !value)
|
||||
|
||||
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||
const prev = input().trim()
|
||||
const next = value.trim()
|
||||
@@ -126,36 +131,40 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
}
|
||||
|
||||
const reply = async (answers: QuestionAnswer[]) => {
|
||||
if (store.sending) return
|
||||
|
||||
props.onSubmit()
|
||||
setStore("sending", true)
|
||||
try {
|
||||
await sdk.client.question.reply({ requestID: props.request.id, answers })
|
||||
const replyMutation = useMutation(() => ({
|
||||
mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
onSuccess: () => {
|
||||
replied = true
|
||||
cache.delete(props.request.id)
|
||||
} catch (err) {
|
||||
fail(err)
|
||||
} finally {
|
||||
setStore("sending", false)
|
||||
}
|
||||
},
|
||||
onError: fail,
|
||||
}))
|
||||
|
||||
const rejectMutation = useMutation(() => ({
|
||||
mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
onSuccess: () => {
|
||||
replied = true
|
||||
cache.delete(props.request.id)
|
||||
},
|
||||
onError: fail,
|
||||
}))
|
||||
|
||||
const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
|
||||
|
||||
const reply = async (answers: QuestionAnswer[]) => {
|
||||
if (sending()) return
|
||||
await replyMutation.mutateAsync(answers)
|
||||
}
|
||||
|
||||
const reject = async () => {
|
||||
if (store.sending) return
|
||||
|
||||
props.onSubmit()
|
||||
setStore("sending", true)
|
||||
try {
|
||||
await sdk.client.question.reject({ requestID: props.request.id })
|
||||
replied = true
|
||||
cache.delete(props.request.id)
|
||||
} catch (err) {
|
||||
fail(err)
|
||||
} finally {
|
||||
setStore("sending", false)
|
||||
}
|
||||
if (sending()) return
|
||||
await rejectMutation.mutateAsync()
|
||||
}
|
||||
|
||||
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
|
||||
@@ -175,7 +184,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
}
|
||||
|
||||
const customToggle = () => {
|
||||
if (store.sending) return
|
||||
if (sending()) return
|
||||
|
||||
if (!multi()) {
|
||||
setStore("customOn", store.tab, true)
|
||||
@@ -198,14 +207,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
}
|
||||
|
||||
const customOpen = () => {
|
||||
if (store.sending) return
|
||||
if (sending()) return
|
||||
if (!on()) setStore("customOn", store.tab, true)
|
||||
setStore("editing", true)
|
||||
customUpdate(input(), true)
|
||||
}
|
||||
|
||||
const selectOption = (optIndex: number) => {
|
||||
if (store.sending) return
|
||||
if (sending()) return
|
||||
|
||||
if (optIndex === options().length) {
|
||||
customOpen()
|
||||
@@ -227,7 +236,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
}
|
||||
|
||||
const next = () => {
|
||||
if (store.sending) return
|
||||
if (sending()) return
|
||||
if (store.editing) commitCustom()
|
||||
|
||||
if (store.tab >= total() - 1) {
|
||||
@@ -240,14 +249,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
}
|
||||
|
||||
const back = () => {
|
||||
if (store.sending) return
|
||||
if (sending()) return
|
||||
if (store.tab <= 0) return
|
||||
setStore("tab", store.tab - 1)
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
const jump = (tab: number) => {
|
||||
if (store.sending) return
|
||||
if (sending()) return
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
}
|
||||
@@ -257,9 +266,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
kind="question"
|
||||
ref={(el) => (root = el)}
|
||||
header={
|
||||
<>
|
||||
<div
|
||||
data-action="session-question-toggle"
|
||||
class="flex flex-1 min-w-0 items-center gap-2 cursor-default select-none"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={{ margin: "0 -10px", padding: "0 0 0 10px" }}
|
||||
onClick={fold}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
fold()
|
||||
}}
|
||||
>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<div data-slot="question-progress">
|
||||
<div data-slot="question-progress" class="ml-auto mr-1">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
@@ -270,83 +291,173 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
(store.answers[i()]?.length ?? 0) > 0 ||
|
||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||
}
|
||||
disabled={store.sending}
|
||||
onClick={() => jump(i())}
|
||||
disabled={sending()}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
jump(i())
|
||||
}}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
<div>
|
||||
<IconButton
|
||||
data-action="session-question-toggle-button"
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
classList={{ "rotate-180": store.collapsed }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
fold()
|
||||
}}
|
||||
aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
|
||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
|
||||
{language.t("ui.common.dismiss")}
|
||||
</Button>
|
||||
<div data-slot="question-footer-actions">
|
||||
<Show when={store.tab > 0}>
|
||||
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
|
||||
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
|
||||
{language.t("ui.common.back")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
|
||||
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
|
||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div data-slot="question-text">{question()?.question}</div>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
<div
|
||||
data-slot="question-text"
|
||||
class="cursor-default"
|
||||
classList={{
|
||||
"mb-6": store.collapsed && picked() === 0,
|
||||
}}
|
||||
role={store.collapsed ? "button" : undefined}
|
||||
tabIndex={store.collapsed ? 0 : undefined}
|
||||
onClick={fold}
|
||||
onKeyDown={(event) => {
|
||||
if (!store.collapsed) return
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
fold()
|
||||
}}
|
||||
>
|
||||
{question()?.question}
|
||||
</div>
|
||||
<Show when={store.collapsed && picked() > 0}>
|
||||
<div data-slot="question-hint" class="cursor-default mb-6">
|
||||
{picked()} answer{picked() === 1 ? "" : "s"} selected
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<div data-slot="question-answers" hidden={store.collapsed} aria-hidden={store.collapsed}>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={picked()}
|
||||
disabled={sending()}
|
||||
onClick={() => selectOption(i())}
|
||||
>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={picked()}
|
||||
>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={picked()}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectOption(i())}
|
||||
aria-checked={on()}
|
||||
disabled={sending()}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={picked()}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={store.sending}
|
||||
onClick={customOpen}
|
||||
onMouseDown={(e) => {
|
||||
if (sending()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
@@ -365,80 +476,39 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
onMouseDown={(e) => {
|
||||
if (store.sending) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<textarea
|
||||
ref={(el) =>
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, 0)
|
||||
}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={store.sending}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
return
|
||||
<textarea
|
||||
ref={(el) =>
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, 0)
|
||||
}
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
e.currentTarget.style.height = "0px"
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
data-slot="question-custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
e.currentTarget.style.height = "0px"
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</DockPrompt>
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createSessionTabs,
|
||||
focusTerminalById,
|
||||
getTabReorderIndex,
|
||||
shouldFocusTerminalOnKeyDown,
|
||||
} from "./helpers"
|
||||
|
||||
describe("createOpenReviewFile", () => {
|
||||
@@ -86,6 +87,26 @@ describe("focusTerminalById", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldFocusTerminalOnKeyDown", () => {
|
||||
test("skips pure modifier keys", () => {
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Meta", metaKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Control", ctrlKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Alt", altKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Shift", shiftKey: true }))).toBe(false)
|
||||
})
|
||||
|
||||
test("skips shortcut key combos", () => {
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", metaKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", ctrlKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true }))).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps plain typing focused on terminal", () => {
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "a" }))).toBe(true)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "A", shiftKey: true }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTabReorderIndex", () => {
|
||||
test("returns target index for valid drag reorder", () => {
|
||||
expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2)
|
||||
|
||||
@@ -93,6 +93,13 @@ export const focusTerminalById = (id: string) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const skip = new Set(["Alt", "Control", "Meta", "Shift"])
|
||||
|
||||
export const shouldFocusTerminalOnKeyDown = (event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey">) => {
|
||||
if (skip.has(event.key)) return false
|
||||
return !(event.ctrlKey || event.metaKey || event.altKey)
|
||||
}
|
||||
|
||||
export const createOpenReviewFile = (input: {
|
||||
showAllFiles: () => void
|
||||
tabForPath: (path: string) => string
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
|
||||
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -29,6 +30,7 @@ import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
import { makeTimer } from "@solid-primitives/timer"
|
||||
|
||||
type MessageComment = {
|
||||
path: string
|
||||
@@ -249,38 +251,21 @@ export function MessageTimeline(props: {
|
||||
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
|
||||
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
|
||||
|
||||
const [slot, setSlot] = createStore({
|
||||
open: false,
|
||||
show: false,
|
||||
fade: false,
|
||||
const [timeoutDone, setTimeoutDone] = createSignal(true)
|
||||
|
||||
const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => {
|
||||
if (working()) return "showing"
|
||||
if (prev === "showing" || !timeoutDone()) return "hiding"
|
||||
return "hidden"
|
||||
})
|
||||
|
||||
let f: number | undefined
|
||||
const clear = () => {
|
||||
if (f !== undefined) window.clearTimeout(f)
|
||||
f = undefined
|
||||
}
|
||||
createEffect(() => {
|
||||
if (workingStatus() !== "hiding") return
|
||||
|
||||
setTimeoutDone(false)
|
||||
makeTimer(() => setTimeoutDone(true), 260, setTimeout)
|
||||
})
|
||||
|
||||
onCleanup(clear)
|
||||
createEffect(
|
||||
on(
|
||||
working,
|
||||
(on, prev) => {
|
||||
clear()
|
||||
if (on) {
|
||||
setSlot({ open: true, show: true, fade: false })
|
||||
return
|
||||
}
|
||||
if (prev) {
|
||||
setSlot({ open: false, show: true, fade: true })
|
||||
f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
|
||||
return
|
||||
}
|
||||
setSlot({ open: false, show: false, fade: false })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const activeMessageID = createMemo(() => {
|
||||
const parentID = pending()?.parentID
|
||||
if (parentID) {
|
||||
@@ -321,7 +306,6 @@ export function MessageTimeline(props: {
|
||||
const [title, setTitle] = createStore({
|
||||
draft: "",
|
||||
editing: false,
|
||||
saving: false,
|
||||
menuOpen: false,
|
||||
pendingRename: false,
|
||||
pendingShare: false,
|
||||
@@ -335,38 +319,6 @@ export function MessageTimeline(props: {
|
||||
|
||||
let more: HTMLButtonElement | undefined
|
||||
|
||||
const [req, setReq] = createStore({ share: false, unshare: false })
|
||||
|
||||
const shareSession = () => {
|
||||
const id = sessionID()
|
||||
if (!id || req.share) return
|
||||
if (!shareEnabled()) return
|
||||
setReq("share", true)
|
||||
globalSDK.client.session
|
||||
.share({ sessionID: id, directory: sdk.directory })
|
||||
.catch((err: unknown) => {
|
||||
console.error("Failed to share session", err)
|
||||
})
|
||||
.finally(() => {
|
||||
setReq("share", false)
|
||||
})
|
||||
}
|
||||
|
||||
const unshareSession = () => {
|
||||
const id = sessionID()
|
||||
if (!id || req.unshare) return
|
||||
if (!shareEnabled()) return
|
||||
setReq("unshare", true)
|
||||
globalSDK.client.session
|
||||
.unshare({ sessionID: id, directory: sdk.directory })
|
||||
.catch((err: unknown) => {
|
||||
console.error("Failed to unshare session", err)
|
||||
})
|
||||
.finally(() => {
|
||||
setReq("unshare", false)
|
||||
})
|
||||
}
|
||||
|
||||
const viewShare = () => {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
@@ -382,6 +334,54 @@ export function MessageTimeline(props: {
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
|
||||
const shareMutation = useMutation(() => ({
|
||||
mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
|
||||
onError: (err) => {
|
||||
console.error("Failed to share session", err)
|
||||
},
|
||||
}))
|
||||
|
||||
const unshareMutation = useMutation(() => ({
|
||||
mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
|
||||
onError: (err) => {
|
||||
console.error("Failed to unshare session", err)
|
||||
},
|
||||
}))
|
||||
|
||||
const titleMutation = useMutation(() => ({
|
||||
mutationFn: (input: { id: string; title: string }) =>
|
||||
sdk.client.session.update({ sessionID: input.id, title: input.title }),
|
||||
onSuccess: (_, input) => {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((s) => s.id === input.id)
|
||||
if (index !== -1) draft.session[index].title = input.title
|
||||
}),
|
||||
)
|
||||
setTitle("editing", false)
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const shareSession = () => {
|
||||
const id = sessionID()
|
||||
if (!id || shareMutation.isPending) return
|
||||
if (!shareEnabled()) return
|
||||
shareMutation.mutate(id)
|
||||
}
|
||||
|
||||
const unshareSession = () => {
|
||||
const id = sessionID()
|
||||
if (!id || unshareMutation.isPending) return
|
||||
if (!shareEnabled()) return
|
||||
unshareMutation.mutate(id)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
sessionKey,
|
||||
@@ -389,7 +389,6 @@ export function MessageTimeline(props: {
|
||||
setTitle({
|
||||
draft: "",
|
||||
editing: false,
|
||||
saving: false,
|
||||
menuOpen: false,
|
||||
pendingRename: false,
|
||||
pendingShare: false,
|
||||
@@ -408,40 +407,22 @@ export function MessageTimeline(props: {
|
||||
}
|
||||
|
||||
const closeTitleEditor = () => {
|
||||
if (title.saving) return
|
||||
setTitle({ editing: false, saving: false })
|
||||
if (titleMutation.isPending) return
|
||||
setTitle("editing", false)
|
||||
}
|
||||
|
||||
const saveTitleEditor = async () => {
|
||||
const saveTitleEditor = () => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
if (title.saving) return
|
||||
if (titleMutation.isPending) return
|
||||
|
||||
const next = title.draft.trim()
|
||||
if (!next || next === (titleValue() ?? "")) {
|
||||
setTitle({ editing: false, saving: false })
|
||||
setTitle("editing", false)
|
||||
return
|
||||
}
|
||||
|
||||
setTitle("saving", true)
|
||||
await sdk.client.session
|
||||
.update({ sessionID: id, title: next })
|
||||
.then(() => {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((s) => s.id === id)
|
||||
if (index !== -1) draft.session[index].title = next
|
||||
}),
|
||||
)
|
||||
setTitle({ editing: false, saving: false })
|
||||
})
|
||||
.catch((err) => {
|
||||
setTitle("saving", false)
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
})
|
||||
titleMutation.mutate({ id, title: next })
|
||||
}
|
||||
|
||||
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||
@@ -679,17 +660,15 @@ export function MessageTimeline(props: {
|
||||
<div
|
||||
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
style={{
|
||||
width: slot.open ? "16px" : "0px",
|
||||
"margin-right": slot.open ? "8px" : "0px",
|
||||
width: working() ? "16px" : "0px",
|
||||
"margin-right": working() ? "8px" : "0px",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Show when={slot.show}>
|
||||
<Show when={workingStatus() !== "hidden"}>
|
||||
<div
|
||||
class="transition-opacity duration-200 ease-out"
|
||||
classList={{
|
||||
"opacity-0": slot.fade,
|
||||
}}
|
||||
classList={{ "opacity-0": workingStatus() === "hiding" }}
|
||||
>
|
||||
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
|
||||
</div>
|
||||
@@ -712,7 +691,7 @@ export function MessageTimeline(props: {
|
||||
titleRef = el
|
||||
}}
|
||||
value={title.draft}
|
||||
disabled={title.saving}
|
||||
disabled={titleMutation.isPending}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
@@ -863,9 +842,9 @@ export function MessageTimeline(props: {
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={shareSession}
|
||||
disabled={req.share}
|
||||
disabled={shareMutation.isPending}
|
||||
>
|
||||
{req.share
|
||||
{shareMutation.isPending
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</Button>
|
||||
@@ -886,9 +865,9 @@ export function MessageTimeline(props: {
|
||||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={unshareSession}
|
||||
disabled={req.unshare}
|
||||
disabled={unshareMutation.isPending}
|
||||
>
|
||||
{req.unshare
|
||||
{unshareMutation.isPending
|
||||
? language.t("session.share.action.unpublishing")
|
||||
: language.t("session.share.action.unpublish")}
|
||||
</Button>
|
||||
@@ -897,7 +876,7 @@ export function MessageTimeline(props: {
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={viewShare}
|
||||
disabled={req.unshare}
|
||||
disabled={unshareMutation.isPending}
|
||||
>
|
||||
{language.t("session.share.action.view")}
|
||||
</Button>
|
||||
@@ -915,10 +894,10 @@ export function MessageTimeline(props: {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
role="log"
|
||||
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
||||
data-slot="session-turn-list"
|
||||
class="flex flex-col items-start justify-start pb-16 transition-[margin]"
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
@@ -945,7 +924,15 @@ export function MessageTimeline(props: {
|
||||
{(messageID) => {
|
||||
const active = createMemo(() => activeMessageID() === messageID)
|
||||
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
|
||||
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
|
||||
equals: (a, b) =>
|
||||
a.length === b.length &&
|
||||
a.every(
|
||||
(c, i) =>
|
||||
c.path === b[i].path &&
|
||||
c.comment === b[i].comment &&
|
||||
c.selection?.startLine === b[i].selection?.startLine &&
|
||||
c.selection?.endLine === b[i].selection?.endLine,
|
||||
),
|
||||
})
|
||||
const commentCount = createMemo(() => comments().length)
|
||||
return (
|
||||
@@ -1001,6 +988,7 @@ export function MessageTimeline(props: {
|
||||
<SessionTurn
|
||||
sessionID={sessionID() ?? ""}
|
||||
messageID={messageID}
|
||||
messages={sessionMessages()}
|
||||
actions={props.actions}
|
||||
active={active()}
|
||||
status={active() ? sessionStatus() : undefined}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
@@ -19,7 +20,8 @@ import { useCommand } from "@/context/command"
|
||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
||||
@@ -27,6 +29,12 @@ import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: () => boolean
|
||||
diffs: () => FileDiff[]
|
||||
diffsReady: () => boolean
|
||||
empty: () => string
|
||||
hasReview: () => boolean
|
||||
reviewCount: () => number
|
||||
reviewPanel: () => JSX.Element
|
||||
activeDiff?: string
|
||||
focusReviewDiff: (path: string) => void
|
||||
@@ -34,17 +42,19 @@ export function SessionSidePanel(props: {
|
||||
size: Sizing
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const sync = useSync()
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||
const { sessionKey, tabs, view } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const shown = createMemo(() => platform.platform !== "desktop" || settings.general.showFileTree())
|
||||
|
||||
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
|
||||
const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())
|
||||
const open = createMemo(() => reviewOpen() || fileOpen())
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const panelWidth = createMemo(() => {
|
||||
@@ -54,24 +64,7 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasReview = createMemo(() => reviewCount() > 0)
|
||||
const diffsReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
if (!hasReview()) return true
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
|
||||
const reviewEmptyKey = createMemo(() => {
|
||||
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
|
||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||
return "session.review.noChanges"
|
||||
})
|
||||
|
||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||
const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
|
||||
const kinds = createMemo(() => {
|
||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||
if (!a) return b
|
||||
@@ -82,7 +75,7 @@ export function SessionSidePanel(props: {
|
||||
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
||||
|
||||
const out = new Map<string, "add" | "del" | "mix">()
|
||||
for (const diff of diffs()) {
|
||||
for (const diff of props.diffs()) {
|
||||
const file = normalize(diff.file)
|
||||
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||
|
||||
@@ -136,7 +129,7 @@ export function SessionSidePanel(props: {
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab,
|
||||
review: reviewTab,
|
||||
hasReview,
|
||||
hasReview: props.canReview,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openedTabs = tabState.openedTabs
|
||||
@@ -241,12 +234,12 @@ export function SessionSidePanel(props: {
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<Show when={reviewTab()}>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Trigger value="review">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>{language.t("session.tab.review")}</div>
|
||||
<Show when={hasReview()}>
|
||||
<div>{reviewCount()}</div>
|
||||
<Show when={props.hasReview()}>
|
||||
<div>{props.reviewCount()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
@@ -303,7 +296,7 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Show when={reviewTab()}>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||
</Tabs.Content>
|
||||
@@ -352,102 +345,100 @@ export function SessionSidePanel(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="file-tree-panel"
|
||||
aria-hidden={!fileOpen()}
|
||||
inert={!fileOpen()}
|
||||
class="relative min-w-0 h-full shrink-0 overflow-hidden"
|
||||
classList={{
|
||||
"pointer-events-none": !fileOpen(),
|
||||
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active(),
|
||||
}}
|
||||
style={{ width: treeWidth() }}
|
||||
>
|
||||
<Show when={shown()}>
|
||||
<div
|
||||
class="h-full flex flex-col overflow-hidden group/filetree"
|
||||
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
|
||||
id="file-tree-panel"
|
||||
aria-hidden={!fileOpen()}
|
||||
inert={!fileOpen()}
|
||||
class="relative min-w-0 h-full shrink-0 overflow-hidden"
|
||||
classList={{
|
||||
"pointer-events-none": !fileOpen(),
|
||||
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active(),
|
||||
}}
|
||||
style={{ width: treeWidth() }}
|
||||
>
|
||||
<Tabs
|
||||
variant="pill"
|
||||
value={fileTreeTab()}
|
||||
onChange={setFileTreeTabValue}
|
||||
class="h-full"
|
||||
data-scope="filetree"
|
||||
<div
|
||||
class="h-full flex flex-col overflow-hidden group/filetree"
|
||||
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{reviewCount()}{" "}
|
||||
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{language.t("session.files.all")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
variant="pill"
|
||||
value={fileTreeTab()}
|
||||
onChange={setFileTreeTabValue}
|
||||
class="h-full"
|
||||
data-scope="filetree"
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{props.reviewCount()}{" "}
|
||||
{language.t(
|
||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||
)}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{language.t("session.files.all")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={props.hasReview() || !props.diffsReady()}>
|
||||
<Show
|
||||
when={props.diffsReady()}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FileTree
|
||||
path=""
|
||||
class="pt-3"
|
||||
allowed={diffFiles()}
|
||||
kinds={kinds()}
|
||||
draggable={false}
|
||||
active={props.activeDiff}
|
||||
onFileClick={(node) => props.focusReviewDiff(node.path)}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>{empty(props.empty())}</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
|
||||
<Match when={true}>
|
||||
<FileTree
|
||||
path=""
|
||||
class="pt-3"
|
||||
allowed={diffFiles()}
|
||||
modified={diffFiles()}
|
||||
kinds={kinds()}
|
||||
draggable={false}
|
||||
active={props.activeDiff}
|
||||
onFileClick={(node) => props.focusReviewDiff(node.path)}
|
||||
onFileClick={(node) => openTab(file.tab(node.path))}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
{empty(
|
||||
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
|
||||
<Match when={true}>
|
||||
<FileTree
|
||||
path=""
|
||||
class="pt-3"
|
||||
modified={diffFiles()}
|
||||
kinds={kinds()}
|
||||
onFileClick={(node) => openTab(file.tab(node.path))}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
<Show when={fileOpen()}>
|
||||
<div onPointerDown={() => props.size.start()}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
edge="start"
|
||||
size={layout.fileTree.width()}
|
||||
min={200}
|
||||
max={480}
|
||||
collapseThreshold={160}
|
||||
onResize={(width) => {
|
||||
props.size.touch()
|
||||
layout.fileTree.resize(width)
|
||||
}}
|
||||
onCollapse={layout.fileTree.close}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={fileOpen()}>
|
||||
<div onPointerDown={() => props.size.start()}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
edge="start"
|
||||
size={layout.fileTree.width()}
|
||||
min={200}
|
||||
max={480}
|
||||
onResize={(width) => {
|
||||
props.size.touch()
|
||||
layout.fileTree.resize(width)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</aside>
|
||||
</Show>
|
||||
|
||||
@@ -7,8 +7,10 @@ import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||
@@ -43,8 +45,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const language = useLanguage()
|
||||
const local = useLocal()
|
||||
const permission = usePermission()
|
||||
const platform = usePlatform()
|
||||
const prompt = usePrompt()
|
||||
const sdk = useSDK()
|
||||
const settings = useSettings()
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const layout = useLayout()
|
||||
@@ -56,11 +60,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
if (!id) return
|
||||
return sync.session.get(id)
|
||||
}
|
||||
const hasReview = () => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
|
||||
}
|
||||
const hasReview = () => !!params.id
|
||||
const normalizeTab = (tab: string) => {
|
||||
if (!tab.startsWith("file://")) return tab
|
||||
return file.tab(tab)
|
||||
@@ -74,6 +74,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
})
|
||||
const activeFileTab = tabState.activeFileTab
|
||||
const closableTab = tabState.closableTab
|
||||
const shown = () => platform.platform !== "desktop" || settings.general.showFileTree()
|
||||
|
||||
const idle = { type: "idle" as const }
|
||||
const status = () => sync.data.session_status[params.id ?? ""] ?? idle
|
||||
@@ -255,7 +256,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
id: "file.open",
|
||||
title: language.t("command.file.open"),
|
||||
description: language.t("palette.search.placeholder"),
|
||||
keybind: "mod+p",
|
||||
keybind: "mod+k,mod+p",
|
||||
slash: "open",
|
||||
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
|
||||
}),
|
||||
@@ -307,12 +308,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
keybind: "mod+shift+r",
|
||||
onSelect: () => view().reviewPanel.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "fileTree.toggle",
|
||||
title: language.t("command.fileTree.toggle"),
|
||||
keybind: "mod+\\",
|
||||
onSelect: () => layout.fileTree.toggle(),
|
||||
}),
|
||||
...(shown()
|
||||
? [
|
||||
viewCommand({
|
||||
id: "fileTree.toggle",
|
||||
title: language.t("command.fileTree.toggle"),
|
||||
keybind: "mod+\\",
|
||||
onSelect: () => layout.fileTree.toggle(),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
viewCommand({
|
||||
id: "input.focus",
|
||||
title: language.t("command.input.focus"),
|
||||
@@ -333,7 +338,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
id: "message.previous",
|
||||
title: language.t("command.message.previous"),
|
||||
description: language.t("command.message.previous.description"),
|
||||
keybind: "mod+arrowup",
|
||||
keybind: "mod+alt+[",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(-1),
|
||||
}),
|
||||
@@ -341,7 +346,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
id: "message.next",
|
||||
title: language.t("command.message.next"),
|
||||
description: language.t("command.message.next.description"),
|
||||
keybind: "mod+arrowdown",
|
||||
keybind: "mod+alt+]",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(1),
|
||||
}),
|
||||
|
||||
@@ -8,6 +8,9 @@ export const useSessionHashScroll = (input: {
|
||||
sessionID: () => string | undefined
|
||||
messagesReady: () => boolean
|
||||
visibleUserMessages: () => UserMessage[]
|
||||
historyMore: () => boolean
|
||||
historyLoading: () => boolean
|
||||
loadMore: (sessionID: string) => Promise<void>
|
||||
turnStart: () => number
|
||||
currentMessageId: () => string | undefined
|
||||
pendingMessage: () => string | undefined
|
||||
@@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: {
|
||||
queue(() => scrollToMessage(msg, "auto"))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = input.sessionID()
|
||||
if (!sessionID || !input.messagesReady()) return
|
||||
|
||||
visibleUserMessages()
|
||||
|
||||
let targetId = input.pendingMessage()
|
||||
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
|
||||
if (!targetId) return
|
||||
if (messageById().has(targetId)) return
|
||||
if (!input.historyMore() || input.historyLoading()) return
|
||||
|
||||
void input.loadMore(sessionID)
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
|
||||
window.history.scrollRestoration = "manual"
|
||||
|
||||
44
packages/app/src/utils/prompt.test.ts
Normal file
44
packages/app/src/utils/prompt.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Part } from "@opencode-ai/sdk/v2"
|
||||
import { extractPromptFromParts } from "./prompt"
|
||||
|
||||
describe("extractPromptFromParts", () => {
|
||||
test("restores multiple uploaded attachments", () => {
|
||||
const parts = [
|
||||
{
|
||||
id: "text_1",
|
||||
type: "text",
|
||||
text: "check these",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
},
|
||||
{
|
||||
id: "file_1",
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
url: "data:image/png;base64,AAA",
|
||||
filename: "a.png",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
},
|
||||
{
|
||||
id: "file_2",
|
||||
type: "file",
|
||||
mime: "application/pdf",
|
||||
url: "data:application/pdf;base64,BBB",
|
||||
filename: "b.pdf",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
},
|
||||
] satisfies Part[]
|
||||
|
||||
const result = extractPromptFromParts(parts)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0]).toMatchObject({ type: "text", content: "check these" })
|
||||
expect(result.slice(1)).toMatchObject([
|
||||
{ type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
{ type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,15 @@ interface CheckServerHealthOptions {
|
||||
const defaultTimeoutMs = 3000
|
||||
const defaultRetryCount = 2
|
||||
const defaultRetryDelayMs = 100
|
||||
const cacheMs = 750
|
||||
const healthCache = new Map<
|
||||
string,
|
||||
{ at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise<ServerHealth> }
|
||||
>()
|
||||
|
||||
function cacheKey(server: ServerConnection.HttpBase) {
|
||||
return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}`
|
||||
}
|
||||
|
||||
function timeoutSignal(timeoutMs: number) {
|
||||
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
|
||||
@@ -87,5 +96,18 @@ export function useCheckServerHealth() {
|
||||
const platform = usePlatform()
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
|
||||
return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher)
|
||||
return (http: ServerConnection.HttpBase) => {
|
||||
const key = cacheKey(http)
|
||||
const hit = healthCache.get(key)
|
||||
const now = Date.now()
|
||||
if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise
|
||||
const promise = checkServerHealth(http, fetcher).finally(() => {
|
||||
const next = healthCache.get(key)
|
||||
if (!next || next.promise !== promise) return
|
||||
next.done = true
|
||||
next.at = Date.now()
|
||||
})
|
||||
healthCache.set(key, { at: now, done: false, fetch: fetcher, promise })
|
||||
return promise
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +1,89 @@
|
||||
import alert01 from "@opencode-ai/ui/audio/alert-01.aac"
|
||||
import alert02 from "@opencode-ai/ui/audio/alert-02.aac"
|
||||
import alert03 from "@opencode-ai/ui/audio/alert-03.aac"
|
||||
import alert04 from "@opencode-ai/ui/audio/alert-04.aac"
|
||||
import alert05 from "@opencode-ai/ui/audio/alert-05.aac"
|
||||
import alert06 from "@opencode-ai/ui/audio/alert-06.aac"
|
||||
import alert07 from "@opencode-ai/ui/audio/alert-07.aac"
|
||||
import alert08 from "@opencode-ai/ui/audio/alert-08.aac"
|
||||
import alert09 from "@opencode-ai/ui/audio/alert-09.aac"
|
||||
import alert10 from "@opencode-ai/ui/audio/alert-10.aac"
|
||||
import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac"
|
||||
import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac"
|
||||
import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac"
|
||||
import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac"
|
||||
import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac"
|
||||
import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac"
|
||||
import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac"
|
||||
import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac"
|
||||
import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac"
|
||||
import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac"
|
||||
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 nope06 from "@opencode-ai/ui/audio/nope-06.aac"
|
||||
import nope07 from "@opencode-ai/ui/audio/nope-07.aac"
|
||||
import nope08 from "@opencode-ai/ui/audio/nope-08.aac"
|
||||
import nope09 from "@opencode-ai/ui/audio/nope-09.aac"
|
||||
import nope10 from "@opencode-ai/ui/audio/nope-10.aac"
|
||||
import nope11 from "@opencode-ai/ui/audio/nope-11.aac"
|
||||
import nope12 from "@opencode-ai/ui/audio/nope-12.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"
|
||||
import yup01 from "@opencode-ai/ui/audio/yup-01.aac"
|
||||
import yup02 from "@opencode-ai/ui/audio/yup-02.aac"
|
||||
import yup03 from "@opencode-ai/ui/audio/yup-03.aac"
|
||||
import yup04 from "@opencode-ai/ui/audio/yup-04.aac"
|
||||
import yup05 from "@opencode-ai/ui/audio/yup-05.aac"
|
||||
import yup06 from "@opencode-ai/ui/audio/yup-06.aac"
|
||||
let files: Record<string, () => Promise<string>> | undefined
|
||||
let loads: Record<SoundID, () => Promise<string>> | undefined
|
||||
|
||||
function getFiles() {
|
||||
if (files) return files
|
||||
files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record<
|
||||
string,
|
||||
() => Promise<string>
|
||||
>
|
||||
return files
|
||||
}
|
||||
|
||||
export const SOUND_OPTIONS = [
|
||||
{ id: "alert-01", label: "sound.option.alert01", src: alert01 },
|
||||
{ id: "alert-02", label: "sound.option.alert02", src: alert02 },
|
||||
{ id: "alert-03", label: "sound.option.alert03", src: alert03 },
|
||||
{ id: "alert-04", label: "sound.option.alert04", src: alert04 },
|
||||
{ id: "alert-05", label: "sound.option.alert05", src: alert05 },
|
||||
{ id: "alert-06", label: "sound.option.alert06", src: alert06 },
|
||||
{ id: "alert-07", label: "sound.option.alert07", src: alert07 },
|
||||
{ id: "alert-08", label: "sound.option.alert08", src: alert08 },
|
||||
{ id: "alert-09", label: "sound.option.alert09", src: alert09 },
|
||||
{ id: "alert-10", label: "sound.option.alert10", src: alert10 },
|
||||
{ id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 },
|
||||
{ id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 },
|
||||
{ id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 },
|
||||
{ id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 },
|
||||
{ id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 },
|
||||
{ id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 },
|
||||
{ id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 },
|
||||
{ id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 },
|
||||
{ id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 },
|
||||
{ id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 },
|
||||
{ id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 },
|
||||
{ id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 },
|
||||
{ id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 },
|
||||
{ id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 },
|
||||
{ id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 },
|
||||
{ id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 },
|
||||
{ id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 },
|
||||
{ id: "nope-01", label: "sound.option.nope01", src: nope01 },
|
||||
{ id: "nope-02", label: "sound.option.nope02", src: nope02 },
|
||||
{ id: "nope-03", label: "sound.option.nope03", src: nope03 },
|
||||
{ id: "nope-04", label: "sound.option.nope04", src: nope04 },
|
||||
{ id: "nope-05", label: "sound.option.nope05", src: nope05 },
|
||||
{ id: "nope-06", label: "sound.option.nope06", src: nope06 },
|
||||
{ id: "nope-07", label: "sound.option.nope07", src: nope07 },
|
||||
{ id: "nope-08", label: "sound.option.nope08", src: nope08 },
|
||||
{ id: "nope-09", label: "sound.option.nope09", src: nope09 },
|
||||
{ id: "nope-10", label: "sound.option.nope10", src: nope10 },
|
||||
{ id: "nope-11", label: "sound.option.nope11", src: nope11 },
|
||||
{ id: "nope-12", label: "sound.option.nope12", src: nope12 },
|
||||
{ id: "yup-01", label: "sound.option.yup01", src: yup01 },
|
||||
{ id: "yup-02", label: "sound.option.yup02", src: yup02 },
|
||||
{ id: "yup-03", label: "sound.option.yup03", src: yup03 },
|
||||
{ id: "yup-04", label: "sound.option.yup04", src: yup04 },
|
||||
{ id: "yup-05", label: "sound.option.yup05", src: yup05 },
|
||||
{ id: "yup-06", label: "sound.option.yup06", src: yup06 },
|
||||
{ id: "alert-01", label: "sound.option.alert01" },
|
||||
{ id: "alert-02", label: "sound.option.alert02" },
|
||||
{ id: "alert-03", label: "sound.option.alert03" },
|
||||
{ id: "alert-04", label: "sound.option.alert04" },
|
||||
{ id: "alert-05", label: "sound.option.alert05" },
|
||||
{ id: "alert-06", label: "sound.option.alert06" },
|
||||
{ id: "alert-07", label: "sound.option.alert07" },
|
||||
{ id: "alert-08", label: "sound.option.alert08" },
|
||||
{ id: "alert-09", label: "sound.option.alert09" },
|
||||
{ id: "alert-10", label: "sound.option.alert10" },
|
||||
{ id: "bip-bop-01", label: "sound.option.bipbop01" },
|
||||
{ id: "bip-bop-02", label: "sound.option.bipbop02" },
|
||||
{ id: "bip-bop-03", label: "sound.option.bipbop03" },
|
||||
{ id: "bip-bop-04", label: "sound.option.bipbop04" },
|
||||
{ id: "bip-bop-05", label: "sound.option.bipbop05" },
|
||||
{ id: "bip-bop-06", label: "sound.option.bipbop06" },
|
||||
{ id: "bip-bop-07", label: "sound.option.bipbop07" },
|
||||
{ id: "bip-bop-08", label: "sound.option.bipbop08" },
|
||||
{ id: "bip-bop-09", label: "sound.option.bipbop09" },
|
||||
{ id: "bip-bop-10", label: "sound.option.bipbop10" },
|
||||
{ id: "staplebops-01", label: "sound.option.staplebops01" },
|
||||
{ id: "staplebops-02", label: "sound.option.staplebops02" },
|
||||
{ id: "staplebops-03", label: "sound.option.staplebops03" },
|
||||
{ id: "staplebops-04", label: "sound.option.staplebops04" },
|
||||
{ id: "staplebops-05", label: "sound.option.staplebops05" },
|
||||
{ id: "staplebops-06", label: "sound.option.staplebops06" },
|
||||
{ id: "staplebops-07", label: "sound.option.staplebops07" },
|
||||
{ id: "nope-01", label: "sound.option.nope01" },
|
||||
{ id: "nope-02", label: "sound.option.nope02" },
|
||||
{ id: "nope-03", label: "sound.option.nope03" },
|
||||
{ id: "nope-04", label: "sound.option.nope04" },
|
||||
{ id: "nope-05", label: "sound.option.nope05" },
|
||||
{ id: "nope-06", label: "sound.option.nope06" },
|
||||
{ id: "nope-07", label: "sound.option.nope07" },
|
||||
{ id: "nope-08", label: "sound.option.nope08" },
|
||||
{ id: "nope-09", label: "sound.option.nope09" },
|
||||
{ id: "nope-10", label: "sound.option.nope10" },
|
||||
{ id: "nope-11", label: "sound.option.nope11" },
|
||||
{ id: "nope-12", label: "sound.option.nope12" },
|
||||
{ id: "yup-01", label: "sound.option.yup01" },
|
||||
{ id: "yup-02", label: "sound.option.yup02" },
|
||||
{ id: "yup-03", label: "sound.option.yup03" },
|
||||
{ id: "yup-04", label: "sound.option.yup04" },
|
||||
{ id: "yup-05", label: "sound.option.yup05" },
|
||||
{ id: "yup-06", label: "sound.option.yup06" },
|
||||
] 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>
|
||||
function getLoads() {
|
||||
if (loads) return loads
|
||||
loads = Object.fromEntries(
|
||||
Object.entries(getFiles()).flatMap(([path, load]) => {
|
||||
const file = path.split("/").at(-1)
|
||||
if (!file) return []
|
||||
return [[file.replace(/\.aac$/, ""), load] as const]
|
||||
}),
|
||||
) as Record<SoundID, () => Promise<string>>
|
||||
return loads
|
||||
}
|
||||
|
||||
const cache = new Map<SoundID, Promise<string | undefined>>()
|
||||
|
||||
export function soundSrc(id: string | undefined) {
|
||||
if (!id) return
|
||||
if (!(id in soundById)) return
|
||||
return soundById[id as SoundID]
|
||||
const loads = getLoads()
|
||||
if (!id || !(id in loads)) return Promise.resolve(undefined)
|
||||
const key = id as SoundID
|
||||
const hit = cache.get(key)
|
||||
if (hit) return hit
|
||||
const next = loads[key]().catch(() => undefined)
|
||||
cache.set(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export function playSound(src: string | undefined) {
|
||||
@@ -108,10 +91,12 @@ export function playSound(src: string | undefined) {
|
||||
if (!src) return
|
||||
const audio = new Audio(src)
|
||||
audio.play().catch(() => undefined)
|
||||
|
||||
// Return a cleanup function to pause the sound.
|
||||
return () => {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
}
|
||||
}
|
||||
|
||||
export function playSoundById(id: string | undefined) {
|
||||
return soundSrc(id).then((src) => playSound(src))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import solidPlugin from "vite-plugin-solid"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
|
||||
|
||||
/**
|
||||
* @type {import("vite").PluginOption}
|
||||
*/
|
||||
@@ -21,6 +24,15 @@ export default [
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "opencode-desktop:theme-preload",
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(
|
||||
'<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>',
|
||||
`<script id="oc-theme-preload-script">${readFileSync(theme, "utf8")}</script>`,
|
||||
)
|
||||
},
|
||||
},
|
||||
tailwindcss(),
|
||||
solidPlugin(),
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.2",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -2,6 +2,6 @@ import { redirect } from "@solidjs/router"
|
||||
|
||||
export async function GET() {
|
||||
return redirect(
|
||||
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true",
|
||||
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,13 @@ import {
|
||||
FreeUsageLimitError,
|
||||
SubscriptionUsageLimitError,
|
||||
} from "./error"
|
||||
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
|
||||
import {
|
||||
buildCostChunk,
|
||||
createBodyConverter,
|
||||
createStreamPartConverter,
|
||||
createResponseConverter,
|
||||
UsageInfo,
|
||||
} from "./provider/provider"
|
||||
import { anthropicHelper } from "./provider/anthropic"
|
||||
import { googleHelper } from "./provider/google"
|
||||
import { openaiHelper } from "./provider/openai"
|
||||
@@ -90,7 +96,7 @@ export async function handler(
|
||||
const projectId = input.request.headers.get("x-opencode-project") ?? ""
|
||||
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
|
||||
logger.metric({
|
||||
is_tream: isStream,
|
||||
is_stream: isStream,
|
||||
session: sessionId,
|
||||
request: requestId,
|
||||
client: ocClient,
|
||||
@@ -126,7 +132,7 @@ export async function handler(
|
||||
retry,
|
||||
stickyProvider,
|
||||
)
|
||||
validateModelSettings(authInfo)
|
||||
validateModelSettings(billingSource, authInfo)
|
||||
updateProviderKey(authInfo, providerInfo)
|
||||
logger.metric({ provider: providerInfo.id })
|
||||
|
||||
@@ -230,7 +236,7 @@ export async function handler(
|
||||
const body = JSON.stringify(
|
||||
responseConverter({
|
||||
...json,
|
||||
cost: calculateOccuredCost(billingSource, costInfo),
|
||||
cost: calculateOccurredCost(billingSource, costInfo),
|
||||
}),
|
||||
)
|
||||
logger.metric({ response_length: body.length })
|
||||
@@ -274,8 +280,8 @@ export async function handler(
|
||||
await trialLimiter?.track(usageInfo)
|
||||
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
||||
await reload(billingSource, authInfo, costInfo)
|
||||
const cost = calculateOccuredCost(billingSource, costInfo)
|
||||
c.enqueue(encoder.encode(usageParser.buidlCostChunk(cost)))
|
||||
const cost = calculateOccurredCost(billingSource, costInfo)
|
||||
c.enqueue(encoder.encode(buildCostChunk(opts.format, cost)))
|
||||
}
|
||||
c.close()
|
||||
return
|
||||
@@ -334,6 +340,13 @@ export async function handler(
|
||||
"error.message": error.message,
|
||||
"error.cause": error.cause?.toString(),
|
||||
})
|
||||
if (error.message.startsWith("Failed query")) {
|
||||
try {
|
||||
logger.metric({
|
||||
"error.cause2": JSON.stringify(error.cause),
|
||||
})
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
|
||||
if (
|
||||
@@ -455,12 +468,17 @@ export async function handler(
|
||||
...modelProvider,
|
||||
...zenData.providers[modelProvider.id],
|
||||
...(() => {
|
||||
const format = zenData.providers[modelProvider.id].format
|
||||
const providerProps = zenData.providers[modelProvider.id]
|
||||
const format = providerProps.format
|
||||
const providerModel = modelProvider.model
|
||||
if (format === "anthropic") return anthropicHelper({ reqModel, providerModel })
|
||||
if (format === "google") return googleHelper({ reqModel, providerModel })
|
||||
if (format === "openai") return openaiHelper({ reqModel, providerModel })
|
||||
return oaCompatHelper({ reqModel, providerModel })
|
||||
return oaCompatHelper({
|
||||
reqModel,
|
||||
providerModel,
|
||||
adjustCacheUsage: providerProps.adjustCacheUsage,
|
||||
})
|
||||
})(),
|
||||
}
|
||||
}
|
||||
@@ -750,9 +768,10 @@ export async function handler(
|
||||
return "balance"
|
||||
}
|
||||
|
||||
function validateModelSettings(authInfo: AuthInfo) {
|
||||
if (!authInfo) return
|
||||
if (authInfo.isDisabled) throw new ModelError(t("zen.api.error.modelDisabled"))
|
||||
function validateModelSettings(billingSource: BillingSource, authInfo: AuthInfo) {
|
||||
if (billingSource === "lite") return
|
||||
if (billingSource === "anonymous") return
|
||||
if (authInfo!.isDisabled) throw new ModelError(t("zen.api.error.modelDisabled"))
|
||||
}
|
||||
|
||||
function updateProviderKey(authInfo: AuthInfo, providerInfo: ProviderInfo) {
|
||||
@@ -818,7 +837,7 @@ export async function handler(
|
||||
}
|
||||
}
|
||||
|
||||
function calculateOccuredCost(billingSource: BillingSource, costInfo: CostInfo) {
|
||||
function calculateOccurredCost(billingSource: BillingSource, costInfo: CostInfo) {
|
||||
return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0"
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
||||
const isBedrockModelArn = providerModel.startsWith("arn:aws:bedrock:")
|
||||
const isBedrockModelID = providerModel.startsWith("global.anthropic.")
|
||||
const isBedrock = isBedrockModelArn || isBedrockModelID
|
||||
const isDatabricks = providerModel.startsWith("databricks-claude-")
|
||||
const supports1m = reqModel.includes("sonnet") || reqModel.includes("opus-4-6")
|
||||
return {
|
||||
format: "anthropic",
|
||||
@@ -28,7 +29,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
||||
? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}`
|
||||
: providerApi + "/messages",
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
||||
if (isBedrock) {
|
||||
if (isBedrock || isDatabricks) {
|
||||
headers.set("Authorization", `Bearer ${apiKey}`)
|
||||
} else {
|
||||
headers.set("x-api-key", apiKey)
|
||||
@@ -47,9 +48,14 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
||||
model: undefined,
|
||||
stream: undefined,
|
||||
}
|
||||
: {
|
||||
service_tier: "standard_only",
|
||||
}),
|
||||
: isDatabricks
|
||||
? {
|
||||
anthropic_version: "bedrock-2023-05-31",
|
||||
anthropic_beta: supports1m ? ["context-1m-2025-08-07"] : undefined,
|
||||
}
|
||||
: {
|
||||
service_tier: "standard_only",
|
||||
}),
|
||||
}),
|
||||
createBinaryStreamDecoder: () => {
|
||||
if (!isBedrock) return undefined
|
||||
@@ -167,7 +173,6 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
||||
}
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => ({
|
||||
|
||||
@@ -56,7 +56,6 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
|
||||
usage = json.usageMetadata
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
|
||||
@@ -21,7 +21,7 @@ type Usage = {
|
||||
}
|
||||
}
|
||||
|
||||
export const oaCompatHelper: ProviderHelper = () => ({
|
||||
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
|
||||
format: "oa-compat",
|
||||
modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
||||
@@ -54,14 +54,18 @@ export const oaCompatHelper: ProviderHelper = () => ({
|
||||
usage = json.usage
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ choices: [], cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
const inputTokens = usage.prompt_tokens ?? 0
|
||||
let inputTokens = usage.prompt_tokens ?? 0
|
||||
const outputTokens = usage.completion_tokens ?? 0
|
||||
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
||||
const cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
|
||||
let cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
|
||||
|
||||
if (adjustCacheUsage && !cacheReadTokens) {
|
||||
cacheReadTokens = Math.floor(inputTokens * 0.9)
|
||||
}
|
||||
|
||||
return {
|
||||
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
||||
outputTokens,
|
||||
|
||||
@@ -44,7 +44,6 @@ export const openaiHelper: ProviderHelper = () => ({
|
||||
usage = json.response.usage
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
|
||||
@@ -33,7 +33,7 @@ export type UsageInfo = {
|
||||
cacheWrite1hTokens?: number
|
||||
}
|
||||
|
||||
export type ProviderHelper = (input: { reqModel: string; providerModel: string }) => {
|
||||
export type ProviderHelper = (input: { reqModel: string; providerModel: string; adjustCacheUsage?: boolean }) => {
|
||||
format: ZenData.Format
|
||||
modifyUrl: (providerApi: string, isStream?: boolean) => string
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
|
||||
@@ -43,7 +43,6 @@ export type ProviderHelper = (input: { reqModel: string; providerModel: string }
|
||||
createUsageParser: () => {
|
||||
parse: (chunk: string) => void
|
||||
retrieve: () => any
|
||||
buidlCostChunk: (cost: string) => string
|
||||
}
|
||||
normalizeUsage: (usage: any) => UsageInfo
|
||||
}
|
||||
@@ -162,6 +161,19 @@ export interface CommonChunk {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCostChunk(format: ZenData.Format, cost: string): string {
|
||||
switch (format) {
|
||||
case "anthropic":
|
||||
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
||||
case "openai":
|
||||
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
||||
case "oa-compat":
|
||||
return `data: ${JSON.stringify({ choices: [], cost })}\n\n`
|
||||
default:
|
||||
return `data: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
||||
}
|
||||
}
|
||||
|
||||
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
|
||||
return (body: any): any => {
|
||||
if (from === to) return body
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -42,7 +42,7 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.0",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"mysql2": "3.14.4",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user