mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-23 15:14:40 +00:00
Compare commits
343 Commits
node-pty
...
opencode/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32ba9287b6 | ||
|
|
3c8b069bba | ||
|
|
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 | ||
|
|
129fe1e350 | ||
|
|
214a6c6cf1 | ||
|
|
3f249aba6d | ||
|
|
5c6ec1caac | ||
|
|
24f9df5463 | ||
|
|
12b8e1c2be | ||
|
|
d70099b059 | ||
|
|
ce845a0b1b | ||
|
|
05d3e65f76 | ||
|
|
51618e9cef | ||
|
|
e78944e9a4 | ||
|
|
bfdc38e421 | ||
|
|
83023e4f0f | ||
|
|
d0a57305ef | ||
|
|
27a70ad70f | ||
|
|
0bbf26a1ce | ||
|
|
83cdb4de64 | ||
|
|
4989632245 | ||
|
|
d460614cd7 | ||
|
|
7866dbcfcc | ||
|
|
e71a21e0a8 | ||
|
|
1071aca91f | ||
|
|
b3d0446d13 | ||
|
|
949191ab74 | ||
|
|
92cd908fb5 | ||
|
|
6fcc970def | ||
|
|
52a7a04ad8 | ||
|
|
37b8662a9d | ||
|
|
ddcb32ae0b | ||
|
|
2c056c90da | ||
|
|
812d1bb32a | ||
|
|
9a58c43ef4 | ||
|
|
63585db6a7 | ||
|
|
bd44489ada | ||
|
|
a6ef9e9206 | ||
|
|
6e09a1d904 | ||
|
|
4f21757e0d | ||
|
|
2dbcd79fd2 | ||
|
|
48a7f0fd93 | ||
|
|
d69962b0f7 | ||
|
|
a6f23cb08e | ||
|
|
0540751897 | ||
|
|
baa204193c | ||
|
|
aeece6166b | ||
|
|
0d7e62a532 | ||
|
|
41aa254db4 | ||
|
|
d178d8249f | ||
|
|
e6f5214779 | ||
|
|
84f60d97a0 | ||
|
|
cbf4b68fee | ||
|
|
bd4527b4f2 | ||
|
|
f4a9fe29a3 | ||
|
|
5a0bfa7061 | ||
|
|
1ac1a0287c | ||
|
|
8e09e8c612 | ||
|
|
84e62fc662 | ||
|
|
a7ea93528b | ||
|
|
d90e3a2833 | ||
|
|
1c74c2741a | ||
|
|
5d2f8d77f9 | ||
|
|
81be544981 | ||
|
|
773c1192dc | ||
|
|
5ddfe4ada5 | ||
|
|
a93d98bd94 | ||
|
|
54ed87d53c | ||
|
|
8ee939c741 | ||
|
|
1b0096bf61 | ||
|
|
8006c29db3 | ||
|
|
3f1c96a0bb | ||
|
|
3558deba4a | ||
|
|
c3ddc85cca | ||
|
|
a800583aea | ||
|
|
171e69c2fc | ||
|
|
822bb7b336 | ||
|
|
47cf267c23 | ||
|
|
976aae7e42 | ||
|
|
0ca51eebcf | ||
|
|
3256886e25 | ||
|
|
d2194f6dde | ||
|
|
bfd4787fcd | ||
|
|
58dce0148a | ||
|
|
79635b8b41 | ||
|
|
331dacf9db | ||
|
|
4ba7d3b406 | ||
|
|
a43783a6d4 | ||
|
|
37c5295111 | ||
|
|
56102ff642 | ||
|
|
1b86c27fb8 | ||
|
|
fe43bdb699 | ||
|
|
a849a17e93 | ||
|
|
0292f1b559 | ||
|
|
5dfe86dcb1 | ||
|
|
4b4dd2b882 | ||
|
|
bc949af623 | ||
|
|
9e7c136de7 | ||
|
|
fee3c196c5 | ||
|
|
6c047391bb | ||
|
|
350df0b261 | ||
|
|
fbabc97c4c | ||
|
|
7daea69e13 | ||
|
|
0772a95918 | ||
|
|
dadddc9c8c | ||
|
|
6708c3f6cf | ||
|
|
ba22976568 | ||
|
|
0afeaea21f | ||
|
|
b07b5a9b7f | ||
|
|
dbbe931a18 | ||
|
|
e14e874e51 | ||
|
|
544315dff7 | ||
|
|
f13da808ff | ||
|
|
e416e59ea6 | ||
|
|
cb69501098 | ||
|
|
a64f604d54 | ||
|
|
d7093abf61 | ||
|
|
60af447908 | ||
|
|
1cdc558ac0 | ||
|
|
3849822769 | ||
|
|
e9a17e4480 | ||
|
|
68809365df | ||
|
|
8da511dfa8 | ||
|
|
69381f6aea | ||
|
|
df6508530f | ||
|
|
335356280c | ||
|
|
03d84f49c2 | ||
|
|
2cbdf04ec9 | ||
|
|
410fbd8a00 | ||
|
|
e5cbecf17c | ||
|
|
ca3af5dc6a | ||
|
|
9e740d9947 | ||
|
|
d4694d058c | ||
|
|
469c3a4204 | ||
|
|
4cb29967f6 | ||
|
|
e718db624f | ||
|
|
15b27e0d18 | ||
|
|
c523aac586 | ||
|
|
51fcd04a70 | ||
|
|
4d7cbdcbef | ||
|
|
59c530cc6c | ||
|
|
c2ca1494e5 | ||
|
|
4ee426ba54 | ||
|
|
510374207d | ||
|
|
aedbecedf7 | ||
|
|
9c00669927 | ||
|
|
b9f6b40e3a | ||
|
|
ad06d8f496 | ||
|
|
2fc06c5a17 | ||
|
|
52877d8765 | ||
|
|
8f957b8f90 | ||
|
|
0befa1e57e | ||
|
|
f015154314 | ||
|
|
689d9e14ea | ||
|
|
66e8c57ed1 | ||
|
|
b698f14e55 | ||
|
|
cec1255b36 | ||
|
|
88226f3061 | ||
|
|
8c53b2b470 | ||
|
|
f2d3a4c70f | ||
|
|
4b9b86b544 | ||
|
|
f54abe58cf | ||
|
|
d954026dd8 | ||
|
|
4ad8116ce3 | ||
|
|
5c7088338c | ||
|
|
389daa03df | ||
|
|
1cbe7b0854 | ||
|
|
050d71bcf9 | ||
|
|
ffde837e83 | ||
|
|
536abea2e2 | ||
|
|
c7a52b6a2d | ||
|
|
c4ccb50c37 | ||
|
|
5aaf1ddfb7 | ||
|
|
f5f07310e0 | ||
|
|
c9e9dbeee1 | ||
|
|
b88b323049 | ||
|
|
6653f868ae | ||
|
|
af29d91dca | ||
|
|
1a3735b619 | ||
|
|
d4ae13f2a0 | ||
|
|
f4804dac85 | ||
|
|
843f188aaa | ||
|
|
05cb3c87ca | ||
|
|
270cb0b8b4 | ||
|
|
46ba9c8170 | ||
|
|
80f91d3fd9 | ||
|
|
a564231caf | ||
|
|
9457493696 | ||
|
|
ff748b82ca | ||
|
|
9fafa57562 | ||
|
|
f8475649da | ||
|
|
b94e110a4c | ||
|
|
f0bba10b12 | ||
|
|
d961981e25 | ||
|
|
5576662200 | ||
|
|
4a2a046d79 | ||
|
|
8f8c74cfb8 | ||
|
|
092f654f63 | ||
|
|
96b1d8f639 | ||
|
|
dcb17c6a67 | ||
|
|
dd68b85f58 | ||
|
|
84df96eaef | ||
|
|
d9dd33aeeb | ||
|
|
0a281c7390 | ||
|
|
3016efba47 | ||
|
|
3998df8112 | ||
|
|
7066e2a25e | ||
|
|
c173988aaa | ||
|
|
268855dc5a | ||
|
|
bfb736e94a | ||
|
|
df8464f89c | ||
|
|
3ea387f364 | ||
|
|
9d3c42c8c4 | ||
|
|
f2cad046e6 | ||
|
|
d722026a8d | ||
|
|
42a5af6c8f | ||
|
|
f0542fae7a | ||
|
|
02c75821a8 | ||
|
|
3ba9ab2c0a | ||
|
|
184732fc20 | ||
|
|
b66222baf7 | ||
|
|
dce7eceb28 | ||
|
|
0e077f7483 | ||
|
|
776e7a9c15 | ||
|
|
c455d41876 | ||
|
|
a776a3ee12 | ||
|
|
64fb9233bf | ||
|
|
3533f33ecb | ||
|
|
1cb7df7159 | ||
|
|
a4f8d66a9b | ||
|
|
12efbbfa4c | ||
|
|
13402529ce | ||
|
|
fc678ef36c | ||
|
|
03cd891ea9 | ||
|
|
6314d741e7 | ||
|
|
c45467964c | ||
|
|
2eeba53b07 | ||
|
|
d4107d51f1 | ||
|
|
d8fbe0af01 | ||
|
|
b76ead3fe8 | ||
|
|
51835ecf90 | ||
|
|
328c6de80d | ||
|
|
c9c0318e0e | ||
|
|
d481f64bde | ||
|
|
54e7baa6cf | ||
|
|
1d7fcd40b4 | ||
|
|
db7bafe917 | ||
|
|
b1ef501207 | ||
|
|
9fb12a906e | ||
|
|
fafbc29316 | ||
|
|
7b0def4b81 | ||
|
|
1d9c83b576 | ||
|
|
2c825c3223 | ||
|
|
2a4dedc210 | ||
|
|
b0bca6342e | ||
|
|
547eb7676d | ||
|
|
83f083ee0d | ||
|
|
090f636354 | ||
|
|
d26c6f80e1 | ||
|
|
16a6d6feba | ||
|
|
f1c3a44190 | ||
|
|
34fa5de9c5 | ||
|
|
cb67465675 | ||
|
|
4e73473119 | ||
|
|
cc18fa599c | ||
|
|
aa81c1c4cb | ||
|
|
8569fc1f0e | ||
|
|
78de287bcc | ||
|
|
bbc7052c7a | ||
|
|
502d6db6d0 | ||
|
|
0b0ad5de99 | ||
|
|
9e6c4a01aa | ||
|
|
4a81df190c | ||
|
|
75cae81f75 | ||
|
|
ed3bb3ea8f | ||
|
|
fac23a1afc | ||
|
|
f89696509e | ||
|
|
604ab1bde1 | ||
|
|
fbd9b7cf4f | ||
|
|
58f45ae22b | ||
|
|
440405dbdd | ||
|
|
a1cda29012 | ||
|
|
f96e2d4222 | ||
|
|
387ab78bf6 | ||
|
|
dbc00aa8e0 | ||
|
|
c37f7b9d99 | ||
|
|
cf7ca9b2f7 | ||
|
|
981c7b9e37 | ||
|
|
2aae0d3493 | ||
|
|
bcc0d19867 | ||
|
|
9c585bb58b | ||
|
|
0f6bc8ae71 | ||
|
|
7291e28273 | ||
|
|
db57fe6193 | ||
|
|
802416639b | ||
|
|
7ec398d855 | ||
|
|
4ab35d2c5c | ||
|
|
b4ae030fc2 | ||
|
|
0843964eb3 | ||
|
|
a1b06d63c9 | ||
|
|
1b6820bab5 | ||
|
|
89bf199c07 | ||
|
|
5acfdd1c5d |
4
.github/VOUCHED.td
vendored
4
.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,6 +20,7 @@ iamdavidhill
|
||||
jayair
|
||||
kitlangton
|
||||
kommander
|
||||
-opencode2026
|
||||
r44vc0rp
|
||||
rekram1-node
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
|
||||
31
.github/actions/setup-bun/action.yml
vendored
31
.github/actions/setup-bun/action.yml
vendored
@@ -3,14 +3,6 @@ description: "Setup Bun with caching and install dependencies"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Get baseline download URL
|
||||
id: bun-url
|
||||
shell: bash
|
||||
@@ -31,10 +23,31 @@ runs:
|
||||
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
|
||||
bun-download-url: ${{ steps.bun-url.outputs.url }}
|
||||
|
||||
- name: Get cache directory
|
||||
id: cache
|
||||
shell: bash
|
||||
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install setuptools for distutils compatibility
|
||||
run: python3 -m pip install setuptools || pip install setuptools || true
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
run: |
|
||||
# Workaround for patched peer variants
|
||||
# e.g. ./patches/ for standard-openapi
|
||||
# https://github.com/oven-sh/bun/issues/28147
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
bun install --linker hoisted
|
||||
else
|
||||
bun install
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
11
.github/workflows/publish.yml
vendored
11
.github/workflows/publish.yml
vendored
@@ -115,6 +115,9 @@ jobs:
|
||||
target: x86_64-apple-darwin
|
||||
- host: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
|
||||
- host: windows-2025
|
||||
target: aarch64-pc-windows-msvc
|
||||
- host: blacksmith-4vcpu-windows-2025
|
||||
target: x86_64-pc-windows-msvc
|
||||
- host: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -149,6 +152,10 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Cache apt packages
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
uses: actions/cache@v4
|
||||
@@ -254,6 +261,10 @@ jobs:
|
||||
- host: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
platform_flag: --mac --arm64
|
||||
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
|
||||
- host: "windows-2025"
|
||||
target: aarch64-pc-windows-msvc
|
||||
platform_flag: --win --arm64
|
||||
- host: "blacksmith-4vcpu-windows-2025"
|
||||
target: x86_64-pc-windows-msvc
|
||||
platform_flag: --win
|
||||
|
||||
32
.github/workflows/test.yml
vendored
32
.github/workflows/test.yml
vendored
@@ -8,7 +8,9 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
# Keep every run on dev so cancelled checks do not pollute the default branch
|
||||
# commit history. PRs and other branches still share a group and cancel stale runs.
|
||||
group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
@@ -48,20 +50,17 @@ jobs:
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
needs: unit
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- name: linux
|
||||
host: blacksmith-4vcpu-ubuntu-2404
|
||||
playwright: bunx playwright install --with-deps
|
||||
- name: windows
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
playwright: bunx playwright install
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: 0
|
||||
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -74,9 +73,28 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install Playwright browsers
|
||||
- name: Read Playwright version
|
||||
id: playwright-version
|
||||
run: |
|
||||
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ github.workspace }}/.playwright-browsers
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
|
||||
|
||||
- name: Install Playwright system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/app
|
||||
run: ${{ matrix.settings.playwright }}
|
||||
run: bunx playwright install-deps chromium
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
working-directory: packages/app
|
||||
run: bunx playwright install chromium
|
||||
|
||||
- name: Run app e2e tests
|
||||
run: bun --cwd packages/app test:e2e:local
|
||||
|
||||
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 }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,7 +17,7 @@ ts-dist
|
||||
/result
|
||||
refs
|
||||
Session.vim
|
||||
opencode.json
|
||||
/opencode.json
|
||||
a.out
|
||||
target
|
||||
.scripts
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
description: Translate content for a specified locale while preserving technical terms
|
||||
mode: subagent
|
||||
model: opencode/gemini-3-pro
|
||||
model: opencode/gpt-5.4
|
||||
---
|
||||
|
||||
You are a professional translator and localization specialist.
|
||||
|
||||
5
.opencode/command/changelog.md
Normal file
5
.opencode/command/changelog.md
Normal file
@@ -0,0 +1,5 @@
|
||||
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
|
||||
|
||||
once that is done, read UPCOMING_CHANGELOG.md and group it into sections for better readability. make sure all PR references are preserved
|
||||
@@ -1,6 +1,5 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
|
||||
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
const response = await fetch(`https://api.github.com${endpoint}`, {
|
||||
...options,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
|
||||
const TEAM = {
|
||||
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||
zen: ["fwang", "MrMushrooooom"],
|
||||
|
||||
@@ -128,7 +128,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
|
||||
|
||||
#### How is this different from Claude Code?
|
||||
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences::
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||
|
||||
- 100% open source
|
||||
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
|
||||
|
||||
@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
|
||||
|
||||
---
|
||||
|
||||
**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
**加入我们的社区** [飞书](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)
|
||||
|
||||
@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
||||
|
||||
---
|
||||
|
||||
**加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
**加入我們的社群** [飞书](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)
|
||||
|
||||
@@ -103,6 +103,12 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
|
||||
const zenLiteProduct = new stripe.Product("ZenLite", {
|
||||
name: "OpenCode Go",
|
||||
})
|
||||
const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50", {
|
||||
name: "First month 50% off",
|
||||
percentOff: 50,
|
||||
appliesToProducts: [zenLiteProduct.id],
|
||||
duration: "once",
|
||||
})
|
||||
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
||||
product: zenLiteProduct.id,
|
||||
currency: "usd",
|
||||
@@ -116,6 +122,8 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
|
||||
properties: {
|
||||
product: zenLiteProduct.id,
|
||||
price: zenLitePrice.id,
|
||||
priceInr: 92900,
|
||||
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -194,6 +202,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||
|
||||
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
|
||||
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
|
||||
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
|
||||
|
||||
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
||||
handler: "packages/console/function/src/log-processor.ts",
|
||||
link: [new sst.Secret("HONEYCOMB_API_KEY")],
|
||||
@@ -212,6 +224,9 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
EMAILOCTOPUS_API_KEY,
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
SALESFORCE_CLIENT_ID,
|
||||
SALESFORCE_CLIENT_SECRET,
|
||||
SALESFORCE_INSTANCE_URL,
|
||||
ZEN_BLACK_PRICE,
|
||||
ZEN_LITE_PRICE,
|
||||
new sst.Secret("ZEN_LIMITS"),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-dhL4YeSi4Lm9yDp919Fx7N2hyLUbZQa2qWoCf/50ce8=",
|
||||
"aarch64-linux": "sha256-//YxCsrvYlxuvd0MtFFO+pLxjmuemyrvGzSIPxzO+rA=",
|
||||
"aarch64-darwin": "sha256-c65kSWteQNaBcQUsjbXNqT61vt98JPNYo9yMNvUygCw=",
|
||||
"x86_64-darwin": "sha256-hlTzEFv3nZHwlDXU65LfMC+NaqYjjyZqagdJ366CNxY="
|
||||
"x86_64-linux": "sha256-CxeVxNDKEvMsdZpvGZOShklSQ+pWAYq4S3cKDoo6cPQ=",
|
||||
"aarch64-linux": "sha256-qkMacyXRgbFW9ZvAPepDM5O8GROpXtIZhgQsPOHVogg=",
|
||||
"aarch64-darwin": "sha256-jPGGoMViHvMBYFqe8BdtWVT1tvPUKYiLCrOy34PjOG0=",
|
||||
"x86_64-darwin": "sha256-Dss5ChrHZV5/J32iNIh4E+ASltlZsp4QKIiKNlDfAWw="
|
||||
}
|
||||
}
|
||||
|
||||
18
package.json
18
package.json
@@ -4,14 +4,14 @@
|
||||
"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",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'",
|
||||
"hello": "echo 'Hello World!'",
|
||||
@@ -25,7 +25,8 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@types/bun": "1.3.9",
|
||||
"@effect/platform-node": "4.0.0-beta.35",
|
||||
"@types/bun": "1.3.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
@@ -42,9 +43,9 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"effect": "4.0.0-beta.29",
|
||||
"drizzle-kit": "1.0.0-beta.16-c2458b2",
|
||||
"drizzle-orm": "1.0.0-beta.16-c2458b2",
|
||||
"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",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -99,7 +100,6 @@
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"node-pty",
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
@@ -112,6 +112,8 @@
|
||||
},
|
||||
"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"
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
||||
- `openSettings(page)` - Open settings dialog
|
||||
- `closeDialog(page, dialog)` - Close any dialog
|
||||
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
||||
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
|
||||
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
|
||||
- `withSession(sdk, title, callback)` - Create temp session
|
||||
- `withProject(...)` - Create temp project/workspace
|
||||
- `sessionIDFromUrl(url)` - Read session ID from URL
|
||||
@@ -167,6 +169,42 @@ await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
|
||||
await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||
```
|
||||
|
||||
### Terminal Tests
|
||||
|
||||
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
|
||||
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
|
||||
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
|
||||
- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters.
|
||||
- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts.
|
||||
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
|
||||
|
||||
### Wait on state
|
||||
|
||||
- Never use wall-clock waits like `page.waitForTimeout(...)` to make a test pass
|
||||
- Avoid race-prone flows that assume work is finished after an action
|
||||
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
|
||||
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
|
||||
- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops
|
||||
- Do not treat a visible element as proof that the app will route the next action to it
|
||||
- When fixing a flake, validate with `--repeat-each` and multiple workers when practical
|
||||
|
||||
### Add hooks
|
||||
|
||||
- If required state is not observable from the UI, add a small test-only driver or probe in app code instead of sleeps or fragile DOM checks
|
||||
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
|
||||
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
|
||||
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
|
||||
- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable
|
||||
- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states
|
||||
|
||||
### Prefer helpers
|
||||
|
||||
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
|
||||
- Use direct locators when the interaction is simple and a helper would not add clarity
|
||||
- Prefer helpers that both perform an action and verify the app consumed it
|
||||
- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state
|
||||
- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
1. Choose appropriate folder or create new one
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, type Locator, type Page } from "@playwright/test"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { execSync } from "node:child_process"
|
||||
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
||||
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||
import {
|
||||
dropdownMenuTriggerSelector,
|
||||
dropdownMenuContentSelector,
|
||||
projectSwitchSelector,
|
||||
projectMenuTriggerSelector,
|
||||
projectCloseMenuSelector,
|
||||
projectWorkspacesToggleSelector,
|
||||
@@ -15,10 +18,22 @@ import {
|
||||
listItemSelector,
|
||||
listItemKeySelector,
|
||||
listItemKeyStartsWithSelector,
|
||||
promptSelector,
|
||||
terminalSelector,
|
||||
workspaceItemSelector,
|
||||
workspaceMenuTriggerSelector,
|
||||
} from "./selectors"
|
||||
|
||||
const phase = new WeakMap<Page, "test" | "cleanup">()
|
||||
|
||||
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
|
||||
phase.set(page, value)
|
||||
}
|
||||
|
||||
export function healthPhase(page: Page) {
|
||||
return phase.get(page) ?? "test"
|
||||
}
|
||||
|
||||
export async function defocus(page: Page) {
|
||||
await page
|
||||
.evaluate(() => {
|
||||
@@ -28,6 +43,138 @@ export async function defocus(page: Page) {
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
async function terminalID(term: Locator) {
|
||||
const id = await term.getAttribute(terminalAttr)
|
||||
if (id) return id
|
||||
throw new Error(`Active terminal missing ${terminalAttr}`)
|
||||
}
|
||||
|
||||
export async function terminalConnects(page: Page, input?: { term?: Locator }) {
|
||||
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||
const id = await terminalID(term)
|
||||
return page.evaluate((id) => {
|
||||
return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0
|
||||
}, id)
|
||||
}
|
||||
|
||||
export async function disconnectTerminal(page: Page, input?: { term?: Locator }) {
|
||||
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||
const id = await terminalID(term)
|
||||
await page.evaluate((id) => {
|
||||
;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.()
|
||||
}, id)
|
||||
}
|
||||
|
||||
async function terminalReady(page: Page, term?: Locator) {
|
||||
const next = term ?? page.locator(terminalSelector).first()
|
||||
const id = await terminalID(next)
|
||||
return page.evaluate((id) => {
|
||||
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
|
||||
return !!state?.connected && (state.settled ?? 0) > 0
|
||||
}, id)
|
||||
}
|
||||
|
||||
async function terminalFocusIdle(page: Page, term?: Locator) {
|
||||
const next = term ?? page.locator(terminalSelector).first()
|
||||
const id = await terminalID(next)
|
||||
return page.evaluate((id) => {
|
||||
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
|
||||
return (state?.focusing ?? 0) === 0
|
||||
}, id)
|
||||
}
|
||||
|
||||
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
|
||||
const next = input.term ?? page.locator(terminalSelector).first()
|
||||
const id = await terminalID(next)
|
||||
return page.evaluate(
|
||||
(input) => {
|
||||
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
|
||||
return state?.rendered.includes(input.token) ?? false
|
||||
},
|
||||
{ id, token: input.token },
|
||||
)
|
||||
}
|
||||
|
||||
async function promptSlashActive(page: Page, id: string) {
|
||||
return page.evaluate((id) => {
|
||||
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
|
||||
if (state?.popover !== "slash") return false
|
||||
if (!state.slash.ids.includes(id)) return false
|
||||
return state.slash.active === id
|
||||
}, id)
|
||||
}
|
||||
|
||||
async function promptSlashSelects(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
async function promptSlashSelected(page: Page, input: { id: string; count: number }) {
|
||||
return page.evaluate((input) => {
|
||||
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
|
||||
if (!state) return false
|
||||
return state.selected === input.id && state.selects >= input.count
|
||||
}, input)
|
||||
}
|
||||
|
||||
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
|
||||
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||
const timeout = input?.timeout ?? 10_000
|
||||
await expect(term).toBeVisible()
|
||||
await expect(term.locator("textarea")).toHaveCount(1)
|
||||
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) {
|
||||
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||
const timeout = input?.timeout ?? 10_000
|
||||
await waitTerminalReady(page, { term, timeout })
|
||||
await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function showPromptSlash(
|
||||
page: Page,
|
||||
input: { id: string; text: string; prompt?: Locator; timeout?: number },
|
||||
) {
|
||||
const prompt = input.prompt ?? page.locator(promptSelector)
|
||||
const timeout = input.timeout ?? 10_000
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await prompt.click().catch(() => false)
|
||||
await prompt.fill(input.text).catch(() => false)
|
||||
return promptSlashActive(page, input.id).catch(() => false)
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
export async function runPromptSlash(
|
||||
page: Page,
|
||||
input: { id: string; text: string; prompt?: Locator; timeout?: number },
|
||||
) {
|
||||
const prompt = input.prompt ?? page.locator(promptSelector)
|
||||
const timeout = input.timeout ?? 10_000
|
||||
const count = await promptSlashSelects(page)
|
||||
await showPromptSlash(page, input)
|
||||
await prompt.press("Enter")
|
||||
await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
|
||||
const term = input.term ?? page.locator(terminalSelector).first()
|
||||
const timeout = input.timeout ?? 10_000
|
||||
await waitTerminalReady(page, { term, timeout })
|
||||
const textarea = term.locator("textarea")
|
||||
await term.click()
|
||||
await expect(textarea).toBeFocused()
|
||||
await page.keyboard.type(input.cmd)
|
||||
await page.keyboard.press("Enter")
|
||||
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function openPalette(page: Page) {
|
||||
await defocus(page)
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
@@ -60,11 +207,51 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
||||
}
|
||||
|
||||
export async function isSidebarClosed(page: Page) {
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
await expect(button).toBeVisible()
|
||||
const button = await waitSidebarButton(page, "isSidebarClosed")
|
||||
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||
}
|
||||
|
||||
async function errorBoundaryText(page: Page) {
|
||||
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
|
||||
if (!(await title.isVisible().catch(() => false))) return
|
||||
|
||||
const description = await page
|
||||
.getByText(/an error occurred while loading the application\./i)
|
||||
.first()
|
||||
.textContent()
|
||||
.catch(() => "")
|
||||
const detail = await page
|
||||
.getByRole("textbox", { name: /error details/i })
|
||||
.first()
|
||||
.inputValue()
|
||||
.catch(async () =>
|
||||
(
|
||||
(await page
|
||||
.getByRole("textbox", { name: /error details/i })
|
||||
.first()
|
||||
.textContent()
|
||||
.catch(() => "")) ?? ""
|
||||
).trim(),
|
||||
)
|
||||
|
||||
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
|
||||
}
|
||||
|
||||
export async function assertHealthy(page: Page, context: string) {
|
||||
const text = await errorBoundaryText(page)
|
||||
if (!text) return
|
||||
console.log(`[e2e:error-boundary][${context}]\n${text}`)
|
||||
throw new Error(`Error boundary during ${context}\n${text}`)
|
||||
}
|
||||
|
||||
async function waitSidebarButton(page: Page, context: string) {
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
|
||||
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
|
||||
await assertHealthy(page, context)
|
||||
return button
|
||||
}
|
||||
|
||||
export async function toggleSidebar(page: Page) {
|
||||
await defocus(page)
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
@@ -73,7 +260,7 @@ export async function toggleSidebar(page: Page) {
|
||||
export async function openSidebar(page: Page) {
|
||||
if (!(await isSidebarClosed(page))) return
|
||||
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const button = await waitSidebarButton(page, "openSidebar")
|
||||
await button.click()
|
||||
|
||||
const opened = await expect(button)
|
||||
@@ -90,7 +277,7 @@ export async function openSidebar(page: Page) {
|
||||
export async function closeSidebar(page: Page) {
|
||||
if (await isSidebarClosed(page)) return
|
||||
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const button = await waitSidebarButton(page, "closeSidebar")
|
||||
await button.click()
|
||||
|
||||
const closed = await expect(button)
|
||||
@@ -105,6 +292,7 @@ export async function closeSidebar(page: Page) {
|
||||
}
|
||||
|
||||
export async function openSettings(page: Page) {
|
||||
await assertHealthy(page, "openSettings")
|
||||
await defocus(page)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
@@ -117,6 +305,8 @@ export async function openSettings(page: Page) {
|
||||
|
||||
if (opened) return dialog
|
||||
|
||||
await assertHealthy(page, "openSettings")
|
||||
|
||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||
await expect(dialog).toBeVisible()
|
||||
return dialog
|
||||
@@ -178,10 +368,12 @@ export async function seedProjects(page: Page, input: { directory: string; extra
|
||||
|
||||
export async function createTestProject() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||
const id = `e2e-${path.basename(root)}`
|
||||
|
||||
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
||||
await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
|
||||
|
||||
execSync("git init", { cwd: root, stdio: "ignore" })
|
||||
await fs.writeFile(path.join(root, ".git", "opencode"), id)
|
||||
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
||||
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
||||
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||
@@ -203,12 +395,24 @@ export function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
async function probeSession(page: Page) {
|
||||
return page
|
||||
.evaluate(() => {
|
||||
const win = window as E2EWindow
|
||||
const current = win.__opencode_e2e?.model?.current
|
||||
if (!current) return null
|
||||
return { dir: current.dir, sessionID: current.sessionID }
|
||||
})
|
||||
.catch(() => null as { dir?: string; sessionID?: string } | null)
|
||||
}
|
||||
|
||||
export async function waitSlug(page: Page, skip: string[] = []) {
|
||||
let prev = ""
|
||||
let next = ""
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
async () => {
|
||||
await assertHealthy(page, "waitSlug")
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
if (skip.includes(slug)) return ""
|
||||
@@ -226,6 +430,94 @@ export async function waitSlug(page: Page, skip: string[] = []) {
|
||||
return next
|
||||
}
|
||||
|
||||
export async function resolveSlug(slug: string) {
|
||||
const directory = base64Decode(slug)
|
||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||
const resolved = await resolveDirectory(directory)
|
||||
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
|
||||
}
|
||||
|
||||
export async function waitDir(page: Page, directory: string) {
|
||||
const target = await resolveDirectory(directory)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await assertHealthy(page, "waitDir")
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
return resolveSlug(slug)
|
||||
.then((item) => item.directory)
|
||||
.catch(() => "")
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(target)
|
||||
return { directory: target, slug: base64Encode(target) }
|
||||
}
|
||||
|
||||
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
|
||||
const target = await resolveDirectory(input.directory)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await assertHealthy(page, "waitSession")
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return false
|
||||
const resolved = await resolveSlug(slug).catch(() => undefined)
|
||||
if (!resolved || resolved.directory !== target) return false
|
||||
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
|
||||
|
||||
const state = await probeSession(page)
|
||||
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
||||
if (state?.dir) {
|
||||
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
|
||||
if (dir !== target) return false
|
||||
}
|
||||
|
||||
return page
|
||||
.locator(promptSelector)
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
return { directory: target, slug: base64Encode(target) }
|
||||
}
|
||||
|
||||
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
|
||||
const sdk = createSdk(directory)
|
||||
const target = await resolveDirectory(directory)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session
|
||||
.get({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!data?.directory) return ""
|
||||
return resolveDirectory(data.directory).catch(() => data.directory)
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
.toBe(target)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const items = await sdk.session
|
||||
.messages({ sessionID, limit: 20 })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
return items.some((item) => item.info.role === "user")
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
export function sessionIDFromUrl(url: string) {
|
||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||
return match?.[1]
|
||||
@@ -539,12 +831,19 @@ export async function seedSessionTask(
|
||||
.flatMap((message) => message.parts)
|
||||
.find((part) => {
|
||||
if (part.type !== "tool" || part.tool !== "task") return false
|
||||
if (part.state.input?.description !== input.description) return false
|
||||
return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
|
||||
if (!("state" in part) || !part.state || typeof part.state !== "object") return false
|
||||
if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false
|
||||
if (!("description" in part.state.input) || part.state.input.description !== input.description) return false
|
||||
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object")
|
||||
return false
|
||||
if (!("sessionId" in part.state.metadata)) return false
|
||||
return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0
|
||||
})
|
||||
|
||||
if (!part) return
|
||||
const id = part.state.metadata?.sessionId
|
||||
if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return
|
||||
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
|
||||
if (!("sessionId" in part.state.metadata)) return
|
||||
const id = part.state.metadata.sessionId
|
||||
if (typeof id !== "string" || !id) return
|
||||
const child = await sdk.session
|
||||
.get({ sessionID: id })
|
||||
@@ -630,8 +929,14 @@ export async function openStatusPopover(page: Page) {
|
||||
}
|
||||
|
||||
export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||
await openSidebar(page)
|
||||
const item = page.locator(projectSwitchSelector(projectSlug)).first()
|
||||
await expect(item).toBeVisible()
|
||||
await item.hover()
|
||||
|
||||
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
||||
await expect(trigger).toHaveCount(1)
|
||||
await expect(trigger).toBeVisible()
|
||||
|
||||
const menu = page
|
||||
.locator(dropdownMenuContentSelector)
|
||||
@@ -640,7 +945,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
||||
|
||||
const clicked = await trigger
|
||||
.click({ timeout: 1500 })
|
||||
.click({ force: true, timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
|
||||
@@ -3,8 +3,11 @@ import { serverNamePattern } from "../utils"
|
||||
|
||||
test("home renders and shows core entrypoints", async ({ page }) => {
|
||||
await page.goto("/")
|
||||
const nav = page.locator('[data-component="sidebar-nav-desktop"]')
|
||||
|
||||
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
|
||||
await expect(nav.getByText("No projects open")).toBeVisible()
|
||||
await expect(nav.getByText("Open a project to get started")).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test"
|
||||
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
|
||||
import { promptSelector } from "./selectors"
|
||||
import type { E2EWindow } from "../src/testing/terminal"
|
||||
import {
|
||||
healthPhase,
|
||||
cleanupSession,
|
||||
cleanupTestProject,
|
||||
createTestProject,
|
||||
setHealthPhase,
|
||||
seedProjects,
|
||||
sessionIDFromUrl,
|
||||
waitSlug,
|
||||
waitSession,
|
||||
} from "./actions"
|
||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||
|
||||
export const settingsKey = "settings.v3"
|
||||
@@ -26,6 +36,29 @@ type WorkerFixtures = {
|
||||
}
|
||||
|
||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
page: async ({ page }, use) => {
|
||||
let boundary: string | undefined
|
||||
setHealthPhase(page, "test")
|
||||
const consoleHandler = (msg: { text(): string }) => {
|
||||
const text = msg.text()
|
||||
if (!text.includes("[e2e:error-boundary]")) return
|
||||
if (healthPhase(page) === "cleanup") {
|
||||
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
|
||||
return
|
||||
}
|
||||
boundary ||= text
|
||||
console.log(text)
|
||||
}
|
||||
const pageErrorHandler = (err: Error) => {
|
||||
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
|
||||
}
|
||||
page.on("console", consoleHandler)
|
||||
page.on("pageerror", pageErrorHandler)
|
||||
await use(page)
|
||||
page.off("console", consoleHandler)
|
||||
page.off("pageerror", pageErrorHandler)
|
||||
if (boundary) throw new Error(boundary)
|
||||
},
|
||||
directory: [
|
||||
async ({}, use) => {
|
||||
const directory = await getWorktree()
|
||||
@@ -47,21 +80,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await waitSession(page, { directory, sessionID })
|
||||
}
|
||||
await use(gotoSession)
|
||||
},
|
||||
withProject: async ({ page }, use) => {
|
||||
await use(async (callback, options) => {
|
||||
const root = await createTestProject()
|
||||
const slug = dirSlug(root)
|
||||
const sessions = new Map<string, string>()
|
||||
const dirs = new Set<string>()
|
||||
await seedStorage(page, { directory: root, extra: options?.extra })
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(root, sessionID))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await waitSession(page, { directory: root, sessionID })
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (current) trackSession(current)
|
||||
}
|
||||
@@ -76,13 +108,16 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
const slug = await waitSlug(page)
|
||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
||||
} finally {
|
||||
setHealthPhase(page, "cleanup")
|
||||
await Promise.allSettled(
|
||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
||||
)
|
||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||
await cleanupTestProject(root)
|
||||
setHealthPhase(page, "test")
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -91,6 +126,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
||||
await seedProjects(page, input)
|
||||
await page.addInitScript(() => {
|
||||
const win = window as E2EWindow
|
||||
win.__opencode_e2e = {
|
||||
...win.__opencode_e2e,
|
||||
model: {
|
||||
enabled: true,
|
||||
},
|
||||
prompt: {
|
||||
enabled: true,
|
||||
},
|
||||
terminal: {
|
||||
enabled: true,
|
||||
terminals: {},
|
||||
},
|
||||
}
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:model",
|
||||
JSON.stringify({
|
||||
|
||||
@@ -1,41 +1,19 @@
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
|
||||
import {
|
||||
defocus,
|
||||
createTestProject,
|
||||
cleanupTestProject,
|
||||
openSidebar,
|
||||
sessionIDFromUrl,
|
||||
setWorkspacesEnabled,
|
||||
waitSession,
|
||||
waitSessionSaved,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { dirSlug, resolveDirectory } from "../utils"
|
||||
|
||||
async function workspaces(page: Page, directory: string, enabled: boolean) {
|
||||
await page.evaluate(
|
||||
({ directory, enabled }: { directory: string; enabled: boolean }) => {
|
||||
const key = "opencode.global.dat:layout"
|
||||
const raw = localStorage.getItem(key)
|
||||
const data = raw ? JSON.parse(raw) : {}
|
||||
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
|
||||
const current =
|
||||
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
|
||||
? sidebar.workspaces
|
||||
: {}
|
||||
const next = { ...current }
|
||||
|
||||
if (enabled) next[directory] = true
|
||||
if (!enabled) delete next[directory]
|
||||
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
...data,
|
||||
sidebar: {
|
||||
...sidebar,
|
||||
workspaces: next,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ directory, enabled },
|
||||
)
|
||||
}
|
||||
|
||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
@@ -76,9 +54,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
await withProject(
|
||||
async ({ directory, slug, trackSession, trackDirectory }) => {
|
||||
await defocus(page)
|
||||
await workspaces(page, directory, true)
|
||||
await page.reload()
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
await openSidebar(page)
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
|
||||
@@ -100,11 +76,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
await expect(btn).toBeVisible()
|
||||
await btn.click({ force: true })
|
||||
|
||||
// A new workspace can be discovered via a transient slug before the route and sidebar
|
||||
// settle to the canonical workspace path on Windows, so interact with either and assert
|
||||
// against the resolved workspace slug.
|
||||
await waitSlug(page)
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
||||
await waitSession(page, { directory: space })
|
||||
|
||||
// Create a session by sending a prompt
|
||||
const prompt = page.locator(promptSelector)
|
||||
@@ -118,6 +90,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
const created = sessionIDFromUrl(page.url())
|
||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
||||
trackSession(created, space)
|
||||
await waitSessionSaved(space, created)
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||
|
||||
@@ -125,14 +98,14 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||
await otherButton.click({ force: true })
|
||||
await waitSession(page, { directory: other })
|
||||
|
||||
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
||||
await expect(rootButton).toBeVisible()
|
||||
await rootButton.click()
|
||||
await rootButton.click({ force: true })
|
||||
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
|
||||
await waitSession(page, { directory: space, sessionID: created })
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
||||
},
|
||||
{ extra: [other] },
|
||||
|
||||
@@ -1,109 +1,94 @@
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
|
||||
import {
|
||||
openSidebar,
|
||||
resolveSlug,
|
||||
sessionIDFromUrl,
|
||||
setWorkspacesEnabled,
|
||||
waitDir,
|
||||
waitSession,
|
||||
waitSessionSaved,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
async function waitWorkspaceReady(page: Page, slug: string) {
|
||||
function item(space: { slug: string; raw: string }) {
|
||||
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
|
||||
}
|
||||
|
||||
function button(space: { slug: string; raw: string }) {
|
||||
return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}`
|
||||
}
|
||||
|
||||
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
|
||||
await openSidebar(page)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
|
||||
}
|
||||
|
||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||
await openSidebar(page)
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
const slug = await waitSlug(page, [root, ...seen])
|
||||
const directory = base64Decode(slug)
|
||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||
return { slug, directory }
|
||||
}
|
||||
|
||||
async function openWorkspaceNewSession(page: Page, slug: string) {
|
||||
await waitWorkspaceReady(page, slug)
|
||||
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
await item.hover()
|
||||
|
||||
const button = page.locator(workspaceNewSessionSelector(slug)).first()
|
||||
await expect(button).toBeVisible()
|
||||
await button.click({ force: true })
|
||||
|
||||
const next = await waitSlug(page)
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
||||
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
||||
await waitDir(page, next.directory)
|
||||
return next
|
||||
}
|
||||
|
||||
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
||||
const next = await openWorkspaceNewSession(page, slug)
|
||||
async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) {
|
||||
await waitWorkspaceReady(page, space)
|
||||
|
||||
const row = page.locator(item(space)).first()
|
||||
await row.hover()
|
||||
|
||||
const next = page.locator(button(space)).first()
|
||||
await expect(next).toBeVisible()
|
||||
await next.click({ force: true })
|
||||
|
||||
await waitSession(page, { directory: space.directory })
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
|
||||
}
|
||||
|
||||
async function createSessionFromWorkspace(
|
||||
page: Page,
|
||||
space: { slug: string; raw: string; directory: string },
|
||||
text: string,
|
||||
) {
|
||||
await openWorkspaceNewSession(page, space)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await expect(prompt).toBeVisible()
|
||||
await expect(prompt).toBeEditable()
|
||||
await prompt.click()
|
||||
await expect(prompt).toBeFocused()
|
||||
await prompt.fill(text)
|
||||
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
||||
await prompt.press("Enter")
|
||||
|
||||
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
||||
const sessionID = sessionIDFromUrl(page.url())
|
||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
|
||||
return { sessionID, slug: next }
|
||||
}
|
||||
|
||||
async function sessionDirectory(directory: string, sessionID: string) {
|
||||
const info = await createSdk(directory)
|
||||
.session.get({ sessionID })
|
||||
.then((x) => x.data)
|
||||
await waitSessionSaved(space.directory, sessionID)
|
||||
await createSdk(space.directory)
|
||||
.session.abort({ sessionID })
|
||||
.catch(() => undefined)
|
||||
if (!info) return ""
|
||||
return info.directory
|
||||
return sessionID
|
||||
}
|
||||
|
||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
|
||||
await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, root, true)
|
||||
|
||||
const first = await createWorkspace(page, root, [])
|
||||
trackDirectory(first.directory)
|
||||
await waitWorkspaceReady(page, first.slug)
|
||||
await waitWorkspaceReady(page, first)
|
||||
|
||||
const second = await createWorkspace(page, root, [first.slug])
|
||||
trackDirectory(second.directory)
|
||||
await waitWorkspaceReady(page, second.slug)
|
||||
await waitWorkspaceReady(page, second)
|
||||
|
||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||
trackSession(firstSession.sessionID, first.directory)
|
||||
|
||||
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
||||
trackSession(secondSession.sessionID, second.directory)
|
||||
|
||||
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
||||
trackSession(thirdSession.sessionID, first.directory)
|
||||
|
||||
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
|
||||
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
|
||||
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
|
||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
|
||||
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
|
||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import type { Page } from "@playwright/test"
|
||||
|
||||
import { test, expect } from "../fixtures"
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
confirmDialog,
|
||||
openSidebar,
|
||||
openWorkspaceMenu,
|
||||
resolveSlug,
|
||||
setWorkspacesEnabled,
|
||||
slugFromUrl,
|
||||
waitDir,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||
@@ -27,15 +29,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||
await setWorkspacesEnabled(page, rootSlug, true)
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
const slug = await waitSlug(page, [rootSlug])
|
||||
const dir = base64Decode(slug)
|
||||
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
|
||||
await waitDir(page, next.directory)
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
@@ -47,7 +49,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
return { rootSlug, slug, directory: dir }
|
||||
return { rootSlug, slug: next.slug, directory: next.directory }
|
||||
}
|
||||
|
||||
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
||||
@@ -79,15 +81,15 @@ test("can create a workspace", async ({ page, withProject }) => {
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
const workspaceSlug = await waitSlug(page, [slug])
|
||||
const workspaceDir = base64Decode(workspaceSlug)
|
||||
const next = await resolveSlug(await waitSlug(page, [slug]))
|
||||
await waitDir(page, next.directory)
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
||||
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
@@ -99,9 +101,9 @@ test("can create a workspace", async ({ page, withProject }) => {
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
|
||||
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
|
||||
|
||||
await cleanupTestProject(workspaceDir)
|
||||
await cleanupTestProject(next.directory)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -119,7 +121,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
|
||||
|
||||
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
||||
|
||||
const activeDir = base64Decode(slugFromUrl(page.url()))
|
||||
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
|
||||
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
||||
|
||||
await openSidebar(page)
|
||||
@@ -331,9 +333,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
|
||||
for (const _ of [0, 1]) {
|
||||
const prev = slugFromUrl(page.url())
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
const slug = await waitSlug(page, [rootSlug, prev])
|
||||
const dir = base64Decode(slug)
|
||||
workspaces.push({ slug, directory: dir })
|
||||
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
|
||||
await waitDir(page, next.directory)
|
||||
workspaces.push(next)
|
||||
|
||||
await openSidebar(page)
|
||||
}
|
||||
|
||||
@@ -7,12 +7,18 @@ test("shift+enter inserts a newline without submitting", async ({ page, gotoSess
|
||||
await expect(page).toHaveURL(/\/session\/?$/)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await prompt.click()
|
||||
await page.keyboard.type("line one")
|
||||
await page.keyboard.press("Shift+Enter")
|
||||
await page.keyboard.type("line two")
|
||||
await prompt.focus()
|
||||
await expect(prompt).toBeFocused()
|
||||
|
||||
await prompt.pressSequentially("line one")
|
||||
await expect(prompt).toBeFocused()
|
||||
|
||||
await prompt.press("Shift+Enter")
|
||||
await expect(page).toHaveURL(/\/session\/?$/)
|
||||
await expect(prompt).toBeFocused()
|
||||
|
||||
await prompt.pressSequentially("line two")
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/?$/)
|
||||
await expect(prompt).toContainText("line one")
|
||||
await expect(prompt).toContainText("line two")
|
||||
await expect.poll(() => prompt.evaluate((el) => el.innerText)).toBe("line one\nline two")
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { runPromptSlash, waitTerminalFocusIdle } from "../actions"
|
||||
import { promptSelector, terminalSelector } from "../selectors"
|
||||
|
||||
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||
@@ -9,15 +10,9 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/terminal")
|
||||
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(terminal).toBeVisible()
|
||||
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
|
||||
await waitTerminalFocusIdle(page, { term: terminal })
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/terminal")
|
||||
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
|
||||
await expect(terminal).not.toBeVisible()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const promptSelector = '[data-component="prompt-input"]'
|
||||
export const terminalSelector = '[data-component="terminal"]'
|
||||
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
|
||||
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
|
||||
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
||||
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
||||
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
||||
@@ -12,6 +13,9 @@ export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggl
|
||||
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
|
||||
|
||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
|
||||
export const promptModelSelector = '[data-component="prompt-model-control"]'
|
||||
export const promptVariantSelector = '[data-component="prompt-variant-control"]'
|
||||
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
||||
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
|
||||
export const settingsThemeSelector = '[data-action="settings-theme"]'
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
||||
import {
|
||||
composerEvent,
|
||||
type ComposerDriverState,
|
||||
type ComposerProbeState,
|
||||
type ComposerWindow,
|
||||
} from "../../src/testing/session-composer"
|
||||
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
|
||||
import {
|
||||
permissionDockSelector,
|
||||
promptSelector,
|
||||
questionDockSelector,
|
||||
sessionComposerDockSelector,
|
||||
sessionTodoDockSelector,
|
||||
sessionTodoListSelector,
|
||||
sessionTodoToggleButtonSelector,
|
||||
} from "../selectors"
|
||||
|
||||
@@ -42,12 +46,8 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
|
||||
|
||||
async function clearPermissionDock(page: any, label: RegExp) {
|
||||
const dock = page.locator(permissionDockSelector)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const count = await dock.count()
|
||||
if (count === 0) return
|
||||
await dock.getByRole("button", { name: label }).click()
|
||||
await page.waitForTimeout(150)
|
||||
}
|
||||
await expect(dock).toBeVisible()
|
||||
await dock.getByRole("button", { name: label }).click()
|
||||
}
|
||||
|
||||
async function setAutoAccept(page: any, enabled: boolean) {
|
||||
@@ -59,6 +59,120 @@ async function setAutoAccept(page: any, enabled: boolean) {
|
||||
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
|
||||
}
|
||||
|
||||
async function expectQuestionBlocked(page: any) {
|
||||
await expect(page.locator(questionDockSelector)).toBeVisible()
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
}
|
||||
|
||||
async function expectQuestionOpen(page: any) {
|
||||
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
}
|
||||
|
||||
async function expectPermissionBlocked(page: any) {
|
||||
await expect(page.locator(permissionDockSelector)).toBeVisible()
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
}
|
||||
|
||||
async function expectPermissionOpen(page: any) {
|
||||
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
}
|
||||
|
||||
async function todoDock(page: any, sessionID: string) {
|
||||
await page.addInitScript(() => {
|
||||
const win = window as ComposerWindow
|
||||
win.__opencode_e2e = {
|
||||
...win.__opencode_e2e,
|
||||
composer: {
|
||||
enabled: true,
|
||||
sessions: {},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const write = async (driver: ComposerDriverState | undefined) => {
|
||||
await page.evaluate(
|
||||
(input) => {
|
||||
const win = window as ComposerWindow
|
||||
const composer = win.__opencode_e2e?.composer
|
||||
if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
|
||||
composer.sessions ??= {}
|
||||
const prev = composer.sessions[input.sessionID] ?? {}
|
||||
if (!input.driver) {
|
||||
if (!prev.probe) {
|
||||
delete composer.sessions[input.sessionID]
|
||||
} else {
|
||||
composer.sessions[input.sessionID] = { probe: prev.probe }
|
||||
}
|
||||
} else {
|
||||
composer.sessions[input.sessionID] = {
|
||||
...prev,
|
||||
driver: input.driver,
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } }))
|
||||
},
|
||||
{ event: composerEvent, sessionID, driver },
|
||||
)
|
||||
}
|
||||
|
||||
const read = () =>
|
||||
page.evaluate((sessionID) => {
|
||||
const win = window as ComposerWindow
|
||||
return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
|
||||
}, sessionID) as Promise<ComposerProbeState | null>
|
||||
|
||||
const api = {
|
||||
async clear() {
|
||||
await write(undefined)
|
||||
return api
|
||||
},
|
||||
async open(todos: NonNullable<ComposerDriverState["todos"]>) {
|
||||
await write({ live: true, todos })
|
||||
return api
|
||||
},
|
||||
async finish(todos: NonNullable<ComposerDriverState["todos"]>) {
|
||||
await write({ live: false, todos })
|
||||
return api
|
||||
},
|
||||
async expectOpen(states: ComposerProbeState["states"]) {
|
||||
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
|
||||
mounted: true,
|
||||
collapsed: false,
|
||||
hidden: false,
|
||||
count: states.length,
|
||||
states,
|
||||
})
|
||||
return api
|
||||
},
|
||||
async expectCollapsed(states: ComposerProbeState["states"]) {
|
||||
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
|
||||
mounted: true,
|
||||
collapsed: true,
|
||||
hidden: true,
|
||||
count: states.length,
|
||||
states,
|
||||
})
|
||||
return api
|
||||
},
|
||||
async expectClosed() {
|
||||
await expect.poll(read, { timeout: 10_000 }).toMatchObject({ mounted: false })
|
||||
return api
|
||||
},
|
||||
async collapse() {
|
||||
await page.locator(sessionTodoToggleButtonSelector).click()
|
||||
return api
|
||||
},
|
||||
async expand() {
|
||||
await page.locator(sessionTodoToggleButtonSelector).click()
|
||||
return api
|
||||
},
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
async function withMockPermission<T>(
|
||||
page: any,
|
||||
request: {
|
||||
@@ -70,7 +184,7 @@ async function withMockPermission<T>(
|
||||
always?: string[]
|
||||
},
|
||||
opts: { child?: any } | undefined,
|
||||
fn: () => Promise<T>,
|
||||
fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
|
||||
) {
|
||||
let pending = [
|
||||
{
|
||||
@@ -119,8 +233,14 @@ async function withMockPermission<T>(
|
||||
|
||||
if (sessionList) await page.route("**/session?*", sessionList)
|
||||
|
||||
const state = {
|
||||
async resolved() {
|
||||
await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0)
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn()
|
||||
return await fn(state)
|
||||
} finally {
|
||||
await page.unroute("**/permission", list)
|
||||
await page.unroute("**/session/*/permissions/*", reply)
|
||||
@@ -173,14 +293,12 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
|
||||
})
|
||||
|
||||
const dock = page.locator(questionDockSelector)
|
||||
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
await expectQuestionBlocked(page)
|
||||
|
||||
await dock.locator('[data-slot="question-option"]').first().click()
|
||||
await dock.getByRole("button", { name: /submit/i }).click()
|
||||
|
||||
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -199,15 +317,14 @@ test("blocked permission flow supports allow once", async ({ page, sdk, gotoSess
|
||||
metadata: { description: "Need permission for command" },
|
||||
},
|
||||
undefined,
|
||||
async () => {
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
await clearPermissionDock(page, /allow once/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -226,15 +343,14 @@ test("blocked permission flow supports reject", async ({ page, sdk, gotoSession
|
||||
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
||||
},
|
||||
undefined,
|
||||
async () => {
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
await clearPermissionDock(page, /deny/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -254,15 +370,14 @@ test("blocked permission flow supports allow always", async ({ page, sdk, gotoSe
|
||||
metadata: { description: "Need permission for command" },
|
||||
},
|
||||
undefined,
|
||||
async () => {
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
await clearPermissionDock(page, /allow always/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -301,14 +416,12 @@ test("child session question request blocks parent dock and unblocks after submi
|
||||
})
|
||||
|
||||
const dock = page.locator(questionDockSelector)
|
||||
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
await expectQuestionBlocked(page)
|
||||
|
||||
await dock.locator('[data-slot="question-option"]').first().click()
|
||||
await dock.getByRole("button", { name: /submit/i }).click()
|
||||
|
||||
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
} finally {
|
||||
await cleanupSession({ sdk, sessionID: child.id })
|
||||
@@ -344,17 +457,15 @@ test("child session permission request blocks parent dock and supports allow onc
|
||||
metadata: { description: "Need child permission" },
|
||||
},
|
||||
{ child },
|
||||
async () => {
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
const dock = page.locator(permissionDockSelector)
|
||||
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
await clearPermissionDock(page, /allow once/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
@@ -365,36 +476,31 @@ test("child session permission request blocks parent dock and supports allow onc
|
||||
|
||||
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
const dock = await todoDock(page, session.id)
|
||||
await gotoSession(session.id)
|
||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||
|
||||
await seedSessionTodos(sdk, {
|
||||
sessionID: session.id,
|
||||
todos: [
|
||||
{ content: "first task", status: "pending", priority: "high" },
|
||||
{ content: "second task", status: "in_progress", priority: "medium" },
|
||||
],
|
||||
})
|
||||
try {
|
||||
await dock.open([
|
||||
{ content: "first task", status: "pending", priority: "high" },
|
||||
{ content: "second task", status: "in_progress", priority: "medium" },
|
||||
])
|
||||
await dock.expectOpen(["pending", "in_progress"])
|
||||
|
||||
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
|
||||
await dock.collapse()
|
||||
await dock.expectCollapsed(["pending", "in_progress"])
|
||||
|
||||
await page.locator(sessionTodoToggleButtonSelector).click()
|
||||
await expect(page.locator(sessionTodoListSelector)).toBeHidden()
|
||||
await dock.expand()
|
||||
await dock.expectOpen(["pending", "in_progress"])
|
||||
|
||||
await page.locator(sessionTodoToggleButtonSelector).click()
|
||||
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
|
||||
|
||||
await seedSessionTodos(sdk, {
|
||||
sessionID: session.id,
|
||||
todos: [
|
||||
{ content: "first task", status: "completed", priority: "high" },
|
||||
{ content: "second task", status: "cancelled", priority: "medium" },
|
||||
],
|
||||
})
|
||||
|
||||
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
})
|
||||
await dock.finish([
|
||||
{ content: "first task", status: "completed", priority: "high" },
|
||||
{ content: "second task", status: "cancelled", priority: "medium" },
|
||||
])
|
||||
await dock.expectClosed()
|
||||
} finally {
|
||||
await dock.clear()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -414,8 +520,7 @@ test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSe
|
||||
],
|
||||
})
|
||||
|
||||
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
await expectQuestionBlocked(page)
|
||||
|
||||
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||
await page.keyboard.type("abc")
|
||||
|
||||
359
packages/app/e2e/session/session-model-persistence.spec.ts
Normal file
359
packages/app/e2e/session/session-model-persistence.spec.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import type { Locator, Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import {
|
||||
openSidebar,
|
||||
resolveSlug,
|
||||
sessionIDFromUrl,
|
||||
setWorkspacesEnabled,
|
||||
waitSession,
|
||||
waitSessionIdle,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import {
|
||||
promptAgentSelector,
|
||||
promptModelSelector,
|
||||
promptSelector,
|
||||
promptVariantSelector,
|
||||
workspaceItemSelector,
|
||||
workspaceNewSessionSelector,
|
||||
} from "../selectors"
|
||||
import { createSdk, sessionPath } from "../utils"
|
||||
|
||||
type Footer = {
|
||||
agent: string
|
||||
model: string
|
||||
variant: string
|
||||
}
|
||||
|
||||
type Probe = {
|
||||
dir?: string
|
||||
sessionID?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
}
|
||||
|
||||
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
|
||||
const text = async (locator: Locator) => ((await locator.textContent()) ?? "").trim()
|
||||
|
||||
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
|
||||
|
||||
async function probe(page: Page): Promise<Probe | null> {
|
||||
return page.evaluate(() => {
|
||||
const win = window as Window & {
|
||||
__opencode_e2e?: {
|
||||
model?: {
|
||||
current?: Probe
|
||||
}
|
||||
}
|
||||
}
|
||||
return win.__opencode_e2e?.model?.current ?? null
|
||||
})
|
||||
}
|
||||
|
||||
async function read(page: Page): Promise<Footer> {
|
||||
return {
|
||||
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
|
||||
model: await text(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()),
|
||||
variant: await text(page.locator(`${promptVariantSelector} [data-slot="select-select-trigger-value"]`).first()),
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFooter(page: Page, expected: Partial<Footer>) {
|
||||
let hit: Footer | null = null
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await read(page)
|
||||
const ok = Object.entries(expected).every(([key, value]) => state[key as keyof Footer] === value)
|
||||
if (ok) hit = state
|
||||
return ok
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
if (!hit) throw new Error("Failed to resolve prompt footer state")
|
||||
return hit
|
||||
}
|
||||
|
||||
async function waitModel(page: Page, value: string) {
|
||||
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).toBe(value)
|
||||
}
|
||||
|
||||
async function choose(page: Page, root: string, value: string) {
|
||||
const select = page.locator(root)
|
||||
await expect(select).toBeVisible()
|
||||
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
|
||||
const item = page
|
||||
.locator('[data-slot="select-select-item"]')
|
||||
.filter({ hasText: new RegExp(`^\\s*${escape(value)}\\s*$`) })
|
||||
.first()
|
||||
await expect(item).toBeVisible()
|
||||
await item.click()
|
||||
}
|
||||
|
||||
async function variantCount(page: Page) {
|
||||
const select = page.locator(promptVariantSelector)
|
||||
await expect(select).toBeVisible()
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
const count = await page.locator('[data-slot="select-select-item"]').count()
|
||||
await page.keyboard.press("Escape")
|
||||
return count
|
||||
}
|
||||
|
||||
async function agents(page: Page) {
|
||||
const select = page.locator(promptAgentSelector)
|
||||
await expect(select).toBeVisible()
|
||||
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
|
||||
const labels = await page.locator('[data-slot="select-select-item-label"]').allTextContents()
|
||||
await page.keyboard.press("Escape")
|
||||
return labels.map((item) => item.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
async function ensureVariant(page: Page, directory: string): Promise<Footer> {
|
||||
const current = await read(page)
|
||||
if ((await variantCount(page)) >= 2) return current
|
||||
|
||||
const cfg = await createSdk(directory)
|
||||
.config.get()
|
||||
.then((x) => x.data)
|
||||
const visible = new Set(await agents(page))
|
||||
const entry = Object.entries(cfg?.agent ?? {}).find((item) => {
|
||||
const value = item[1]
|
||||
return !!value && typeof value === "object" && "variant" in value && "model" in value && visible.has(item[0])
|
||||
})
|
||||
const name = entry?.[0]
|
||||
test.skip(!name, "no agent with alternate variants available")
|
||||
if (!name) return current
|
||||
|
||||
await choose(page, promptAgentSelector, name)
|
||||
await expect.poll(() => variantCount(page), { timeout: 30_000 }).toBeGreaterThanOrEqual(2)
|
||||
return waitFooter(page, { agent: name })
|
||||
}
|
||||
|
||||
async function chooseDifferentVariant(page: Page): Promise<Footer> {
|
||||
const current = await read(page)
|
||||
const select = page.locator(promptVariantSelector)
|
||||
await expect(select).toBeVisible()
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
const items = page.locator('[data-slot="select-select-item"]')
|
||||
const count = await items.count()
|
||||
if (count < 2) throw new Error("Current model has no alternate variant to select")
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const item = items.nth(i)
|
||||
const next = await text(item.locator('[data-slot="select-select-item-label"]').first())
|
||||
if (!next || next === current.variant) continue
|
||||
await item.click()
|
||||
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
|
||||
}
|
||||
|
||||
throw new Error("Failed to choose a different variant")
|
||||
}
|
||||
|
||||
async function chooseOtherModel(page: Page): Promise<Footer> {
|
||||
const current = await read(page)
|
||||
const button = page.locator(`${promptModelSelector} [data-action="prompt-model"]`)
|
||||
await expect(button).toBeVisible()
|
||||
await button.click()
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
const items = dialog.locator('[data-slot="list-item"]')
|
||||
const count = await items.count()
|
||||
expect(count).toBeGreaterThan(1)
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const item = items.nth(i)
|
||||
const selected = (await item.getAttribute("data-selected")) === "true"
|
||||
if (selected) continue
|
||||
await item.click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect.poll(async () => (await read(page)).model !== current.model, { timeout: 30_000 }).toBe(true)
|
||||
return read(page)
|
||||
}
|
||||
|
||||
throw new Error("Failed to choose a different model")
|
||||
}
|
||||
|
||||
async function goto(page: Page, directory: string, sessionID?: string) {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
await waitSession(page, { directory, sessionID })
|
||||
}
|
||||
|
||||
async function submit(page: Page, value: string) {
|
||||
const prompt = page.locator(promptSelector)
|
||||
await expect(prompt).toBeVisible()
|
||||
await prompt.click()
|
||||
await prompt.fill(value)
|
||||
await prompt.press("Enter")
|
||||
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||
const id = sessionIDFromUrl(page.url())
|
||||
if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`)
|
||||
return id
|
||||
}
|
||||
|
||||
async function waitUser(directory: string, sessionID: string) {
|
||||
const sdk = createSdk(directory)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? [])
|
||||
return items.some((item) => item.info.role === "user")
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
await sdk.session.abort({ sessionID }).catch(() => undefined)
|
||||
await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined)
|
||||
}
|
||||
|
||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||
await openSidebar(page)
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
||||
await waitSession(page, { directory: next.directory })
|
||||
return next
|
||||
}
|
||||
|
||||
async function waitWorkspace(page: Page, slug: string) {
|
||||
await openSidebar(page)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function newWorkspaceSession(page: Page, slug: string) {
|
||||
await waitWorkspace(page, slug)
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
await item.hover()
|
||||
|
||||
const button = page.locator(workspaceNewSessionSelector(slug)).first()
|
||||
await expect(button).toBeVisible()
|
||||
await button.click({ force: true })
|
||||
|
||||
const next = await resolveSlug(await waitSlug(page))
|
||||
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
||||
}
|
||||
|
||||
test("session model and variant restore per session without leaking into new sessions", async ({
|
||||
page,
|
||||
withProject,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
|
||||
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await ensureVariant(page, directory)
|
||||
const firstState = await chooseDifferentVariant(page)
|
||||
const first = await submit(page, `session variant ${Date.now()}`)
|
||||
trackSession(first)
|
||||
await waitUser(directory, first)
|
||||
|
||||
await page.reload()
|
||||
await waitSession(page, { directory, sessionID: first })
|
||||
await waitFooter(page, firstState)
|
||||
|
||||
await gotoSession()
|
||||
const fresh = await ensureVariant(page, directory)
|
||||
expect(fresh.variant).not.toBe(firstState.variant)
|
||||
|
||||
const secondState = await chooseOtherModel(page)
|
||||
const second = await submit(page, `session model ${Date.now()}`)
|
||||
trackSession(second)
|
||||
await waitUser(directory, second)
|
||||
|
||||
await goto(page, directory, first)
|
||||
await waitFooter(page, firstState)
|
||||
|
||||
await goto(page, directory, second)
|
||||
await waitFooter(page, secondState)
|
||||
|
||||
await gotoSession()
|
||||
await waitFooter(page, fresh)
|
||||
})
|
||||
})
|
||||
|
||||
test("session model restore across workspaces", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
|
||||
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await ensureVariant(page, root)
|
||||
const firstState = await chooseDifferentVariant(page)
|
||||
const first = await submit(page, `root session ${Date.now()}`)
|
||||
trackSession(first, root)
|
||||
await waitUser(root, first)
|
||||
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
|
||||
const one = await createWorkspace(page, slug, [])
|
||||
const oneDir = await newWorkspaceSession(page, one.slug)
|
||||
trackDirectory(oneDir)
|
||||
|
||||
const secondState = await chooseOtherModel(page)
|
||||
const second = await submit(page, `workspace one ${Date.now()}`)
|
||||
trackSession(second, oneDir)
|
||||
await waitUser(oneDir, second)
|
||||
|
||||
const two = await createWorkspace(page, slug, [one.slug])
|
||||
const twoDir = await newWorkspaceSession(page, two.slug)
|
||||
trackDirectory(twoDir)
|
||||
|
||||
await ensureVariant(page, twoDir)
|
||||
const thirdState = await chooseDifferentVariant(page)
|
||||
const third = await submit(page, `workspace two ${Date.now()}`)
|
||||
trackSession(third, twoDir)
|
||||
await waitUser(twoDir, third)
|
||||
|
||||
await goto(page, root, first)
|
||||
await waitFooter(page, firstState)
|
||||
|
||||
await goto(page, oneDir, second)
|
||||
await waitFooter(page, secondState)
|
||||
|
||||
await goto(page, twoDir, third)
|
||||
await waitFooter(page, thirdState)
|
||||
|
||||
await goto(page, root, first)
|
||||
await waitFooter(page, firstState)
|
||||
})
|
||||
})
|
||||
|
||||
test("variant preserved when switching agent modes", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
|
||||
await withProject(async ({ directory, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await ensureVariant(page, directory)
|
||||
const updated = await chooseDifferentVariant(page)
|
||||
|
||||
const available = await agents(page)
|
||||
const other = available.find((name) => name !== updated.agent)
|
||||
test.skip(!other, "only one agent available")
|
||||
if (!other) return
|
||||
|
||||
await choose(page, promptAgentSelector, other)
|
||||
await waitFooter(page, { agent: other, variant: updated.variant })
|
||||
|
||||
await choose(page, promptAgentSelector, updated.agent)
|
||||
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
|
||||
})
|
||||
})
|
||||
426
packages/app/e2e/session/session-review.spec.ts
Normal file
426
packages/app/e2e/session/session-review.spec.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import { waitSessionIdle, withSession } from "../actions"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
const count = 14
|
||||
|
||||
function body(mark: string) {
|
||||
return [
|
||||
`title ${mark}`,
|
||||
`mark ${mark}`,
|
||||
...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`),
|
||||
]
|
||||
}
|
||||
|
||||
function files(tag: string) {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const id = String(i).padStart(2, "0")
|
||||
return {
|
||||
file: `review-scroll-${id}.txt`,
|
||||
mark: `${tag}-${id}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function seed(list: ReturnType<typeof files>) {
|
||||
const out = ["*** Begin Patch"]
|
||||
|
||||
for (const item of list) {
|
||||
out.push(`*** Add File: ${item.file}`)
|
||||
for (const line of body(item.mark)) out.push(`+${line}`)
|
||||
}
|
||||
|
||||
out.push("*** End Patch")
|
||||
return out.join("\n")
|
||||
}
|
||||
|
||||
function edit(file: string, prev: string, next: string) {
|
||||
return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join(
|
||||
"\n",
|
||||
)
|
||||
}
|
||||
|
||||
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
|
||||
await sdk.session.promptAsync({
|
||||
sessionID,
|
||||
agent: "build",
|
||||
system: [
|
||||
"You are seeding deterministic e2e UI state.",
|
||||
"Your only valid response is one apply_patch tool call.",
|
||||
`Use this JSON input: ${JSON.stringify({ patchText })}`,
|
||||
"Do not call any other tools.",
|
||||
"Do not output plain text.",
|
||||
].join("\n"),
|
||||
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
||||
})
|
||||
|
||||
await waitSessionIdle(sdk, sessionID, 120_000)
|
||||
}
|
||||
|
||||
async function show(page: Parameters<typeof test>[0]["page"]) {
|
||||
const btn = page.getByRole("button", { name: "Toggle review" }).first()
|
||||
await expect(btn).toBeVisible()
|
||||
if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
|
||||
await expect(btn).toHaveAttribute("aria-expanded", "true")
|
||||
}
|
||||
|
||||
async function expand(page: Parameters<typeof test>[0]["page"]) {
|
||||
const close = page.getByRole("button", { name: /^Collapse all$/i }).first()
|
||||
const open = await close
|
||||
.isVisible()
|
||||
.then((value) => value)
|
||||
.catch(() => false)
|
||||
|
||||
const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
|
||||
if (open) {
|
||||
await close.click()
|
||||
await expect(btn).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(btn).toBeVisible()
|
||||
await btn.click()
|
||||
await expect(close).toBeVisible()
|
||||
}
|
||||
|
||||
async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
|
||||
await page.waitForFunction(
|
||||
({ file, mark }) => {
|
||||
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
|
||||
if (!(view instanceof HTMLElement)) return false
|
||||
|
||||
const head = Array.from(view.querySelectorAll("h3")).find(
|
||||
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
|
||||
)
|
||||
if (!(head instanceof HTMLElement)) return false
|
||||
|
||||
return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => {
|
||||
if (!(host instanceof HTMLElement)) return false
|
||||
const root = host.shadowRoot
|
||||
return root?.textContent?.includes(`mark ${mark}`) ?? false
|
||||
})
|
||||
},
|
||||
{ file, mark },
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
|
||||
return page.evaluate((file) => {
|
||||
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
|
||||
if (!(view instanceof HTMLElement)) return null
|
||||
|
||||
const row = Array.from(view.querySelectorAll("h3")).find(
|
||||
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
|
||||
)
|
||||
if (!(row instanceof HTMLElement)) return null
|
||||
|
||||
const a = row.getBoundingClientRect()
|
||||
const b = view.getBoundingClientRect()
|
||||
return {
|
||||
top: a.top - b.top,
|
||||
y: view.scrollTop,
|
||||
}
|
||||
}, file)
|
||||
}
|
||||
|
||||
async function comment(page: Parameters<typeof test>[0]["page"], file: string, note: string) {
|
||||
const row = page.locator(`[data-file="${file}"]`).first()
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
const line = row.locator('diffs-container [data-line="2"]').first()
|
||||
await expect(line).toBeVisible()
|
||||
await line.hover()
|
||||
|
||||
const add = row.getByRole("button", { name: /^Comment$/ }).first()
|
||||
await expect(add).toBeVisible()
|
||||
await add.click()
|
||||
|
||||
const area = row.locator('[data-slot="line-comment-textarea"]').first()
|
||||
await expect(area).toBeVisible()
|
||||
await area.fill(note)
|
||||
|
||||
const submit = row.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
|
||||
await expect(submit).toBeEnabled()
|
||||
await submit.click()
|
||||
|
||||
await expect(row.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
|
||||
await expect(row.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
|
||||
}
|
||||
|
||||
async function overflow(page: Parameters<typeof test>[0]["page"], file: string) {
|
||||
const row = page.locator(`[data-file="${file}"]`).first()
|
||||
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
||||
const pop = row.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
|
||||
const tools = row.locator('[data-slot="line-comment-tools"]').first()
|
||||
|
||||
const [width, viewBox, popBox, toolsBox] = await Promise.all([
|
||||
view.evaluate((el) => el.scrollWidth - el.clientWidth),
|
||||
view.boundingBox(),
|
||||
pop.boundingBox(),
|
||||
tools.boundingBox(),
|
||||
])
|
||||
|
||||
if (!viewBox || !popBox || !toolsBox) return null
|
||||
|
||||
return {
|
||||
width,
|
||||
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
|
||||
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
|
||||
}
|
||||
}
|
||||
|
||||
async function openReviewFile(page: Parameters<typeof test>[0]["page"], file: string) {
|
||||
const row = page.locator(`[data-file="${file}"]`).first()
|
||||
await expect(row).toBeVisible()
|
||||
await row.hover()
|
||||
|
||||
const open = row.getByRole("button", { name: /^Open file$/i }).first()
|
||||
await expect(open).toBeVisible()
|
||||
await open.click()
|
||||
|
||||
const tab = page.getByRole("tab", { name: file }).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
|
||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||
await expect(viewer).toBeVisible()
|
||||
return viewer
|
||||
}
|
||||
|
||||
async function fileComment(page: Parameters<typeof test>[0]["page"], note: string) {
|
||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||
await expect(viewer).toBeVisible()
|
||||
|
||||
const line = viewer.locator('diffs-container [data-line="2"]').first()
|
||||
await expect(line).toBeVisible()
|
||||
await line.hover()
|
||||
|
||||
const add = viewer.getByRole("button", { name: /^Comment$/ }).first()
|
||||
await expect(add).toBeVisible()
|
||||
await add.click()
|
||||
|
||||
const area = viewer.locator('[data-slot="line-comment-textarea"]').first()
|
||||
await expect(area).toBeVisible()
|
||||
await area.fill(note)
|
||||
|
||||
const submit = viewer.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
|
||||
await expect(submit).toBeEnabled()
|
||||
await submit.click()
|
||||
|
||||
await expect(viewer.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
|
||||
await expect(viewer.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
|
||||
}
|
||||
|
||||
async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
|
||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||
const view = page.locator('[role="tabpanel"] .scroll-view__viewport').first()
|
||||
const pop = viewer.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
|
||||
const tools = viewer.locator('[data-slot="line-comment-tools"]').first()
|
||||
|
||||
const [width, viewBox, popBox, toolsBox] = await Promise.all([
|
||||
view.evaluate((el) => el.scrollWidth - el.clientWidth),
|
||||
view.boundingBox(),
|
||||
pop.boundingBox(),
|
||||
tools.boundingBox(),
|
||||
])
|
||||
|
||||
if (!viewBox || !popBox || !toolsBox) return null
|
||||
|
||||
return {
|
||||
width,
|
||||
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
|
||||
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
|
||||
}
|
||||
}
|
||||
|
||||
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
|
||||
test.setTimeout(180_000)
|
||||
|
||||
const tag = `review-comment-${Date.now()}`
|
||||
const file = `review-comment-${tag}.txt`
|
||||
const note = `comment ${tag}`
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
|
||||
await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
|
||||
await patch(sdk, session.id, seed([{ file, mark: tag }]))
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
return diff.length
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(1)
|
||||
|
||||
await project.gotoSession(session.id)
|
||||
await show(page)
|
||||
|
||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
|
||||
await expand(page)
|
||||
await waitMark(page, file, tag)
|
||||
await comment(page, file, note)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
|
||||
test.setTimeout(180_000)
|
||||
|
||||
const tag = `review-file-comment-${Date.now()}`
|
||||
const file = `review-file-comment-${tag}.txt`
|
||||
const note = `comment ${tag}`
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
|
||||
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
|
||||
await patch(sdk, session.id, seed([{ file, mark: tag }]))
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
return diff.length
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(1)
|
||||
|
||||
await project.gotoSession(session.id)
|
||||
await show(page)
|
||||
|
||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
|
||||
await expand(page)
|
||||
await waitMark(page, file, tag)
|
||||
await openReviewFile(page, file)
|
||||
await fileComment(page, note)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
|
||||
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
|
||||
test.setTimeout(180_000)
|
||||
|
||||
const tag = `review-${Date.now()}`
|
||||
const list = files(tag)
|
||||
const hit = list[list.length - 4]!
|
||||
const next = `${tag}-live`
|
||||
|
||||
await page.setViewportSize({ width: 1600, height: 1000 })
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
|
||||
await withSession(sdk, `e2e review ${tag}`, async (session) => {
|
||||
await patch(sdk, session.id, seed(list))
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
|
||||
return info?.summary?.files ?? 0
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(list.length)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
return diff.length
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(list.length)
|
||||
|
||||
await project.gotoSession(session.id)
|
||||
await show(page)
|
||||
|
||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
|
||||
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
||||
await expect(view).toBeVisible()
|
||||
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
|
||||
await expect(heads).toHaveCount(list.length, {
|
||||
timeout: 60_000,
|
||||
})
|
||||
|
||||
await expand(page)
|
||||
await waitMark(page, hit.file, hit.mark)
|
||||
|
||||
const row = page
|
||||
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
|
||||
.first()
|
||||
await expect(row).toBeVisible()
|
||||
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
|
||||
|
||||
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
|
||||
const prev = await spot(page, hit.file)
|
||||
if (!prev) throw new Error(`missing review row for ${hit.file}`)
|
||||
|
||||
await patch(sdk, session.id, edit(hit.file, hit.mark, next))
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
const item = diff.find((item) => item.file === hit.file)
|
||||
return typeof item?.after === "string" ? item.after : ""
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toContain(`mark ${next}`)
|
||||
|
||||
await waitMark(page, hit.file, next)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const next = await spot(page, hit.file)
|
||||
if (!next) return Number.POSITIVE_INFINITY
|
||||
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBeLessThanOrEqual(32)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSettings, closeDialog, withSession } from "../actions"
|
||||
import { openSettings, closeDialog, waitTerminalFocusIdle, withSession } from "../actions"
|
||||
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
||||
import { modKey } from "../utils"
|
||||
|
||||
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await expect(terminal).toBeVisible()
|
||||
await waitTerminalFocusIdle(page, { term: terminal })
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { waitTerminalFocusIdle, waitTerminalReady } from "../actions"
|
||||
import { promptSelector, terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey } from "../utils"
|
||||
|
||||
@@ -13,8 +14,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
||||
await page.keyboard.press(terminalToggleKey)
|
||||
}
|
||||
|
||||
await expect(terminals.first()).toBeVisible()
|
||||
await expect(terminals.first().locator("textarea")).toHaveCount(1)
|
||||
await waitTerminalFocusIdle(page, { term: terminals.first() })
|
||||
await expect(terminals).toHaveCount(1)
|
||||
|
||||
// Ghostty captures a lot of keybinds when focused; move focus back
|
||||
@@ -24,5 +24,5 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
||||
|
||||
await expect(tabs).toHaveCount(2)
|
||||
await expect(terminals).toHaveCount(1)
|
||||
await expect(terminals.first().locator("textarea")).toHaveCount(1)
|
||||
await waitTerminalReady(page, { term: terminals.first() })
|
||||
})
|
||||
|
||||
46
packages/app/e2e/terminal/terminal-reconnect.spec.ts
Normal file
46
packages/app/e2e/terminal/terminal-reconnect.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { disconnectTerminal, runTerminal, terminalConnects, waitTerminalReady } from "../actions"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey } from "../utils"
|
||||
|
||||
async function open(page: Page) {
|
||||
const term = page.locator(terminalSelector).first()
|
||||
const visible = await term.isVisible().catch(() => false)
|
||||
if (!visible) await page.keyboard.press(terminalToggleKey)
|
||||
await waitTerminalReady(page, { term })
|
||||
return term
|
||||
}
|
||||
|
||||
test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
|
||||
await withProject(async ({ gotoSession }) => {
|
||||
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
|
||||
const token = `E2E_RECONNECT_${Date.now()}`
|
||||
|
||||
await gotoSession()
|
||||
|
||||
const term = await open(page)
|
||||
const id = await term.getAttribute("data-pty-id")
|
||||
if (!id) throw new Error("Active terminal missing data-pty-id")
|
||||
|
||||
const prev = await terminalConnects(page, { term })
|
||||
|
||||
await runTerminal(page, {
|
||||
term,
|
||||
cmd: `export ${name}=${token}; echo ${token}`,
|
||||
token,
|
||||
})
|
||||
|
||||
await disconnectTerminal(page, { term })
|
||||
|
||||
await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
|
||||
await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
|
||||
|
||||
await runTerminal(page, {
|
||||
term,
|
||||
cmd: `echo $${name}`,
|
||||
token,
|
||||
timeout: 15_000,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +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 = {
|
||||
@@ -17,16 +18,7 @@ async function open(page: Page) {
|
||||
const terminal = page.locator(terminalSelector)
|
||||
const visible = await terminal.isVisible().catch(() => false)
|
||||
if (!visible) await page.keyboard.press(terminalToggleKey)
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
}
|
||||
|
||||
async function run(page: Page, cmd: string) {
|
||||
const terminal = page.locator(terminalSelector)
|
||||
await expect(terminal).toBeVisible()
|
||||
await terminal.click()
|
||||
await page.keyboard.type(cmd)
|
||||
await page.keyboard.press("Enter")
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
}
|
||||
|
||||
async function store(page: Page, key: string) {
|
||||
@@ -56,15 +48,16 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
|
||||
await gotoSession()
|
||||
await open(page)
|
||||
|
||||
await run(page, `echo ${one}`)
|
||||
await runTerminal(page, { cmd: `echo ${one}`, token: one })
|
||||
|
||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||
await expect(tabs).toHaveCount(2)
|
||||
|
||||
await run(page, `echo ${two}`)
|
||||
await runTerminal(page, { cmd: `echo ${two}`, token: two })
|
||||
|
||||
await first.click()
|
||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
@@ -76,7 +69,7 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
|
||||
second: second.includes(two),
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toEqual({ first: false, second: true })
|
||||
|
||||
@@ -93,7 +86,7 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
|
||||
second: second.includes(two),
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toEqual({ first: true, second: false })
|
||||
})
|
||||
@@ -137,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,4 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { waitTerminalReady } from "../actions"
|
||||
import { terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey } from "../utils"
|
||||
|
||||
@@ -13,5 +14,5 @@ test("terminal panel can be toggled", async ({ page, gotoSession }) => {
|
||||
}
|
||||
|
||||
await page.keyboard.press(terminalToggleKey)
|
||||
await expect(terminal).toBeVisible()
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
})
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"rootDir": "..",
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
"include": ["./**/*.ts", "../src/testing/terminal.ts"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.24",
|
||||
"version": "1.3.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -45,8 +45,8 @@
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/active-element": "2.1.3",
|
||||
"@solid-primitives/audio": "1.4.2",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
@@ -54,8 +54,10 @@
|
||||
"@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:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||
"luxon": "catalog:",
|
||||
|
||||
@@ -6,6 +6,7 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
||||
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||
const reuse = !process.env.CI
|
||||
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
@@ -17,6 +18,7 @@ export default defineConfig({
|
||||
fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1",
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers,
|
||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||
webServer: {
|
||||
command,
|
||||
|
||||
@@ -73,6 +73,7 @@ const serverEnv = {
|
||||
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
|
||||
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
|
||||
OPENCODE_CLIENT: "app",
|
||||
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
||||
} satisfies Record<string, string>
|
||||
|
||||
const runnerEnv = {
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
import "@/index.css"
|
||||
import { File } from "@opencode-ai/ui/file"
|
||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
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 { MetaProvider } from "@solidjs/meta"
|
||||
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
|
||||
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,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
ErrorBoundary,
|
||||
For,
|
||||
type JSX,
|
||||
lazy,
|
||||
onCleanup,
|
||||
type ParentProps,
|
||||
Show,
|
||||
Suspense,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider } from "@/context/command"
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
@@ -22,29 +39,21 @@ import { NotificationProvider } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
|
||||
const Home = lazy(() => import("@/pages/home"))
|
||||
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
const Loading = () => <div class="size-full" />
|
||||
|
||||
const HomeRoute = () => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Home />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
const SessionRoute = () => (
|
||||
<SessionProviders>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Session />
|
||||
</Suspense>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
)
|
||||
|
||||
@@ -52,7 +61,7 @@ const SessionIndexRoute = () => <Navigate href="session" />
|
||||
|
||||
function UiI18nBridge(props: ParentProps) {
|
||||
const language = useLanguage()
|
||||
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
|
||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -62,6 +71,9 @@ declare global {
|
||||
deepLinks?: string[]
|
||||
wsl?: boolean
|
||||
}
|
||||
api?: {
|
||||
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,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>
|
||||
@@ -105,8 +122,10 @@ function SessionProviders(props: ParentProps) {
|
||||
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||
return (
|
||||
<AppShellProviders>
|
||||
{props.appChildren}
|
||||
{props.children}
|
||||
<Suspense fallback={<Loading />}>
|
||||
{props.appChildren}
|
||||
{props.children}
|
||||
</Suspense>
|
||||
</AppShellProviders>
|
||||
)
|
||||
}
|
||||
@@ -115,15 +134,21 @@ export function AppBaseProviders(props: ParentProps) {
|
||||
return (
|
||||
<MetaProvider>
|
||||
<Font />
|
||||
<ThemeProvider>
|
||||
<ThemeProvider
|
||||
onThemeApplied={(_, mode) => {
|
||||
void window.api?.setTitlebar?.({ mode })
|
||||
}}
|
||||
>
|
||||
<LanguageProvider>
|
||||
<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>
|
||||
@@ -132,6 +157,116 @@ export function AppBaseProviders(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const effectMinDuration =
|
||||
(duration: Duration.Input) =>
|
||||
<A, E, R>(e: Effect.Effect<A, E, R>) =>
|
||||
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
|
||||
|
||||
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
||||
const server = useServer()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
|
||||
|
||||
// performs repeated health check with a grace period for
|
||||
// non-http connections, otherwise fails instantly
|
||||
const [startupHealthCheck, healthCheckActions] = createResource(() =>
|
||||
props.disableHealthCheck
|
||||
? true
|
||||
: Effect.gen(function* () {
|
||||
if (!server.current) return true
|
||||
const { http, type } = server.current
|
||||
|
||||
while (true) {
|
||||
const res = yield* Effect.promise(() => checkServerHealth(http))
|
||||
if (res.healthy) return true
|
||||
if (checkMode() === "background" || type === "http") return false
|
||||
}
|
||||
}).pipe(
|
||||
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
|
||||
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
|
||||
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
||||
Effect.runPromise,
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
|
||||
fallback={
|
||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={startupHealthCheck()}
|
||||
fallback={
|
||||
<ConnectionError
|
||||
onRetry={() => {
|
||||
if (checkMode() === "background") healthCheckActions.refetch()
|
||||
}}
|
||||
onServerSelected={(key) => {
|
||||
setCheckMode("blocking")
|
||||
server.setActive(key)
|
||||
healthCheckActions.refetch()
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
|
||||
const name = createMemo(() => server.name || server.key)
|
||||
const serverToken = "\u0000server\u0000"
|
||||
const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
|
||||
|
||||
const timer = setInterval(() => props.onRetry?.(), 1000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
|
||||
return (
|
||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
|
||||
<div class="flex flex-col items-center max-w-md text-center">
|
||||
<Splash class="w-12 h-15 mb-4" />
|
||||
<p class="text-14-regular text-text-base">
|
||||
{unreachable()[0]}
|
||||
<span class="text-text-strong font-medium">{name()}</span>
|
||||
{unreachable()[1]}
|
||||
</p>
|
||||
<p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
|
||||
</div>
|
||||
<Show when={others().length > 0}>
|
||||
<div class="flex flex-col gap-2 w-full max-w-sm">
|
||||
<span class="text-12-regular text-text-base text-center">{language.t("app.server.otherServers")}</span>
|
||||
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
|
||||
<For each={others()}>
|
||||
{(conn) => {
|
||||
const key = ServerConnection.key(conn)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
onClick={() => props.onServerSelected?.(key)}
|
||||
>
|
||||
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerKey(props: ParentProps) {
|
||||
const server = useServer()
|
||||
return (
|
||||
@@ -146,25 +281,28 @@ export function AppInterface(props: {
|
||||
defaultServer: ServerConnection.Key
|
||||
servers?: Array<ServerConnection.Any>
|
||||
router?: Component<BaseRouterProps>
|
||||
disableHealthCheck?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||
<ServerKey>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||
>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={SessionIndexRoute} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Dynamic>
|
||||
</GlobalSyncProvider>
|
||||
</GlobalSDKProvider>
|
||||
</ServerKey>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||
<ServerKey>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||
>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={SessionIndexRoute} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Dynamic>
|
||||
</GlobalSyncProvider>
|
||||
</GlobalSDKProvider>
|
||||
</ServerKey>
|
||||
</ConnectionGate>
|
||||
</ServerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useIsRouting, useLocation } from "@solidjs/router"
|
||||
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
type Mem = Performance & {
|
||||
memory?: {
|
||||
@@ -27,17 +28,17 @@ type Obs = PerformanceObserverInit & {
|
||||
const span = 5000
|
||||
|
||||
const ms = (n?: number, d = 0) => {
|
||||
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||
if (n === undefined || Number.isNaN(n)) return
|
||||
return `${n.toFixed(d)}ms`
|
||||
}
|
||||
|
||||
const time = (n?: number) => {
|
||||
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||
if (n === undefined || Number.isNaN(n)) return
|
||||
return `${Math.round(n)}`
|
||||
}
|
||||
|
||||
const mb = (n?: number) => {
|
||||
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||
if (n === undefined || Number.isNaN(n)) return
|
||||
const v = n / 1024 / 1024
|
||||
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
|
||||
}
|
||||
@@ -74,6 +75,7 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string;
|
||||
}
|
||||
|
||||
export function DebugBar() {
|
||||
const language = useLanguage()
|
||||
const location = useLocation()
|
||||
const routing = useIsRouting()
|
||||
const [state, setState] = createStore({
|
||||
@@ -98,14 +100,15 @@ export function DebugBar() {
|
||||
},
|
||||
})
|
||||
|
||||
const na = () => language.t("debugBar.na")
|
||||
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
|
||||
const heapv = () => {
|
||||
const value = heap()
|
||||
if (value === undefined) return "n/a"
|
||||
if (value === undefined) return na()
|
||||
return `${Math.round(value * 100)}%`
|
||||
}
|
||||
const longv = () => (state.long.count === undefined ? "n/a" : `${time(state.long.block)}/${state.long.count}`)
|
||||
const navv = () => (state.nav.pending ? "..." : time(state.nav.dur))
|
||||
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
|
||||
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
|
||||
|
||||
let prev = ""
|
||||
let start = 0
|
||||
@@ -359,7 +362,7 @@ export function DebugBar() {
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Development performance diagnostics"
|
||||
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)",
|
||||
@@ -368,67 +371,70 @@ export function DebugBar() {
|
||||
>
|
||||
<div class="grid grid-cols-5 gap-px font-mono">
|
||||
<Cell
|
||||
label="NAV"
|
||||
tip="Last completed route transition touching a session page, measured from router start until the first paint after it settles."
|
||||
label={language.t("debugBar.nav.label")}
|
||||
tip={language.t("debugBar.nav.tip")}
|
||||
value={navv()}
|
||||
bad={bad(state.nav.dur, 400)}
|
||||
dim={state.nav.dur === undefined && !state.nav.pending}
|
||||
/>
|
||||
<Cell
|
||||
label="FPS"
|
||||
tip="Rolling frames per second over the last 5 seconds."
|
||||
value={state.fps === undefined ? "n/a" : `${Math.round(state.fps)}`}
|
||||
label={language.t("debugBar.fps.label")}
|
||||
tip={language.t("debugBar.fps.tip")}
|
||||
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
|
||||
bad={bad(state.fps, 50, true)}
|
||||
dim={state.fps === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="FRM"
|
||||
tip="Worst frame time over the last 5 seconds."
|
||||
value={time(state.gap)}
|
||||
label={language.t("debugBar.frame.label")}
|
||||
tip={language.t("debugBar.frame.tip")}
|
||||
value={time(state.gap) ?? na()}
|
||||
bad={bad(state.gap, 50)}
|
||||
dim={state.gap === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="JNK"
|
||||
tip="Frames over 32ms in the last 5 seconds."
|
||||
value={state.jank === undefined ? "n/a" : `${state.jank}`}
|
||||
label={language.t("debugBar.jank.label")}
|
||||
tip={language.t("debugBar.jank.tip")}
|
||||
value={state.jank === undefined ? na() : `${state.jank}`}
|
||||
bad={bad(state.jank, 8)}
|
||||
dim={state.jank === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="LNG"
|
||||
tip={`Blocked time and long-task count in the last 5 seconds. Max task: ${ms(state.long.max)}.`}
|
||||
label={language.t("debugBar.long.label")}
|
||||
tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
|
||||
value={longv()}
|
||||
bad={bad(state.long.block, 200)}
|
||||
dim={state.long.count === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="DLY"
|
||||
tip="Worst observed input delay in the last 5 seconds."
|
||||
value={time(state.delay)}
|
||||
label={language.t("debugBar.delay.label")}
|
||||
tip={language.t("debugBar.delay.tip")}
|
||||
value={time(state.delay) ?? na()}
|
||||
bad={bad(state.delay, 100)}
|
||||
dim={state.delay === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="INP"
|
||||
tip="Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP."
|
||||
value={time(state.inp)}
|
||||
label={language.t("debugBar.inp.label")}
|
||||
tip={language.t("debugBar.inp.tip")}
|
||||
value={time(state.inp) ?? na()}
|
||||
bad={bad(state.inp, 200)}
|
||||
dim={state.inp === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="CLS"
|
||||
tip="Cumulative layout shift for the current app lifetime."
|
||||
value={state.cls === undefined ? "n/a" : state.cls.toFixed(2)}
|
||||
label={language.t("debugBar.cls.label")}
|
||||
tip={language.t("debugBar.cls.tip")}
|
||||
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
|
||||
bad={bad(state.cls, 0.1)}
|
||||
dim={state.cls === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="MEM"
|
||||
label={language.t("debugBar.mem.label")}
|
||||
tip={
|
||||
state.heap.used === undefined
|
||||
? "Used JS heap vs heap limit. Chromium only."
|
||||
: `Used JS heap vs heap limit. ${mb(state.heap.used)} of ${mb(state.heap.limit)}.`
|
||||
? language.t("debugBar.mem.tipUnavailable")
|
||||
: language.t("debugBar.mem.tip", {
|
||||
used: mb(state.heap.used) ?? na(),
|
||||
limit: mb(state.heap.limit) ?? na(),
|
||||
})
|
||||
}
|
||||
value={heapv()}
|
||||
bad={bad(heap(), 0.8)}
|
||||
|
||||
@@ -12,18 +12,15 @@ import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { createMemo, 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 { usePlatform } from "@/context/platform"
|
||||
import { DialogSelectModel } from "./dialog-select-model"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
|
||||
export function DialogConnectProvider(props: { provider: string }) {
|
||||
const dialog = useDialog()
|
||||
const globalSync = useGlobalSync()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
|
||||
const alive = { value: true }
|
||||
@@ -49,13 +46,14 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
state: "pending" as undefined | "pending" | "complete" | "error",
|
||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
type Action =
|
||||
| { type: "method.select"; index: number }
|
||||
| { type: "method.reset" }
|
||||
| { type: "auth.prompt" }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||
| { type: "auth.error"; error: string }
|
||||
@@ -77,6 +75,11 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.prompt") {
|
||||
draft.state = "prompt"
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.pending") {
|
||||
draft.state = "pending"
|
||||
draft.error = undefined
|
||||
@@ -120,7 +123,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
async function selectMethod(index: number) {
|
||||
async function selectMethod(index: number, inputs?: Record<string, string>) {
|
||||
if (timer.current !== undefined) {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = undefined
|
||||
@@ -130,6 +133,10 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
dispatch({ type: "method.select", index })
|
||||
|
||||
if (method.type === "oauth") {
|
||||
if (method.prompts?.length && !inputs) {
|
||||
dispatch({ type: "auth.prompt" })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.pending" })
|
||||
const start = Date.now()
|
||||
await globalSDK.client.provider.oauth
|
||||
@@ -137,6 +144,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
{
|
||||
providerID: props.provider,
|
||||
method: index,
|
||||
inputs,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
@@ -163,6 +171,122 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
function OAuthPromptsView() {
|
||||
const [formStore, setFormStore] = createStore({
|
||||
value: {} as Record<string, string>,
|
||||
index: 0,
|
||||
})
|
||||
|
||||
const prompts = createMemo(() => method()?.prompts ?? [])
|
||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||
if (!prompt.when) return true
|
||||
const actual = value[prompt.when.key]
|
||||
if (actual === undefined) return false
|
||||
return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
|
||||
}
|
||||
const current = createMemo(() => {
|
||||
const all = prompts()
|
||||
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
|
||||
if (index === -1) return
|
||||
return {
|
||||
index,
|
||||
prompt: all[index],
|
||||
}
|
||||
})
|
||||
const valid = createMemo(() => {
|
||||
const item = current()
|
||||
if (!item || item.prompt.type !== "text") return false
|
||||
const value = formStore.value[item.prompt.key] ?? ""
|
||||
return value.trim().length > 0
|
||||
})
|
||||
|
||||
async function next(index: number, value: Record<string, string>) {
|
||||
if (store.methodIndex === undefined) return
|
||||
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
|
||||
if (next !== -1) {
|
||||
setFormStore("index", next)
|
||||
return
|
||||
}
|
||||
await selectMethod(store.methodIndex, value)
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
const item = current()
|
||||
if (!item || item.prompt.type !== "text") return
|
||||
if (!valid()) return
|
||||
await next(item.index, formStore.value)
|
||||
}
|
||||
|
||||
const item = () => current()
|
||||
const text = createMemo(() => {
|
||||
const prompt = item()?.prompt
|
||||
if (!prompt || prompt.type !== "text") return
|
||||
return prompt
|
||||
})
|
||||
const select = createMemo(() => {
|
||||
const prompt = item()?.prompt
|
||||
if (!prompt || prompt.type !== "select") return
|
||||
return prompt
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||
<Switch>
|
||||
<Match when={item()?.prompt.type === "text"}>
|
||||
<TextField
|
||||
type="text"
|
||||
label={text()?.message ?? ""}
|
||||
placeholder={text()?.placeholder}
|
||||
value={text() ? (formStore.value[text()!.key] ?? "") : ""}
|
||||
onChange={(value) => {
|
||||
const prompt = text()
|
||||
if (!prompt) return
|
||||
setFormStore("value", prompt.key, value)
|
||||
}}
|
||||
/>
|
||||
<Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
|
||||
{language.t("common.continue")}
|
||||
</Button>
|
||||
</Match>
|
||||
<Match when={item()?.prompt.type === "select"}>
|
||||
<div class="w-full flex flex-col gap-1.5">
|
||||
<div class="text-14-regular text-text-base">{select()?.message}</div>
|
||||
<div>
|
||||
<List
|
||||
items={select()?.options ?? []}
|
||||
key={(x) => x.value}
|
||||
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
|
||||
onSelect={(value) => {
|
||||
if (!value) return
|
||||
const prompt = select()
|
||||
if (!prompt) return
|
||||
const nextValue = {
|
||||
...formStore.value,
|
||||
[prompt.key]: value.value,
|
||||
}
|
||||
setFormStore("value", prompt.key, value.value)
|
||||
void next(item()!.index, nextValue)
|
||||
}}
|
||||
>
|
||||
{(option) => (
|
||||
<div class="w-full flex items-center gap-x-2">
|
||||
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
||||
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
||||
</div>
|
||||
<span>{option.label}</span>
|
||||
<span class="text-14-regular text-text-weak">{option.hint}</span>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
let listRef: ListRef | undefined
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
|
||||
@@ -301,7 +425,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
error={formStore.error}
|
||||
/>
|
||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||
{language.t("common.submit")}
|
||||
{language.t("common.continue")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -314,12 +438,6 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (store.authorization?.method === "code" && store.authorization?.url) {
|
||||
platform.openLink(store.authorization.url)
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -368,7 +486,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
error={formStore.error}
|
||||
/>
|
||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||
{language.t("common.submit")}
|
||||
{language.t("common.continue")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -386,10 +504,6 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
if (store.authorization?.url) {
|
||||
platform.openLink(store.authorization.url)
|
||||
}
|
||||
|
||||
const result = await globalSDK.client.provider.oauth
|
||||
.callback({
|
||||
providerID: props.provider,
|
||||
@@ -470,6 +584,9 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.state === "prompt"}>
|
||||
<OAuthPromptsView />
|
||||
</Match>
|
||||
<Match when={store.state === "error"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
|
||||
158
packages/app/src/components/dialog-custom-provider-form.ts
Normal file
158
packages/app/src/components/dialog-custom-provider-form.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
||||
|
||||
type Translator = (key: string, vars?: Record<string, string | number | boolean>) => string
|
||||
|
||||
export type ModelErr = {
|
||||
id?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type HeaderErr = {
|
||||
key?: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
export type ModelRow = {
|
||||
row: string
|
||||
id: string
|
||||
name: string
|
||||
err: ModelErr
|
||||
}
|
||||
|
||||
export type HeaderRow = {
|
||||
row: string
|
||||
key: string
|
||||
value: string
|
||||
err: HeaderErr
|
||||
}
|
||||
|
||||
export type FormState = {
|
||||
providerID: string
|
||||
name: string
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
models: ModelRow[]
|
||||
headers: HeaderRow[]
|
||||
err: {
|
||||
providerID?: string
|
||||
name?: string
|
||||
baseURL?: string
|
||||
}
|
||||
}
|
||||
|
||||
type ValidateArgs = {
|
||||
form: FormState
|
||||
t: Translator
|
||||
disabledProviders: string[]
|
||||
existingProviderIDs: Set<string>
|
||||
}
|
||||
|
||||
export function validateCustomProvider(input: ValidateArgs) {
|
||||
const providerID = input.form.providerID.trim()
|
||||
const name = input.form.name.trim()
|
||||
const baseURL = input.form.baseURL.trim()
|
||||
const apiKey = input.form.apiKey.trim()
|
||||
|
||||
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
||||
const key = apiKey && !env ? apiKey : undefined
|
||||
|
||||
const idError = !providerID
|
||||
? input.t("provider.custom.error.providerID.required")
|
||||
: !PROVIDER_ID.test(providerID)
|
||||
? input.t("provider.custom.error.providerID.format")
|
||||
: undefined
|
||||
|
||||
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
||||
const urlError = !baseURL
|
||||
? input.t("provider.custom.error.baseURL.required")
|
||||
: !/^https?:\/\//.test(baseURL)
|
||||
? input.t("provider.custom.error.baseURL.format")
|
||||
: undefined
|
||||
|
||||
const disabled = input.disabledProviders.includes(providerID)
|
||||
const existsError = idError
|
||||
? undefined
|
||||
: input.existingProviderIDs.has(providerID) && !disabled
|
||||
? input.t("provider.custom.error.providerID.exists")
|
||||
: undefined
|
||||
|
||||
const seenModels = new Set<string>()
|
||||
const models = input.form.models.map((m) => {
|
||||
const id = m.id.trim()
|
||||
const idError = !id
|
||||
? input.t("provider.custom.error.required")
|
||||
: seenModels.has(id)
|
||||
? input.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenModels.add(id)
|
||||
return undefined
|
||||
})()
|
||||
const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
||||
return { id: idError, name: nameError }
|
||||
})
|
||||
const modelsValid = models.every((m) => !m.id && !m.name)
|
||||
const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
||||
|
||||
const seenHeaders = new Set<string>()
|
||||
const headers = input.form.headers.map((h) => {
|
||||
const key = h.key.trim()
|
||||
const value = h.value.trim()
|
||||
|
||||
if (!key && !value) return {}
|
||||
const keyError = !key
|
||||
? input.t("provider.custom.error.required")
|
||||
: seenHeaders.has(key.toLowerCase())
|
||||
? input.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenHeaders.add(key.toLowerCase())
|
||||
return undefined
|
||||
})()
|
||||
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
||||
return { key: keyError, value: valueError }
|
||||
})
|
||||
const headersValid = headers.every((h) => !h.key && !h.value)
|
||||
const headerConfig = Object.fromEntries(
|
||||
input.form.headers
|
||||
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
||||
.filter((h) => !!h.key && !!h.value)
|
||||
.map((h) => [h.key, h.value]),
|
||||
)
|
||||
|
||||
const err = {
|
||||
providerID: idError ?? existsError,
|
||||
name: nameError,
|
||||
baseURL: urlError,
|
||||
}
|
||||
|
||||
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
||||
if (!ok) return { err, models, headers }
|
||||
|
||||
return {
|
||||
err,
|
||||
models,
|
||||
headers,
|
||||
result: {
|
||||
providerID,
|
||||
name,
|
||||
key,
|
||||
config: {
|
||||
npm: OPENAI_COMPATIBLE,
|
||||
name,
|
||||
...(env ? { env: [env] } : {}),
|
||||
options: {
|
||||
baseURL,
|
||||
...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
|
||||
},
|
||||
models: modelConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let row = 0
|
||||
|
||||
const nextRow = () => `row-${row++}`
|
||||
|
||||
export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} })
|
||||
export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} })
|
||||
80
packages/app/src/components/dialog-custom-provider.test.ts
Normal file
80
packages/app/src/components/dialog-custom-provider.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { validateCustomProvider } from "./dialog-custom-provider-form"
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
describe("validateCustomProvider", () => {
|
||||
test("builds trimmed config payload", () => {
|
||||
const result = validateCustomProvider({
|
||||
form: {
|
||||
providerID: "custom-provider",
|
||||
name: " Custom Provider ",
|
||||
baseURL: "https://api.example.com ",
|
||||
apiKey: " {env: CUSTOM_PROVIDER_KEY} ",
|
||||
models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }],
|
||||
headers: [
|
||||
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
|
||||
{ row: "h1", key: "", value: "", err: {} },
|
||||
],
|
||||
err: {},
|
||||
},
|
||||
t,
|
||||
disabledProviders: [],
|
||||
existingProviderIDs: new Set(),
|
||||
})
|
||||
|
||||
expect(result.result).toEqual({
|
||||
providerID: "custom-provider",
|
||||
name: "Custom Provider",
|
||||
key: undefined,
|
||||
config: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Custom Provider",
|
||||
env: ["CUSTOM_PROVIDER_KEY"],
|
||||
options: {
|
||||
baseURL: "https://api.example.com",
|
||||
headers: {
|
||||
"X-Test": "enabled",
|
||||
},
|
||||
},
|
||||
models: {
|
||||
"model-a": { name: "Model A" },
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("flags duplicate rows and allows reconnecting disabled providers", () => {
|
||||
const result = validateCustomProvider({
|
||||
form: {
|
||||
providerID: "custom-provider",
|
||||
name: "Provider",
|
||||
baseURL: "https://api.example.com",
|
||||
apiKey: "secret",
|
||||
models: [
|
||||
{ row: "m0", id: "model-a", name: "Model A", err: {} },
|
||||
{ row: "m1", id: "model-a", name: "Model A 2", err: {} },
|
||||
],
|
||||
headers: [
|
||||
{ row: "h0", key: "Authorization", value: "one", err: {} },
|
||||
{ row: "h1", key: "authorization", value: "two", err: {} },
|
||||
],
|
||||
err: {},
|
||||
},
|
||||
t,
|
||||
disabledProviders: ["custom-provider"],
|
||||
existingProviderIDs: new Set(["custom-provider"]),
|
||||
})
|
||||
|
||||
expect(result.result).toBeUndefined()
|
||||
expect(result.err.providerID).toBeUndefined()
|
||||
expect(result.models[1]).toEqual({
|
||||
id: "provider.custom.error.duplicate",
|
||||
name: undefined,
|
||||
})
|
||||
expect(result.headers[1]).toEqual({
|
||||
key: "provider.custom.error.duplicate",
|
||||
value: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,160 +3,18 @@ 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 { For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { batch, For } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
|
||||
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
||||
|
||||
type Translator = ReturnType<typeof useLanguage>["t"]
|
||||
|
||||
type ModelRow = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type HeaderRow = {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
providerID: string
|
||||
name: string
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
models: ModelRow[]
|
||||
headers: HeaderRow[]
|
||||
saving: boolean
|
||||
}
|
||||
|
||||
type FormErrors = {
|
||||
providerID: string | undefined
|
||||
name: string | undefined
|
||||
baseURL: string | undefined
|
||||
models: Array<{ id?: string; name?: string }>
|
||||
headers: Array<{ key?: string; value?: string }>
|
||||
}
|
||||
|
||||
type ValidateArgs = {
|
||||
form: FormState
|
||||
t: Translator
|
||||
disabledProviders: string[]
|
||||
existingProviderIDs: Set<string>
|
||||
}
|
||||
|
||||
function validateCustomProvider(input: ValidateArgs) {
|
||||
const providerID = input.form.providerID.trim()
|
||||
const name = input.form.name.trim()
|
||||
const baseURL = input.form.baseURL.trim()
|
||||
const apiKey = input.form.apiKey.trim()
|
||||
|
||||
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
||||
const key = apiKey && !env ? apiKey : undefined
|
||||
|
||||
const idError = !providerID
|
||||
? input.t("provider.custom.error.providerID.required")
|
||||
: !PROVIDER_ID.test(providerID)
|
||||
? input.t("provider.custom.error.providerID.format")
|
||||
: undefined
|
||||
|
||||
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
||||
const urlError = !baseURL
|
||||
? input.t("provider.custom.error.baseURL.required")
|
||||
: !/^https?:\/\//.test(baseURL)
|
||||
? input.t("provider.custom.error.baseURL.format")
|
||||
: undefined
|
||||
|
||||
const disabled = input.disabledProviders.includes(providerID)
|
||||
const existsError = idError
|
||||
? undefined
|
||||
: input.existingProviderIDs.has(providerID) && !disabled
|
||||
? input.t("provider.custom.error.providerID.exists")
|
||||
: undefined
|
||||
|
||||
const seenModels = new Set<string>()
|
||||
const modelErrors = input.form.models.map((m) => {
|
||||
const id = m.id.trim()
|
||||
const modelIdError = !id
|
||||
? input.t("provider.custom.error.required")
|
||||
: seenModels.has(id)
|
||||
? input.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenModels.add(id)
|
||||
return undefined
|
||||
})()
|
||||
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
||||
return { id: modelIdError, name: modelNameError }
|
||||
})
|
||||
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
||||
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
||||
|
||||
const seenHeaders = new Set<string>()
|
||||
const headerErrors = input.form.headers.map((h) => {
|
||||
const key = h.key.trim()
|
||||
const value = h.value.trim()
|
||||
|
||||
if (!key && !value) return {}
|
||||
const keyError = !key
|
||||
? input.t("provider.custom.error.required")
|
||||
: seenHeaders.has(key.toLowerCase())
|
||||
? input.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenHeaders.add(key.toLowerCase())
|
||||
return undefined
|
||||
})()
|
||||
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
||||
return { key: keyError, value: valueError }
|
||||
})
|
||||
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
||||
const headers = Object.fromEntries(
|
||||
input.form.headers
|
||||
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
||||
.filter((h) => !!h.key && !!h.value)
|
||||
.map((h) => [h.key, h.value]),
|
||||
)
|
||||
|
||||
const errors: FormErrors = {
|
||||
providerID: idError ?? existsError,
|
||||
name: nameError,
|
||||
baseURL: urlError,
|
||||
models: modelErrors,
|
||||
headers: headerErrors,
|
||||
}
|
||||
|
||||
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
||||
if (!ok) return { errors }
|
||||
|
||||
const options = {
|
||||
baseURL,
|
||||
...(Object.keys(headers).length ? { headers } : {}),
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
result: {
|
||||
providerID,
|
||||
name,
|
||||
key,
|
||||
config: {
|
||||
npm: OPENAI_COMPATIBLE,
|
||||
name,
|
||||
...(env ? { env: [env] } : {}),
|
||||
options,
|
||||
models,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
back?: "providers" | "close"
|
||||
}
|
||||
@@ -172,17 +30,9 @@ export function DialogCustomProvider(props: Props) {
|
||||
name: "",
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
models: [{ id: "", name: "" }],
|
||||
headers: [{ key: "", value: "" }],
|
||||
saving: false,
|
||||
})
|
||||
|
||||
const [errors, setErrors] = createStore<FormErrors>({
|
||||
providerID: undefined,
|
||||
name: undefined,
|
||||
baseURL: undefined,
|
||||
models: [{}],
|
||||
headers: [{}],
|
||||
models: [modelRow()],
|
||||
headers: [headerRow()],
|
||||
err: {},
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
@@ -194,25 +44,61 @@ export function DialogCustomProvider(props: Props) {
|
||||
}
|
||||
|
||||
const addModel = () => {
|
||||
setForm("models", (v) => [...v, { id: "", name: "" }])
|
||||
setErrors("models", (v) => [...v, {}])
|
||||
setForm(
|
||||
"models",
|
||||
produce((rows) => {
|
||||
rows.push(modelRow())
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const removeModel = (index: number) => {
|
||||
if (form.models.length <= 1) return
|
||||
setForm("models", (v) => v.filter((_, i) => i !== index))
|
||||
setErrors("models", (v) => v.filter((_, i) => i !== index))
|
||||
setForm(
|
||||
"models",
|
||||
produce((rows) => {
|
||||
rows.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const addHeader = () => {
|
||||
setForm("headers", (v) => [...v, { key: "", value: "" }])
|
||||
setErrors("headers", (v) => [...v, {}])
|
||||
setForm(
|
||||
"headers",
|
||||
produce((rows) => {
|
||||
rows.push(headerRow())
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const removeHeader = (index: number) => {
|
||||
if (form.headers.length <= 1) return
|
||||
setForm("headers", (v) => v.filter((_, i) => i !== index))
|
||||
setErrors("headers", (v) => v.filter((_, i) => i !== index))
|
||||
setForm(
|
||||
"headers",
|
||||
produce((rows) => {
|
||||
rows.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => {
|
||||
setForm(key, value)
|
||||
if (key === "apiKey") return
|
||||
setForm("err", key, undefined)
|
||||
}
|
||||
|
||||
const setModel = (index: number, key: "id" | "name", value: string) => {
|
||||
batch(() => {
|
||||
setForm("models", index, key, value)
|
||||
setForm("models", index, "err", key, undefined)
|
||||
})
|
||||
}
|
||||
|
||||
const setHeader = (index: number, key: "key" | "value", value: string) => {
|
||||
batch(() => {
|
||||
setForm("headers", index, key, value)
|
||||
setForm("headers", index, "err", key, undefined)
|
||||
})
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
@@ -222,52 +108,57 @@ export function DialogCustomProvider(props: Props) {
|
||||
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
||||
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
|
||||
})
|
||||
setErrors(output.errors)
|
||||
batch(() => {
|
||||
setForm("err", output.err)
|
||||
output.models.forEach((err, index) => setForm("models", index, "err", err))
|
||||
output.headers.forEach((err, index) => setForm("headers", index, "err", err))
|
||||
})
|
||||
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 (
|
||||
@@ -305,32 +196,32 @@ export function DialogCustomProvider(props: Props) {
|
||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||
description={language.t("provider.custom.field.providerID.description")}
|
||||
value={form.providerID}
|
||||
onChange={(v) => setForm("providerID", v)}
|
||||
validationState={errors.providerID ? "invalid" : undefined}
|
||||
error={errors.providerID}
|
||||
onChange={(v) => setField("providerID", v)}
|
||||
validationState={form.err.providerID ? "invalid" : undefined}
|
||||
error={form.err.providerID}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.name.label")}
|
||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||
value={form.name}
|
||||
onChange={(v) => setForm("name", v)}
|
||||
validationState={errors.name ? "invalid" : undefined}
|
||||
error={errors.name}
|
||||
onChange={(v) => setField("name", v)}
|
||||
validationState={form.err.name ? "invalid" : undefined}
|
||||
error={form.err.name}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.baseURL.label")}
|
||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||
value={form.baseURL}
|
||||
onChange={(v) => setForm("baseURL", v)}
|
||||
validationState={errors.baseURL ? "invalid" : undefined}
|
||||
error={errors.baseURL}
|
||||
onChange={(v) => setField("baseURL", v)}
|
||||
validationState={form.err.baseURL ? "invalid" : undefined}
|
||||
error={form.err.baseURL}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.apiKey.label")}
|
||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||
description={language.t("provider.custom.field.apiKey.description")}
|
||||
value={form.apiKey}
|
||||
onChange={(v) => setForm("apiKey", v)}
|
||||
onChange={(v) => setField("apiKey", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -338,16 +229,16 @@ export function DialogCustomProvider(props: Props) {
|
||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
||||
<For each={form.models}>
|
||||
{(m, i) => (
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex gap-2 items-start" data-row={m.row}>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.models.id.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.models.id.placeholder")}
|
||||
value={m.id}
|
||||
onChange={(v) => setForm("models", i(), "id", v)}
|
||||
validationState={errors.models[i()]?.id ? "invalid" : undefined}
|
||||
error={errors.models[i()]?.id}
|
||||
onChange={(v) => setModel(i(), "id", v)}
|
||||
validationState={m.err.id ? "invalid" : undefined}
|
||||
error={m.err.id}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
@@ -356,9 +247,9 @@ export function DialogCustomProvider(props: Props) {
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.models.name.placeholder")}
|
||||
value={m.name}
|
||||
onChange={(v) => setForm("models", i(), "name", v)}
|
||||
validationState={errors.models[i()]?.name ? "invalid" : undefined}
|
||||
error={errors.models[i()]?.name}
|
||||
onChange={(v) => setModel(i(), "name", v)}
|
||||
validationState={m.err.name ? "invalid" : undefined}
|
||||
error={m.err.name}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
@@ -382,16 +273,16 @@ export function DialogCustomProvider(props: Props) {
|
||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
||||
<For each={form.headers}>
|
||||
{(h, i) => (
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex gap-2 items-start" data-row={h.row}>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.headers.key.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
||||
value={h.key}
|
||||
onChange={(v) => setForm("headers", i(), "key", v)}
|
||||
validationState={errors.headers[i()]?.key ? "invalid" : undefined}
|
||||
error={errors.headers[i()]?.key}
|
||||
onChange={(v) => setHeader(i(), "key", v)}
|
||||
validationState={h.err.key ? "invalid" : undefined}
|
||||
error={h.err.key}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
@@ -400,9 +291,9 @@ export function DialogCustomProvider(props: Props) {
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
||||
value={h.value}
|
||||
onChange={(v) => setForm("headers", i(), "value", v)}
|
||||
validationState={errors.headers[i()]?.value ? "invalid" : undefined}
|
||||
error={errors.headers[i()]?.value}
|
||||
onChange={(v) => setHeader(i(), "value", v)}
|
||||
validationState={h.err.value ? "invalid" : undefined}
|
||||
error={h.err.value}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
@@ -422,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>
|
||||
|
||||
@@ -66,6 +66,7 @@ export const DialogFork: Component = () => {
|
||||
directory: sdk.directory,
|
||||
attachmentName: language.t("common.attachment"),
|
||||
})
|
||||
const dir = base64Encode(sdk.directory)
|
||||
|
||||
sdk.client.session
|
||||
.fork({ sessionID, messageID: item.id })
|
||||
@@ -75,10 +76,8 @@ export const DialogFork: Component = () => {
|
||||
return
|
||||
}
|
||||
dialog.close()
|
||||
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
|
||||
requestAnimationFrame(() => {
|
||||
prompt.set(restored)
|
||||
})
|
||||
prompt.set(restored, undefined, { dir, id: forked.data.id })
|
||||
navigate(`/${dir}/session/${forked.data.id}`)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
@@ -14,6 +14,8 @@ import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
|
||||
@@ -132,9 +134,14 @@ function createFileEntries(props: {
|
||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const tabState = createSessionTabs({
|
||||
tabs: props.tabs,
|
||||
pathFromTab: props.file.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
|
||||
})
|
||||
const recent = createMemo(() => {
|
||||
const all = props.tabs().all()
|
||||
const active = props.tabs().active()
|
||||
const all = tabState.openedTabs()
|
||||
const active = tabState.activeFileTab()
|
||||
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
||||
const seen = new Set<string>()
|
||||
const category = props.language.t("palette.group.files")
|
||||
@@ -259,14 +266,11 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
const layout = useLayout()
|
||||
const file = useFile()
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
const filesOnly = () => props.mode === "files"
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||
const [grouped, setGrouped] = createSignal(false)
|
||||
const commandEntries = createCommandEntries({ filesOnly, command, language })
|
||||
@@ -422,7 +426,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={item.keybind}>
|
||||
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
|
||||
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind>
|
||||
</Show>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -13,8 +13,10 @@ import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export const DialogSelectModelUnpaid: Component = () => {
|
||||
const local = useLocal()
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
|
||||
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
|
||||
const model = props.model ?? useLocal().model
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders()
|
||||
const language = useLanguage()
|
||||
@@ -35,8 +37,8 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||
<List
|
||||
class="[&_[data-slot=list-scroll]]:overflow-visible"
|
||||
ref={(ref) => (listRef = ref)}
|
||||
items={local.model.list}
|
||||
current={local.model.current()}
|
||||
items={model.list}
|
||||
current={model.current()}
|
||||
key={(x) => `${x.provider.id}:${x.id}`}
|
||||
itemWrapper={(item, node) => (
|
||||
<Tooltip
|
||||
@@ -55,7 +57,7 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
onSelect={(x) => {
|
||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||
recent: true,
|
||||
})
|
||||
dialog.close()
|
||||
|
||||
@@ -18,19 +18,22 @@ import { useLanguage } from "@/context/language"
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
|
||||
const ModelList: Component<{
|
||||
provider?: string
|
||||
class?: string
|
||||
onSelect: () => void
|
||||
action?: JSX.Element
|
||||
model?: ModelState
|
||||
}> = (props) => {
|
||||
const local = useLocal()
|
||||
const model = props.model ?? useLocal().model
|
||||
const language = useLanguage()
|
||||
|
||||
const models = createMemo(() =>
|
||||
local.model
|
||||
model
|
||||
.list()
|
||||
.filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id }))
|
||||
.filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id }))
|
||||
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
|
||||
)
|
||||
|
||||
@@ -41,7 +44,7 @@ const ModelList: Component<{
|
||||
emptyMessage={language.t("dialog.model.empty")}
|
||||
key={(x) => `${x.provider.id}:${x.id}`}
|
||||
items={models}
|
||||
current={local.model.current()}
|
||||
current={model.current()}
|
||||
filterKeys={["provider.name", "name", "id"]}
|
||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||
groupBy={(x) => x.provider.name}
|
||||
@@ -63,7 +66,7 @@ const ModelList: Component<{
|
||||
</Tooltip>
|
||||
)}
|
||||
onSelect={(x) => {
|
||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||
recent: true,
|
||||
})
|
||||
props.onSelect()
|
||||
@@ -88,6 +91,7 @@ type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "a
|
||||
|
||||
export function ModelSelectorPopover(props: {
|
||||
provider?: string
|
||||
model?: ModelState
|
||||
children?: JSX.Element
|
||||
triggerAs?: ValidComponent
|
||||
triggerProps?: ModelSelectorTriggerProps
|
||||
@@ -151,6 +155,7 @@ export function ModelSelectorPopover(props: {
|
||||
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
|
||||
<ModelList
|
||||
provider={props.provider}
|
||||
model={props.model}
|
||||
onSelect={() => setStore("open", false)}
|
||||
class="p-1"
|
||||
action={
|
||||
@@ -184,7 +189,7 @@ export function ModelSelectorPopover(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
||||
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
@@ -202,7 +207,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ModelList provider={props.provider} onSelect={() => dialog.close()} />
|
||||
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="ml-3 mt-5 mb-6 text-text-base self-start"
|
||||
|
||||
@@ -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"
|
||||
@@ -14,7 +15,9 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||
|
||||
const DEFAULT_USERNAME = "opencode"
|
||||
|
||||
interface ServerFormProps {
|
||||
value: string
|
||||
@@ -41,13 +44,15 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
|
||||
})
|
||||
}
|
||||
|
||||
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
|
||||
const [defaultUrl, defaultUrlActions] = createResource(
|
||||
function useDefaultServer() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [defaultKey, defaultUrlActions] = createResource(
|
||||
async () => {
|
||||
try {
|
||||
const url = await platform.getDefaultServerUrl?.()
|
||||
if (!url) return null
|
||||
return normalizeServerUrl(url) ?? null
|
||||
const key = await platform.getDefaultServer?.()
|
||||
if (!key) return null
|
||||
return key
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
return null
|
||||
@@ -56,20 +61,22 @@ function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: Re
|
||||
{ initialValue: null },
|
||||
)
|
||||
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
||||
const setDefault = async (url: string | null) => {
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
||||
const setDefault = async (key: ServerConnection.Key | null) => {
|
||||
try {
|
||||
await platform.setDefaultServerUrl?.(url)
|
||||
defaultUrlActions.mutate(url)
|
||||
await platform.setDefaultServer?.(key)
|
||||
defaultUrlActions.mutate(key)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { defaultUrl, canDefault, setDefault }
|
||||
return { defaultKey, canDefault, setDefault }
|
||||
}
|
||||
|
||||
function useServerPreview(fetcher: typeof fetch) {
|
||||
function useServerPreview() {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
const looksComplete = (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return false
|
||||
@@ -92,7 +99,7 @@ function useServerPreview(fetcher: typeof fetch) {
|
||||
const http: ServerConnection.HttpBase = { url: normalized }
|
||||
if (username) http.username = username
|
||||
if (password) http.password = password
|
||||
const result = await checkServerHealth(http, fetcher)
|
||||
const result = await checkServerHealth(http)
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
@@ -115,7 +122,7 @@ function ServerForm(props: ServerFormProps) {
|
||||
|
||||
return (
|
||||
<div class="px-5">
|
||||
<div class="bg-surface-raised-base rounded-md p-5 flex flex-col gap-3">
|
||||
<div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
|
||||
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
|
||||
<TextField
|
||||
type="text"
|
||||
@@ -143,7 +150,7 @@ function ServerForm(props: ServerFormProps) {
|
||||
<TextField
|
||||
type="text"
|
||||
label={language.t("dialog.server.add.username")}
|
||||
placeholder="username"
|
||||
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
||||
value={props.username}
|
||||
disabled={props.busy}
|
||||
onChange={props.onUsernameChange}
|
||||
@@ -152,7 +159,7 @@ function ServerForm(props: ServerFormProps) {
|
||||
<TextField
|
||||
type="password"
|
||||
label={language.t("dialog.server.add.password")}
|
||||
placeholder="password"
|
||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||
value={props.password}
|
||||
disabled={props.busy}
|
||||
onChange={props.onPasswordChange}
|
||||
@@ -170,17 +177,16 @@ export function DialogSelectServer() {
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
||||
const { previewStatus } = useServerPreview(fetcher)
|
||||
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||
const { previewStatus } = useServerPreview()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [store, setStore] = createStore({
|
||||
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
addServer: {
|
||||
url: "",
|
||||
name: "",
|
||||
username: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined as boolean | undefined,
|
||||
@@ -192,7 +198,6 @@ export function DialogSelectServer() {
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
busy: false,
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
})
|
||||
@@ -201,9 +206,8 @@ export function DialogSelectServer() {
|
||||
setStore("addServer", {
|
||||
url: "",
|
||||
name: "",
|
||||
username: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined,
|
||||
@@ -218,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)
|
||||
@@ -264,7 +336,7 @@ export function DialogSelectServer() {
|
||||
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
items().map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
}),
|
||||
)
|
||||
setStore("status", reconcile(results))
|
||||
@@ -285,12 +357,12 @@ export function DialogSelectServer() {
|
||||
navigate("/")
|
||||
return
|
||||
}
|
||||
server.setActive(ServerConnection.key(conn))
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
|
||||
}
|
||||
|
||||
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 }),
|
||||
@@ -298,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 }),
|
||||
@@ -311,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 }),
|
||||
@@ -319,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 }),
|
||||
@@ -327,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 }),
|
||||
@@ -340,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.username) conn.http.username = store.addServer.username
|
||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||
const result = await checkServerHealth(conn.http, fetcher)
|
||||
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, fetcher)
|
||||
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"
|
||||
@@ -441,7 +441,7 @@ export function DialogSelectServer() {
|
||||
showForm: true,
|
||||
url: "",
|
||||
name: "",
|
||||
username: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
@@ -458,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")
|
||||
@@ -494,8 +497,8 @@ export function DialogSelectServer() {
|
||||
|
||||
async function handleRemove(url: ServerConnection.Key) {
|
||||
server.remove(url)
|
||||
if ((await platform.getDefaultServerUrl?.()) === url) {
|
||||
platform.setDefaultServerUrl?.(null)
|
||||
if ((await platform.getDefaultServer?.()) === url) {
|
||||
platform.setDefaultServer?.(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,7 +539,7 @@ export function DialogSelectServer() {
|
||||
if (x) select(x)
|
||||
}}
|
||||
divider={true}
|
||||
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
|
||||
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
|
||||
>
|
||||
{(i) => {
|
||||
const key = ServerConnection.key(i)
|
||||
@@ -551,7 +554,7 @@ export function DialogSelectServer() {
|
||||
status={store.status[key]}
|
||||
class="flex items-center gap-3 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={defaultUrl() === i.http.url}>
|
||||
<Show when={defaultKey() === ServerConnection.key(i)}>
|
||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
@@ -584,14 +587,14 @@ export function DialogSelectServer() {
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={canDefault() && defaultUrl() !== i.http.url}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
|
||||
<Show when={canDefault() && defaultKey() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(key)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={canDefault() && defaultUrl() === i.http.url}>
|
||||
<Show when={canDefault() && defaultKey() === key}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
||||
import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||
import {
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
} from "@/context/prompt"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -27,7 +25,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { RadioGroup } from "@opencode-ai/ui/radio-group"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
@@ -37,8 +34,12 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { promptEnabled, promptProbe } from "@/testing/prompt"
|
||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
|
||||
import { createPromptAttachments } from "./prompt-input/attachments"
|
||||
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
|
||||
import {
|
||||
canNavigateHistoryAtCursor,
|
||||
navigatePromptHistory,
|
||||
@@ -48,7 +49,7 @@ import {
|
||||
type PromptHistoryStoredEntry,
|
||||
promptLength,
|
||||
} from "./prompt-input/history"
|
||||
import { createPromptSubmit } from "./prompt-input/submit"
|
||||
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
|
||||
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
||||
import { PromptContextItems } from "./prompt-input/context-items"
|
||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||
@@ -61,6 +62,11 @@ interface PromptInputProps {
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
newSessionWorktree?: string
|
||||
onNewSessionWorktreeReset?: () => void
|
||||
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
|
||||
onEditLoaded?: () => void
|
||||
shouldQueue?: () => boolean
|
||||
onQueue?: (draft: FollowupDraft) => void
|
||||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
}
|
||||
|
||||
@@ -102,20 +108,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const prompt = usePrompt()
|
||||
const layout = useLayout()
|
||||
const comments = useComments()
|
||||
const params = useParams()
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders()
|
||||
const command = useCommand()
|
||||
const permission = usePermission()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
let editorRef!: HTMLDivElement
|
||||
let fileInputRef: HTMLInputElement | undefined
|
||||
let scrollRef!: HTMLDivElement
|
||||
let slashPopoverRef!: HTMLDivElement
|
||||
|
||||
const mirror = { input: false }
|
||||
const inset = 44
|
||||
const inset = 56
|
||||
const space = `${inset}px`
|
||||
|
||||
const scrollCursorIntoView = () => {
|
||||
const container = scrollRef
|
||||
@@ -150,13 +157,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const queueScroll = () => {
|
||||
requestAnimationFrame(scrollCursorIntoView)
|
||||
const queueScroll = (count = 2) => {
|
||||
requestAnimationFrame(() => {
|
||||
scrollCursorIntoView()
|
||||
if (count > 1) queueScroll(count - 1)
|
||||
})
|
||||
}
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const activeFileTab = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: files.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
|
||||
}).activeFileTab
|
||||
|
||||
const commentInReview = (path: string) => {
|
||||
const sessionID = params.id
|
||||
@@ -209,7 +221,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const recent = createMemo(() => {
|
||||
const all = tabs().all()
|
||||
const active = tabs().active()
|
||||
const active = activeFileTab()
|
||||
const order = active ? [active, ...all.filter((x) => x !== active)] : all
|
||||
const seen = new Set<string>()
|
||||
const paths: string[] = []
|
||||
@@ -232,6 +244,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
},
|
||||
)
|
||||
const working = createMemo(() => status()?.type !== "idle")
|
||||
const tip = () => {
|
||||
if (working()) {
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.stop")}</span>
|
||||
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.send")}</span>
|
||||
<Icon name="enter" size="small" class="text-icon-base" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const imageAttachments = createMemo(() =>
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
)
|
||||
@@ -255,6 +284,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
|
||||
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||
const motion = (value: number) => ({
|
||||
opacity: value,
|
||||
transform: `scale(${0.95 + value * 0.05})`,
|
||||
filter: `blur(${(1 - value) * 2}px)`,
|
||||
"pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const),
|
||||
})
|
||||
const buttons = createMemo(() => motion(buttonsSpring()))
|
||||
const shell = createMemo(() => motion(1 - buttonsSpring()))
|
||||
const control = createMemo(() => ({ height: "28px", ...buttons() }))
|
||||
|
||||
const commentCount = createMemo(() => {
|
||||
if (store.mode === "shell") return 0
|
||||
@@ -390,7 +428,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const isFocused = createFocusSignal(() => editorRef)
|
||||
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
|
||||
|
||||
const pick = () => fileInputRef?.click()
|
||||
@@ -490,6 +527,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setComposing(false)
|
||||
}
|
||||
|
||||
const handleCompositionStart = () => {
|
||||
setComposing(true)
|
||||
}
|
||||
|
||||
const handleCompositionEnd = () => {
|
||||
setComposing(false)
|
||||
requestAnimationFrame(() => {
|
||||
if (composing()) return
|
||||
reconcile(prompt.current().filter((part) => part.type !== "image"))
|
||||
})
|
||||
}
|
||||
|
||||
const agentList = createMemo(() =>
|
||||
sync.data.agent
|
||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||
@@ -573,6 +622,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
||||
if (!cmd) return
|
||||
promptProbe.select(cmd.id)
|
||||
closePopover()
|
||||
|
||||
if (cmd.type === "custom") {
|
||||
@@ -661,6 +711,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
})
|
||||
|
||||
if (promptEnabled()) {
|
||||
createEffect(() => {
|
||||
promptProbe.set({
|
||||
popover: store.popover,
|
||||
slash: {
|
||||
active: slashActive() ?? null,
|
||||
ids: slashFlat().map((cmd) => cmd.id),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => promptProbe.clear())
|
||||
}
|
||||
|
||||
const selectPopoverActive = () => {
|
||||
if (store.popover === "at") {
|
||||
const items = atFlat()
|
||||
@@ -680,24 +744,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const reconcile = (input: Prompt) => {
|
||||
if (mirror.input) {
|
||||
mirror.input = false
|
||||
if (isNormalizedEditor()) return
|
||||
|
||||
renderEditorWithCursor(input)
|
||||
return
|
||||
}
|
||||
|
||||
const dom = parseFromDOM()
|
||||
if (isNormalizedEditor() && isPromptEqual(input, dom)) return
|
||||
|
||||
renderEditorWithCursor(input)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => prompt.current(),
|
||||
(currentParts) => {
|
||||
const inputParts = currentParts.filter((part) => part.type !== "image")
|
||||
|
||||
if (mirror.input) {
|
||||
mirror.input = false
|
||||
if (isNormalizedEditor()) return
|
||||
|
||||
renderEditorWithCursor(inputParts)
|
||||
return
|
||||
}
|
||||
|
||||
const domParts = parseFromDOM()
|
||||
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
|
||||
|
||||
renderEditorWithCursor(inputParts)
|
||||
(parts) => {
|
||||
if (composing()) return
|
||||
reconcile(parts.filter((part) => part.type !== "image"))
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -921,6 +988,45 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setCurrentHistory("entries", next)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.edit?.id,
|
||||
(id) => {
|
||||
const edit = props.edit
|
||||
if (!id || !edit) return
|
||||
|
||||
for (const item of prompt.context.items()) {
|
||||
prompt.context.remove(item.key)
|
||||
}
|
||||
|
||||
for (const item of edit.context) {
|
||||
prompt.context.add({
|
||||
type: item.type,
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
comment: item.comment,
|
||||
commentID: item.commentID,
|
||||
commentOrigin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
})
|
||||
}
|
||||
|
||||
setStore("mode", "normal")
|
||||
setStore("popover", null)
|
||||
setStore("historyIndex", -1)
|
||||
setStore("savedPrompt", null)
|
||||
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||
requestAnimationFrame(() => {
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, promptLength(edit.prompt))
|
||||
queueScroll()
|
||||
})
|
||||
props.onEditLoaded?.()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const navigateHistory = (direction: "up" | "down") => {
|
||||
const result = navigatePromptHistory({
|
||||
direction,
|
||||
@@ -937,9 +1043,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({
|
||||
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
editor: () => editorRef,
|
||||
isFocused,
|
||||
isDialogActive: () => !!dialog.active,
|
||||
setDraggingType: (type) => setStore("draggingType", type),
|
||||
focusEditor: () => {
|
||||
@@ -956,6 +1061,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
return permission.isAutoAccepting(id, sdk.directory)
|
||||
})
|
||||
const acceptLabel = createMemo(() =>
|
||||
language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"),
|
||||
)
|
||||
const toggleAccept = () => {
|
||||
if (!params.id) {
|
||||
permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||
return
|
||||
}
|
||||
|
||||
permission.toggleAutoAccept(params.id, sdk.directory)
|
||||
}
|
||||
|
||||
const { abort, handleSubmit } = createPromptSubmit({
|
||||
info,
|
||||
@@ -975,6 +1091,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setPopover: (popover) => setStore("popover", popover),
|
||||
newSessionWorktree: () => props.newSessionWorktree,
|
||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||
shouldQueue: props.shouldQueue,
|
||||
onQueue: props.onQueue,
|
||||
onAbort: props.onAbort,
|
||||
onSubmit: props.onSubmit,
|
||||
})
|
||||
|
||||
@@ -1122,6 +1241,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
// Note: Shift+Enter is handled earlier, before IME check
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
if (event.repeat) return
|
||||
if (
|
||||
working() &&
|
||||
prompt
|
||||
.current()
|
||||
.map((part) => ("content" in part ? part.content : ""))
|
||||
.join("")
|
||||
.trim().length === 0 &&
|
||||
imageAttachments().length === 0 &&
|
||||
commentCount() === 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
handleSubmit(event)
|
||||
}
|
||||
}
|
||||
@@ -1174,7 +1307,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onOpen={(attachment) =>
|
||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
||||
}
|
||||
onRemove={removeImageAttachment}
|
||||
onRemove={removeAttachment}
|
||||
removeLabel={language.t("prompt.attachment.remove")}
|
||||
/>
|
||||
<div
|
||||
@@ -1192,7 +1325,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
editorRef?.focus()
|
||||
}}
|
||||
>
|
||||
<div class="relative max-h-[240px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
|
||||
<div
|
||||
class="relative max-h-[240px] overflow-y-auto no-scrollbar"
|
||||
ref={(el) => (scrollRef = el)}
|
||||
style={{ "scroll-padding-bottom": space }}
|
||||
>
|
||||
<div
|
||||
data-component="prompt-input"
|
||||
ref={(el) => {
|
||||
@@ -1208,44 +1345,75 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
spellcheck={store.mode === "normal"}
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
onCompositionEnd={() => setComposing(false)}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"select-text": true,
|
||||
"w-full pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||
"w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||
"[&_[data-type=file]]:text-syntax-property": true,
|
||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
}}
|
||||
style={{ "padding-bottom": space }}
|
||||
/>
|
||||
<Show when={!prompt.dirty()}>
|
||||
<div
|
||||
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
||||
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
||||
classList={{ "font-mono!": store.mode === "shell" }}
|
||||
style={{ "padding-bottom": space }}
|
||||
>
|
||||
{placeholder()}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0"
|
||||
style={{
|
||||
height: space,
|
||||
background:
|
||||
"linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPTED_FILE_TYPES.join(",")}
|
||||
class="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.currentTarget.files?.[0]
|
||||
if (file) addImageAttachment(file)
|
||||
const list = e.currentTarget.files
|
||||
if (list) void addAttachments(Array.from(list))
|
||||
e.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-1 pointer-events-auto">
|
||||
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
style={buttons()}
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute bottom-2 left-2">
|
||||
<div
|
||||
aria-hidden={store.mode !== "normal"}
|
||||
class="flex items-center gap-1"
|
||||
class="pointer-events-auto"
|
||||
style={{
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
@@ -1260,11 +1428,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="size-8 p-0"
|
||||
style={{
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
}}
|
||||
style={buttons()}
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
@@ -1273,85 +1437,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Icon name="plus" class="size-4.5" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
<Tooltip
|
||||
placement="top"
|
||||
inactive={!prompt.dirty() && !working()}
|
||||
value={
|
||||
<Switch>
|
||||
<Match when={working()}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.stop")}</span>
|
||||
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.send")}</span>
|
||||
<Icon name="enter" size="small" class="text-icon-base" />
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
style={{
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
}}
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute bottom-2 left-2">
|
||||
<div class="pointer-events-auto">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={8}
|
||||
title={language.t(
|
||||
accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable",
|
||||
)}
|
||||
keybind={command.keybind("permissions.autoaccept")}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-permissions"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (!params.id) {
|
||||
permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||
return
|
||||
}
|
||||
permission.toggleAutoAccept(params.id, sdk.directory)
|
||||
}}
|
||||
classList={{
|
||||
"size-6 flex items-center justify-center": true,
|
||||
"text-text-base": !accepting(),
|
||||
"hover:bg-surface-success-base": accepting(),
|
||||
}}
|
||||
aria-label={
|
||||
accepting()
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable")
|
||||
}
|
||||
aria-pressed={accepting()}
|
||||
>
|
||||
<Icon
|
||||
name="chevron-double-right"
|
||||
size="small"
|
||||
classList={{ "text-icon-success-base": accepting() }}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1364,61 +1449,83 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
|
||||
style={{
|
||||
padding: "0 4px 0 8px",
|
||||
opacity: 1 - buttonsSpring(),
|
||||
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
|
||||
filter: `blur(${buttonsSpring() * 2}px)`,
|
||||
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
|
||||
...shell(),
|
||||
}}
|
||||
>
|
||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||
<div class="size-4 shrink-0" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.agent.cycle")}
|
||||
keybind={command.keybind("agent.cycle")}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={agentNames()}
|
||||
current={local.agent.current()?.name ?? ""}
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
<Show
|
||||
when={providers.paid().length > 0}
|
||||
fallback={
|
||||
<div data-component="prompt-agent-control">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.agent.cycle")}
|
||||
keybind={command.keybind("agent.cycle")}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={agentNames()}
|
||||
current={local.agent.current()?.name ?? ""}
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
valueClass="truncate text-13-regular text-text-base"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-agent" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div data-component="prompt-model-control">
|
||||
<Show
|
||||
when={providers.paid().length > 0}
|
||||
fallback={
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||
style={control()}
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid model={local.model} />)}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
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)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<Button
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular group"
|
||||
style={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
<ModelSelectorPopover
|
||||
model={local.model}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: control(),
|
||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
@@ -1431,100 +1538,54 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</Button>
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-component="prompt-variant-control">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
title={language.t("command.model.variant.cycle")}
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: {
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
},
|
||||
class: "min-w-0 max-w-[320px] text-13-regular group",
|
||||
}}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
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)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.variant.cycle")}
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={local.model.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<RadioGroup
|
||||
options={["shell", "normal"] as const}
|
||||
current={store.mode}
|
||||
value={(mode) => mode}
|
||||
label={(mode) => (
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
openDelay={2000}
|
||||
title={language.t(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
|
||||
keybind={command.keybind(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
|
||||
class="size-full flex items-center justify-center"
|
||||
>
|
||||
<Icon
|
||||
name={mode === "shell" ? "console" : "prompt"}
|
||||
class="size-[18px]"
|
||||
classList={{
|
||||
"text-icon-strong-base": store.mode === mode,
|
||||
"text-icon-weak": store.mode !== mode,
|
||||
}}
|
||||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={local.model.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
valueClass="truncate text-13-regular text-text-base"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
)}
|
||||
onSelect={(mode) => mode && setMode(mode)}
|
||||
fill
|
||||
pad="none"
|
||||
class="w-[68px]"
|
||||
/>
|
||||
</div>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={8}
|
||||
title={acceptLabel()}
|
||||
keybind={command.keybind("permissions.autoaccept")}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-permissions"
|
||||
variant="ghost"
|
||||
onClick={toggleAccept}
|
||||
classList={{
|
||||
"h-7 w-7 p-0 shrink-0 flex items-center justify-center": true,
|
||||
"text-text-base": !accepting(),
|
||||
"hover:bg-surface-success-base": accepting(),
|
||||
}}
|
||||
style={control()}
|
||||
aria-label={acceptLabel()}
|
||||
aria-pressed={accepting()}
|
||||
>
|
||||
<Icon name="shield" size="small" classList={{ "text-icon-success-base": accepting() }} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DockTray>
|
||||
|
||||
61
packages/app/src/components/prompt-input/attachments.test.ts
Normal file
61
packages/app/src/components/prompt-input/attachments.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { attachmentMime } from "./files"
|
||||
import { MAX_ATTACHMENT_BYTES, estimateAttachment, totalAttachments, wouldExceedAttachmentLimit } from "./limit"
|
||||
import { pasteMode } from "./paste"
|
||||
|
||||
describe("attachmentMime", () => {
|
||||
test("keeps PDFs when the browser reports the mime", async () => {
|
||||
const file = new File(["%PDF-1.7"], "guide.pdf", { type: "application/pdf" })
|
||||
expect(await attachmentMime(file)).toBe("application/pdf")
|
||||
})
|
||||
|
||||
test("normalizes structured text types to text/plain", async () => {
|
||||
const file = new File(['{"ok":true}\n'], "data.json", { type: "application/json" })
|
||||
expect(await attachmentMime(file)).toBe("text/plain")
|
||||
})
|
||||
|
||||
test("accepts text files even with a misleading browser mime", async () => {
|
||||
const file = new File(["export const x = 1\n"], "main.ts", { type: "video/mp2t" })
|
||||
expect(await attachmentMime(file)).toBe("text/plain")
|
||||
})
|
||||
|
||||
test("rejects binary files", async () => {
|
||||
const file = new File([Uint8Array.of(0, 255, 1, 2)], "blob.bin", { type: "application/octet-stream" })
|
||||
expect(await attachmentMime(file)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("pasteMode", () => {
|
||||
test("uses native paste for short single-line text", () => {
|
||||
expect(pasteMode("hello world")).toBe("native")
|
||||
})
|
||||
|
||||
test("uses manual paste for multiline text", () => {
|
||||
expect(
|
||||
pasteMode(`{
|
||||
"ok": true
|
||||
}`),
|
||||
).toBe("manual")
|
||||
expect(pasteMode("a\r\nb")).toBe("manual")
|
||||
})
|
||||
|
||||
test("uses manual paste for large text", () => {
|
||||
expect(pasteMode("x".repeat(8000))).toBe("manual")
|
||||
})
|
||||
})
|
||||
|
||||
describe("attachment limit", () => {
|
||||
test("estimates encoded attachment size", () => {
|
||||
expect(estimateAttachment({ size: 3 }, "image/png")).toBe("data:image/png;base64,".length + 4)
|
||||
})
|
||||
|
||||
test("totals current attachments", () => {
|
||||
expect(totalAttachments([{ dataUrl: "abc" }, { dataUrl: "de" }])).toBe(5)
|
||||
})
|
||||
|
||||
test("flags uploads that exceed the total limit", () => {
|
||||
const list = [{ dataUrl: "a".repeat(MAX_ATTACHMENT_BYTES - 4) }]
|
||||
expect(wouldExceedAttachmentLimit(list, { size: 3 }, "image/png")).toBe(true)
|
||||
expect(wouldExceedAttachmentLimit([], { size: 3 }, "image/png")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -4,26 +4,29 @@ import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import { getCursorPosition } from "./editor-dom"
|
||||
import { attachmentMime } from "./files"
|
||||
import { wouldExceedAttachmentLimit } from "./limit"
|
||||
import { normalizePaste, pasteMode } from "./paste"
|
||||
|
||||
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
||||
const LARGE_PASTE_CHARS = 8000
|
||||
const LARGE_PASTE_BREAKS = 120
|
||||
|
||||
function largePaste(text: string) {
|
||||
if (text.length >= LARGE_PASTE_CHARS) return true
|
||||
let breaks = 0
|
||||
for (const char of text) {
|
||||
if (char !== "\n") continue
|
||||
breaks += 1
|
||||
if (breaks >= LARGE_PASTE_BREAKS) return true
|
||||
}
|
||||
return false
|
||||
function dataUrl(file: File, mime: string) {
|
||||
return new Promise<string>((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener("error", () => resolve(""))
|
||||
reader.addEventListener("load", () => {
|
||||
const value = typeof reader.result === "string" ? reader.result : ""
|
||||
const idx = value.indexOf(",")
|
||||
if (idx === -1) {
|
||||
resolve(value)
|
||||
return
|
||||
}
|
||||
resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
|
||||
})
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
type PromptAttachmentsInput = {
|
||||
editor: () => HTMLDivElement | undefined
|
||||
isFocused: () => boolean
|
||||
isDialogActive: () => boolean
|
||||
setDraggingType: (type: "image" | "@mention" | null) => void
|
||||
focusEditor: () => void
|
||||
@@ -31,62 +34,91 @@ type PromptAttachmentsInput = {
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
}
|
||||
|
||||
type AddState = "added" | "failed" | "unsupported" | "limit"
|
||||
|
||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const prompt = usePrompt()
|
||||
const language = useLanguage()
|
||||
|
||||
const addImageAttachment = async (file: File) => {
|
||||
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
const dataUrl = reader.result as string
|
||||
const attachment: ImageAttachmentPart = {
|
||||
type: "image",
|
||||
id: uuid(),
|
||||
filename: file.name,
|
||||
mime: file.type,
|
||||
dataUrl,
|
||||
}
|
||||
const cursorPosition = prompt.cursor() ?? getCursorPosition(editor)
|
||||
prompt.set([...prompt.current(), attachment], cursorPosition)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
const warn = () => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||
})
|
||||
}
|
||||
|
||||
const removeImageAttachment = (id: string) => {
|
||||
const warnLimit = () => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.attachmentLimit.title"),
|
||||
description: language.t("prompt.toast.attachmentLimit.description"),
|
||||
})
|
||||
}
|
||||
|
||||
const add = async (file: File): Promise<AddState> => {
|
||||
const mime = await attachmentMime(file)
|
||||
if (!mime) {
|
||||
return "unsupported" as const
|
||||
}
|
||||
|
||||
const editor = input.editor()
|
||||
if (!editor) return "failed" as const
|
||||
|
||||
const images = prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
if (wouldExceedAttachmentLimit(images, file, mime)) return "limit" as const
|
||||
|
||||
const url = await dataUrl(file, mime)
|
||||
if (!url) return "failed" as const
|
||||
|
||||
const attachment: ImageAttachmentPart = {
|
||||
type: "image",
|
||||
id: uuid(),
|
||||
filename: file.name,
|
||||
mime,
|
||||
dataUrl: url,
|
||||
}
|
||||
const cursor = prompt.cursor() ?? getCursorPosition(editor)
|
||||
prompt.set([...prompt.current(), attachment], cursor)
|
||||
return "added" as const
|
||||
}
|
||||
|
||||
const addAttachments = async (list: File[]) => {
|
||||
const result = { added: false, unsupported: false }
|
||||
for (const file of list) {
|
||||
const state = await add(file)
|
||||
if (state === "limit") {
|
||||
warnLimit()
|
||||
return result.added
|
||||
}
|
||||
result.added = result.added || state === "added"
|
||||
result.unsupported = result.unsupported || state === "unsupported"
|
||||
}
|
||||
if (!result.added && result.unsupported) warn()
|
||||
return result.added
|
||||
}
|
||||
|
||||
const addAttachment = (file: File) => addAttachments([file])
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
const current = prompt.current()
|
||||
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||
prompt.set(next, prompt.cursor())
|
||||
}
|
||||
|
||||
const handlePaste = async (event: ClipboardEvent) => {
|
||||
if (!input.isFocused()) return
|
||||
const clipboardData = event.clipboardData
|
||||
if (!clipboardData) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const items = Array.from(clipboardData.items)
|
||||
const fileItems = items.filter((item) => item.kind === "file")
|
||||
const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
|
||||
const files = Array.from(clipboardData.items).flatMap((item) => {
|
||||
if (item.kind !== "file") return []
|
||||
const file = item.getAsFile()
|
||||
return file ? [file] : []
|
||||
})
|
||||
|
||||
if (imageItems.length > 0) {
|
||||
for (const item of imageItems) {
|
||||
const file = item.getAsFile()
|
||||
if (file) await addImageAttachment(file)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (fileItems.length > 0) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||
})
|
||||
if (files.length > 0) {
|
||||
await addAttachments(files)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -96,23 +128,30 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
if (input.readClipboardImage && !plainText) {
|
||||
const file = await input.readClipboardImage()
|
||||
if (file) {
|
||||
await addImageAttachment(file)
|
||||
await addAttachment(file)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!plainText) return
|
||||
|
||||
if (largePaste(plainText)) {
|
||||
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
|
||||
const text = normalizePaste(plainText)
|
||||
|
||||
const put = () => {
|
||||
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
|
||||
input.focusEditor()
|
||||
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
|
||||
return input.addPart({ type: "text", content: text, start: 0, end: 0 })
|
||||
}
|
||||
|
||||
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
|
||||
if (pasteMode(text) === "manual") {
|
||||
put()
|
||||
return
|
||||
}
|
||||
|
||||
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text)
|
||||
if (inserted) return
|
||||
|
||||
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
||||
put()
|
||||
}
|
||||
|
||||
const handleGlobalDragOver = (event: DragEvent) => {
|
||||
@@ -153,11 +192,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const dropped = event.dataTransfer?.files
|
||||
if (!dropped) return
|
||||
|
||||
for (const file of Array.from(dropped)) {
|
||||
if (ACCEPTED_FILE_TYPES.includes(file.type)) {
|
||||
await addImageAttachment(file)
|
||||
}
|
||||
}
|
||||
await addAttachments(Array.from(dropped))
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -173,8 +208,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
})
|
||||
|
||||
return {
|
||||
addImageAttachment,
|
||||
removeImageAttachment,
|
||||
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 }]
|
||||
|
||||
|
||||
66
packages/app/src/components/prompt-input/files.ts
Normal file
66
packages/app/src/components/prompt-input/files.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker"
|
||||
|
||||
export { ACCEPTED_FILE_TYPES }
|
||||
|
||||
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
|
||||
const IMAGE_EXTS = new Map([
|
||||
["gif", "image/gif"],
|
||||
["jpeg", "image/jpeg"],
|
||||
["jpg", "image/jpeg"],
|
||||
["png", "image/png"],
|
||||
["webp", "image/webp"],
|
||||
])
|
||||
const TEXT_MIMES = new Set([
|
||||
"application/json",
|
||||
"application/ld+json",
|
||||
"application/toml",
|
||||
"application/x-toml",
|
||||
"application/x-yaml",
|
||||
"application/xml",
|
||||
"application/yaml",
|
||||
])
|
||||
|
||||
const SAMPLE = 4096
|
||||
|
||||
function kind(type: string) {
|
||||
return type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
||||
}
|
||||
|
||||
function ext(name: string) {
|
||||
const idx = name.lastIndexOf(".")
|
||||
if (idx === -1) return ""
|
||||
return name.slice(idx + 1).toLowerCase()
|
||||
}
|
||||
|
||||
function textMime(type: string) {
|
||||
if (!type) return false
|
||||
if (type.startsWith("text/")) return true
|
||||
if (TEXT_MIMES.has(type)) return true
|
||||
if (type.endsWith("+json")) return true
|
||||
return type.endsWith("+xml")
|
||||
}
|
||||
|
||||
function textBytes(bytes: Uint8Array) {
|
||||
if (bytes.length === 0) return true
|
||||
let count = 0
|
||||
for (const byte of bytes) {
|
||||
if (byte === 0) return false
|
||||
if (byte < 9 || (byte > 13 && byte < 32)) count += 1
|
||||
}
|
||||
return count / bytes.length <= 0.3
|
||||
}
|
||||
|
||||
export async function attachmentMime(file: File) {
|
||||
const type = kind(file.type)
|
||||
if (IMAGE_MIMES.has(type)) return type
|
||||
if (type === "application/pdf") return type
|
||||
|
||||
const suffix = ext(file.name)
|
||||
const fallback = IMAGE_EXTS.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined)
|
||||
if ((!type || type === "application/octet-stream") && fallback) return fallback
|
||||
|
||||
if (textMime(type)) return "text/plain"
|
||||
const bytes = new Uint8Array(await file.slice(0, SAMPLE).arrayBuffer())
|
||||
if (!textBytes(bytes)) return
|
||||
return "text/plain"
|
||||
}
|
||||
15
packages/app/src/components/prompt-input/limit.ts
Normal file
15
packages/app/src/components/prompt-input/limit.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
const MB = 1024 * 1024
|
||||
|
||||
export const MAX_ATTACHMENT_BYTES = 5 * MB
|
||||
|
||||
export function estimateAttachment(file: { size: number }, mime: string) {
|
||||
return `data:${mime};base64,`.length + Math.ceil(file.size / 3) * 4
|
||||
}
|
||||
|
||||
export function totalAttachments(list: Array<{ dataUrl: string }>) {
|
||||
return list.reduce((sum, part) => sum + part.dataUrl.length, 0)
|
||||
}
|
||||
|
||||
export function wouldExceedAttachmentLimit(list: Array<{ dataUrl: string }>, file: { size: number }, mime: string) {
|
||||
return totalAttachments(list) + estimateAttachment(file, mime) > MAX_ATTACHMENT_BYTES
|
||||
}
|
||||
24
packages/app/src/components/prompt-input/paste.ts
Normal file
24
packages/app/src/components/prompt-input/paste.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
const LARGE_PASTE_CHARS = 8000
|
||||
const LARGE_PASTE_BREAKS = 120
|
||||
|
||||
function largePaste(text: string) {
|
||||
if (text.length >= LARGE_PASTE_CHARS) return true
|
||||
let breaks = 0
|
||||
for (const char of text) {
|
||||
if (char !== "\n") continue
|
||||
breaks += 1
|
||||
if (breaks >= LARGE_PASTE_BREAKS) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function normalizePaste(text: string) {
|
||||
if (!text.includes("\r")) return text
|
||||
return text.replace(/\r\n?/g, "\n")
|
||||
}
|
||||
|
||||
export function pasteMode(text: string) {
|
||||
if (largePaste(text)) return "manual"
|
||||
if (text.includes("\n") || text.includes("\r")) return "manual"
|
||||
return "native"
|
||||
}
|
||||
@@ -7,12 +7,17 @@ const createdClients: string[] = []
|
||||
const createdSessions: string[] = []
|
||||
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
|
||||
const optimistic: Array<{
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
message: {
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}
|
||||
}> = []
|
||||
const optimisticSeeded: boolean[] = []
|
||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||
const sentShell: string[] = []
|
||||
const syncedDirectories: string[] = []
|
||||
|
||||
@@ -28,7 +33,12 @@ const clientFor = (directory: string) => {
|
||||
session: {
|
||||
create: async () => {
|
||||
createdSessions.push(directory)
|
||||
return { data: { id: `session-${createdSessions.length}` } }
|
||||
return {
|
||||
data: {
|
||||
id: `session-${createdSessions.length}`,
|
||||
title: `New session ${createdSessions.length}`,
|
||||
},
|
||||
}
|
||||
},
|
||||
shell: async () => {
|
||||
sentShell.push(directory)
|
||||
@@ -77,6 +87,11 @@ beforeAll(async () => {
|
||||
agent: {
|
||||
current: () => ({ name: "agent" }),
|
||||
},
|
||||
session: {
|
||||
promote(directory: string, sessionID: string) {
|
||||
promoted.push({ directory, sessionID })
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -129,9 +144,16 @@ beforeAll(async () => {
|
||||
session: {
|
||||
optimistic: {
|
||||
add: (value: {
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
|
||||
}) => {
|
||||
optimistic.push(value)
|
||||
optimisticSeeded.push(
|
||||
!!value.directory &&
|
||||
!!value.sessionID &&
|
||||
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
|
||||
)
|
||||
},
|
||||
remove: () => undefined,
|
||||
},
|
||||
@@ -144,7 +166,21 @@ beforeAll(async () => {
|
||||
useGlobalSync: () => ({
|
||||
child: (directory: string) => {
|
||||
syncedDirectories.push(directory)
|
||||
return [{}, () => undefined]
|
||||
storedSessions[directory] ??= []
|
||||
return [
|
||||
{ session: storedSessions[directory] },
|
||||
(...args: unknown[]) => {
|
||||
if (args[0] !== "session") return
|
||||
const next = args[1]
|
||||
if (typeof next === "function") {
|
||||
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
||||
return
|
||||
}
|
||||
if (Array.isArray(next)) {
|
||||
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}),
|
||||
}))
|
||||
@@ -170,11 +206,14 @@ beforeEach(() => {
|
||||
createdSessions.length = 0
|
||||
enabledAutoAccept.length = 0
|
||||
optimistic.length = 0
|
||||
optimisticSeeded.length = 0
|
||||
promoted.length = 0
|
||||
params = {}
|
||||
sentShell.length = 0
|
||||
syncedDirectories.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
variant = undefined
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
})
|
||||
|
||||
describe("prompt submit worktree selection", () => {
|
||||
@@ -207,7 +246,12 @@ describe("prompt submit worktree selection", () => {
|
||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||
expect(promoted).toEqual([
|
||||
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
||||
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
||||
])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||
})
|
||||
|
||||
test("applies auto-accept to newly created sessions", async () => {
|
||||
@@ -271,4 +315,32 @@ describe("prompt submit worktree selection", () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("seeds new sessions before optimistic prompts are added", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
|
||||
expect(optimisticSeeded).toEqual([true])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
@@ -9,7 +10,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { Identifier } from "@/utils/id"
|
||||
@@ -25,6 +26,145 @@ type PendingPrompt = {
|
||||
|
||||
const pending = new Map<string, PendingPrompt>()
|
||||
|
||||
export type FollowupDraft = {
|
||||
sessionID: string
|
||||
sessionDirectory: string
|
||||
prompt: Prompt
|
||||
context: (ContextItem & { key: string })[]
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}
|
||||
|
||||
type FollowupSendInput = {
|
||||
client: ReturnType<typeof useSDK>["client"]
|
||||
globalSync: ReturnType<typeof useGlobalSync>
|
||||
sync: ReturnType<typeof useSync>
|
||||
draft: FollowupDraft
|
||||
messageID?: string
|
||||
optimisticBusy?: boolean
|
||||
before?: () => Promise<boolean> | boolean
|
||||
}
|
||||
|
||||
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
|
||||
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
|
||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
const text = draftText(input.draft.prompt)
|
||||
const images = draftImages(input.draft.prompt)
|
||||
const [, setStore] = input.globalSync.child(input.draft.sessionDirectory)
|
||||
|
||||
const setBusy = () => {
|
||||
if (!input.optimisticBusy) return
|
||||
setStore("session_status", input.draft.sessionID, { type: "busy" })
|
||||
}
|
||||
|
||||
const setIdle = () => {
|
||||
if (!input.optimisticBusy) return
|
||||
setStore("session_status", input.draft.sessionID, { type: "idle" })
|
||||
}
|
||||
|
||||
const wait = async () => {
|
||||
const ok = await input.before?.()
|
||||
if (ok === false) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const [head, ...tail] = text.split(" ")
|
||||
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
||||
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
|
||||
setBusy()
|
||||
try {
|
||||
if (!(await wait())) {
|
||||
setIdle()
|
||||
return false
|
||||
}
|
||||
|
||||
await input.client.session.command({
|
||||
sessionID: input.draft.sessionID,
|
||||
command: cmd,
|
||||
arguments: tail.join(" "),
|
||||
agent: input.draft.agent,
|
||||
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
|
||||
variant: input.draft.variant,
|
||||
parts: images.map((attachment) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
})),
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
setIdle()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const messageID = input.messageID ?? Identifier.ascending("message")
|
||||
const { requestParts, optimisticParts } = buildRequestParts({
|
||||
prompt: input.draft.prompt,
|
||||
context: input.draft.context,
|
||||
images,
|
||||
text,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
sessionDirectory: input.draft.sessionDirectory,
|
||||
})
|
||||
|
||||
const message: Message = {
|
||||
id: messageID,
|
||||
sessionID: input.draft.sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: input.draft.agent,
|
||||
model: input.draft.model,
|
||||
variant: input.draft.variant,
|
||||
}
|
||||
|
||||
const add = () =>
|
||||
input.sync.session.optimistic.add({
|
||||
directory: input.draft.sessionDirectory,
|
||||
sessionID: input.draft.sessionID,
|
||||
message,
|
||||
parts: optimisticParts,
|
||||
})
|
||||
|
||||
const remove = () =>
|
||||
input.sync.session.optimistic.remove({
|
||||
directory: input.draft.sessionDirectory,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
})
|
||||
|
||||
setBusy()
|
||||
add()
|
||||
|
||||
try {
|
||||
if (!(await wait())) {
|
||||
setIdle()
|
||||
remove()
|
||||
return false
|
||||
}
|
||||
|
||||
await input.client.session.promptAsync({
|
||||
sessionID: input.draft.sessionID,
|
||||
agent: input.draft.agent,
|
||||
model: input.draft.model,
|
||||
messageID,
|
||||
parts: requestParts,
|
||||
variant: input.draft.variant,
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
setIdle()
|
||||
remove()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
type PromptSubmitInput = {
|
||||
info: Accessor<{ id: string } | undefined>
|
||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||
@@ -41,6 +181,9 @@ type PromptSubmitInput = {
|
||||
setPopover: (popover: "at" | "slash" | null) => void
|
||||
newSessionWorktree?: Accessor<string | undefined>
|
||||
onNewSessionWorktreeReset?: () => void
|
||||
shouldQueue?: Accessor<boolean>
|
||||
onQueue?: (draft: FollowupDraft) => void
|
||||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
}
|
||||
|
||||
@@ -82,6 +225,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const [, setStore] = globalSync.child(sdk.directory)
|
||||
setStore("todo", sessionID, [])
|
||||
|
||||
input.onAbort?.()
|
||||
|
||||
const queued = pending.get(sessionID)
|
||||
if (queued) {
|
||||
queued.abort.abort()
|
||||
@@ -116,6 +261,26 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const clearContext = () => {
|
||||
for (const item of prompt.context.items()) {
|
||||
prompt.context.remove(item.key)
|
||||
}
|
||||
}
|
||||
|
||||
const seed = (dir: string, info: Session) => {
|
||||
const [, setStore] = globalSync.child(dir)
|
||||
setStore("session", (list: Session[]) => {
|
||||
const result = Binary.search(list, info.id, (item) => item.id)
|
||||
const next = [...list]
|
||||
if (result.found) {
|
||||
next[result.index] = info
|
||||
return next
|
||||
}
|
||||
next.splice(result.index, 0, info)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -131,6 +296,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
const currentModel = local.model.current()
|
||||
const currentAgent = local.agent.current()
|
||||
const variant = local.model.variant.current()
|
||||
if (!currentModel || !currentAgent) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.modelAgentRequired.title"),
|
||||
@@ -191,7 +357,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
let session = input.info()
|
||||
if (!session && isNewSession) {
|
||||
session = await client.session
|
||||
const created = await client.session
|
||||
.create()
|
||||
.then((x) => x.data ?? undefined)
|
||||
.catch((err) => {
|
||||
@@ -201,8 +367,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
if (session) {
|
||||
if (created) {
|
||||
seed(sessionDirectory, created)
|
||||
session = created
|
||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||
local.session.promote(sessionDirectory, session.id)
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
}
|
||||
@@ -215,14 +384,21 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return
|
||||
}
|
||||
|
||||
input.onSubmit?.()
|
||||
|
||||
const model = {
|
||||
modelID: currentModel.id,
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const variant = local.model.variant.current()
|
||||
const context = prompt.context.items().slice()
|
||||
const draft: FollowupDraft = {
|
||||
sessionID: session.id,
|
||||
sessionDirectory,
|
||||
prompt: currentPrompt,
|
||||
context,
|
||||
agent,
|
||||
model,
|
||||
variant,
|
||||
}
|
||||
|
||||
const clearInput = () => {
|
||||
prompt.reset()
|
||||
@@ -243,6 +419,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
})
|
||||
}
|
||||
|
||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||
input.onQueue?.(draft)
|
||||
clearContext()
|
||||
clearInput()
|
||||
return
|
||||
}
|
||||
|
||||
input.onSubmit?.()
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
client.session
|
||||
@@ -295,48 +480,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const context = prompt.context.items().slice()
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
|
||||
const messageID = Identifier.ascending("message")
|
||||
const { requestParts, optimisticParts } = buildRequestParts({
|
||||
prompt: currentPrompt,
|
||||
context,
|
||||
images,
|
||||
text,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
sessionDirectory,
|
||||
})
|
||||
|
||||
const optimisticMessage: Message = {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent,
|
||||
model,
|
||||
variant,
|
||||
}
|
||||
|
||||
const addOptimisticMessage = () =>
|
||||
sync.session.optimistic.add({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
message: optimisticMessage,
|
||||
parts: optimisticParts,
|
||||
})
|
||||
|
||||
const removeOptimisticMessage = () =>
|
||||
const removeOptimisticMessage = () => {
|
||||
sync.session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})
|
||||
}
|
||||
|
||||
removeCommentItems(commentItems)
|
||||
clearInput()
|
||||
addOptimisticMessage()
|
||||
|
||||
const waitForWorktree = async () => {
|
||||
const worktree = WorktreeState.get(sessionDirectory)
|
||||
@@ -393,20 +549,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return true
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
const ok = await waitForWorktree()
|
||||
if (!ok) return
|
||||
await client.session.promptAsync({
|
||||
sessionID: session.id,
|
||||
agent,
|
||||
model,
|
||||
messageID,
|
||||
parts: requestParts,
|
||||
variant,
|
||||
})
|
||||
}
|
||||
|
||||
void send().catch((err) => {
|
||||
void sendFollowupDraft({
|
||||
client,
|
||||
sync,
|
||||
globalSync,
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
before: waitForWorktree,
|
||||
}).catch((err) => {
|
||||
pending.delete(session.id)
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "idle" })
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ParentProps,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type ServerConnection, serverName } from "@/context/server"
|
||||
import type { ServerHealth } from "@/utils/server-health"
|
||||
|
||||
@@ -25,6 +26,7 @@ interface ServerRowProps extends ParentProps {
|
||||
}
|
||||
|
||||
export function ServerRow(props: ServerRowProps) {
|
||||
const language = useLanguage()
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
let nameRef: HTMLSpanElement | undefined
|
||||
let versionRef: HTMLSpanElement | undefined
|
||||
@@ -65,22 +67,26 @@ export function ServerRow(props: ServerRowProps) {
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
class="flex-1"
|
||||
class="flex-1 min-w-0"
|
||||
value={tooltipValue()}
|
||||
contentStyle={{ "max-width": "none", "white-space": "nowrap" }}
|
||||
placement="top-start"
|
||||
inactive={!truncated() && !props.conn.displayName}
|
||||
>
|
||||
<div class={props.class} classList={{ "opacity-50": props.dimmed }}>
|
||||
<div class="flex flex-col items-start">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
|
||||
<div class="flex flex-col items-start min-w-0 w-full">
|
||||
<div class="flex flex-row items-center gap-2 min-w-0 w-full">
|
||||
<span ref={nameRef} class={`${props.nameClass ?? "truncate"} min-w-0`}>
|
||||
{name()}
|
||||
</span>
|
||||
<Show
|
||||
when={badge()}
|
||||
fallback={
|
||||
<Show when={props.status?.version}>
|
||||
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
|
||||
<span
|
||||
ref={versionRef}
|
||||
class={`${props.versionClass ?? "text-text-weak text-14-regular truncate"} min-w-0`}
|
||||
>
|
||||
v{props.status?.version}
|
||||
</span>
|
||||
</Show>
|
||||
@@ -96,7 +102,7 @@ export function ServerRow(props: ServerRowProps) {
|
||||
{conn().http.username ? (
|
||||
<span class="text-text-weak">{conn().http.username}</span>
|
||||
) : (
|
||||
<span class="text-text-weaker">no username</span>
|
||||
<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>
|
||||
)}
|
||||
</span>
|
||||
{conn().http.password && <span class="text-text-weak">••••••••</span>}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Match, Show, Switch, createMemo } from "solid-js"
|
||||
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useParams } from "@solidjs/router"
|
||||
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
|
||||
interface SessionContextUsageProps {
|
||||
variant?: "button" | "indicator"
|
||||
@@ -27,14 +29,17 @@ function openSessionContext(args: {
|
||||
|
||||
export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const sync = useSync()
|
||||
const params = useParams()
|
||||
const file = useFile()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
|
||||
const variant = createMemo(() => props.variant ?? "button")
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
})
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
|
||||
const usd = createMemo(
|
||||
@@ -54,7 +59,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const openContext = () => {
|
||||
if (!params.id) return
|
||||
|
||||
if (tabs().active() === "context") {
|
||||
if (tabState.activeTab() === "context") {
|
||||
tabs().close("context")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { same } from "@/utils/same"
|
||||
@@ -14,6 +12,7 @@ import { Markdown } from "@opencode-ai/ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||
import { createSessionContextFormatter } from "./session-context-format"
|
||||
@@ -91,13 +90,10 @@ const emptyMessages: Message[] = []
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
|
||||
export function SessionContextTab() {
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
|
||||
const messages = createMemo(
|
||||
@@ -271,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()} />}
|
||||
|
||||
@@ -4,18 +4,14 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
@@ -23,6 +19,8 @@ import { useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { focusTerminalById } from "@/pages/session/helpers"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { StatusPopover } from "../status-popover"
|
||||
@@ -50,74 +48,68 @@ type OS = "macos" | "windows" | "linux" | "unknown"
|
||||
const MAC_APPS = [
|
||||
{
|
||||
id: "vscode",
|
||||
label: "VS Code",
|
||||
label: "session.header.open.app.vscode",
|
||||
icon: "vscode",
|
||||
openWith: "Visual Studio Code",
|
||||
},
|
||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
|
||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
|
||||
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
|
||||
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
|
||||
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
|
||||
{ id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
|
||||
{
|
||||
id: "antigravity",
|
||||
label: "Antigravity",
|
||||
label: "session.header.open.app.antigravity",
|
||||
icon: "antigravity",
|
||||
openWith: "Antigravity",
|
||||
},
|
||||
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
|
||||
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
|
||||
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
|
||||
{ id: "warp", label: "Warp", icon: "warp", openWith: "Warp" },
|
||||
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
|
||||
{ id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
|
||||
{ id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
|
||||
{ id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
|
||||
{ id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
|
||||
{ id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
|
||||
{
|
||||
id: "android-studio",
|
||||
label: "Android Studio",
|
||||
label: "session.header.open.app.androidStudio",
|
||||
icon: "android-studio",
|
||||
openWith: "Android Studio",
|
||||
},
|
||||
{
|
||||
id: "sublime-text",
|
||||
label: "Sublime Text",
|
||||
label: "session.header.open.app.sublimeText",
|
||||
icon: "sublime-text",
|
||||
openWith: "Sublime Text",
|
||||
},
|
||||
] as const
|
||||
|
||||
const WINDOWS_APPS = [
|
||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
||||
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
|
||||
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
|
||||
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
|
||||
{
|
||||
id: "powershell",
|
||||
label: "PowerShell",
|
||||
label: "session.header.open.app.powershell",
|
||||
icon: "powershell",
|
||||
openWith: "powershell",
|
||||
},
|
||||
{
|
||||
id: "sublime-text",
|
||||
label: "Sublime Text",
|
||||
label: "session.header.open.app.sublimeText",
|
||||
icon: "sublime-text",
|
||||
openWith: "Sublime Text",
|
||||
},
|
||||
] as const
|
||||
|
||||
const LINUX_APPS = [
|
||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
||||
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
|
||||
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
|
||||
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
|
||||
{
|
||||
id: "sublime-text",
|
||||
label: "Sublime Text",
|
||||
label: "session.header.open.app.sublimeText",
|
||||
icon: "sublime-text",
|
||||
openWith: "Sublime Text",
|
||||
},
|
||||
] as const
|
||||
|
||||
type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number]
|
||||
type OpenIcon = OpenApp | "file-explorer"
|
||||
const OPEN_ICON_BASE = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
|
||||
|
||||
const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]")
|
||||
|
||||
const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
|
||||
if (platform.platform === "desktop" && platform.os) return platform.os
|
||||
if (typeof navigator !== "object") return "unknown"
|
||||
@@ -136,102 +128,15 @@ const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown
|
||||
})
|
||||
}
|
||||
|
||||
function useSessionShare(args: {
|
||||
globalSDK: ReturnType<typeof useGlobalSDK>
|
||||
currentSession: () =>
|
||||
| {
|
||||
share?: {
|
||||
url?: string
|
||||
}
|
||||
}
|
||||
| undefined
|
||||
sessionID: () => string | undefined
|
||||
projectDirectory: () => string
|
||||
platform: ReturnType<typeof usePlatform>
|
||||
}) {
|
||||
const [state, setState] = createStore({
|
||||
share: false,
|
||||
unshare: false,
|
||||
copied: false,
|
||||
timer: undefined as number | undefined,
|
||||
})
|
||||
const shareUrl = createMemo(() => args.currentSession()?.share?.url)
|
||||
|
||||
createEffect(() => {
|
||||
const url = shareUrl()
|
||||
if (url) return
|
||||
if (state.timer) window.clearTimeout(state.timer)
|
||||
setState({ copied: false, timer: undefined })
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (state.timer) window.clearTimeout(state.timer)
|
||||
})
|
||||
|
||||
const shareSession = () => {
|
||||
const sessionID = args.sessionID()
|
||||
if (!sessionID || state.share) return
|
||||
setState("share", true)
|
||||
args.globalSDK.client.session
|
||||
.share({ sessionID, directory: args.projectDirectory() })
|
||||
.catch((error) => {
|
||||
console.error("Failed to share session", error)
|
||||
})
|
||||
.finally(() => {
|
||||
setState("share", false)
|
||||
})
|
||||
}
|
||||
|
||||
const unshareSession = () => {
|
||||
const sessionID = args.sessionID()
|
||||
if (!sessionID || state.unshare) return
|
||||
setState("unshare", true)
|
||||
args.globalSDK.client.session
|
||||
.unshare({ sessionID, directory: args.projectDirectory() })
|
||||
.catch((error) => {
|
||||
console.error("Failed to unshare session", error)
|
||||
})
|
||||
.finally(() => {
|
||||
setState("unshare", false)
|
||||
})
|
||||
}
|
||||
|
||||
const copyLink = (onError: (error: unknown) => void) => {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
if (state.timer) window.clearTimeout(state.timer)
|
||||
setState("copied", true)
|
||||
const timer = window.setTimeout(() => {
|
||||
setState("copied", false)
|
||||
setState("timer", undefined)
|
||||
}, 3000)
|
||||
setState("timer", timer)
|
||||
})
|
||||
.catch(onError)
|
||||
}
|
||||
|
||||
const viewShare = () => {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
args.platform.openLink(url)
|
||||
}
|
||||
|
||||
return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare }
|
||||
}
|
||||
|
||||
export function SessionHeader() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const layout = useLayout()
|
||||
const params = useParams()
|
||||
const command = useCommand()
|
||||
const server = useServer()
|
||||
const sync = useSync()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||
const project = createMemo(() => {
|
||||
@@ -245,12 +150,6 @@ export function SessionHeader() {
|
||||
return getFilename(projectDirectory())
|
||||
})
|
||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||
|
||||
const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||
const showShare = createMemo(() => shareEnabled() && !!params.id)
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const os = createMemo(() => detectOS(platform))
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
@@ -264,9 +163,9 @@ export function SessionHeader() {
|
||||
})
|
||||
|
||||
const fileManager = createMemo(() => {
|
||||
if (os() === "macos") return { label: "Finder", icon: "finder" as const }
|
||||
if (os() === "windows") return { label: "File Explorer", icon: "file-explorer" as const }
|
||||
return { label: "File Manager", icon: "finder" as const }
|
||||
if (os() === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
|
||||
if (os() === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
|
||||
return { label: "session.header.open.fileManager", icon: "finder" as const }
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -282,10 +181,7 @@ export function SessionHeader() {
|
||||
Promise.resolve(platform.checkAppExists?.(app.openWith))
|
||||
.then((value) => Boolean(value))
|
||||
.catch(() => false)
|
||||
.then((ok) => {
|
||||
console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
|
||||
return [app.id, ok] as const
|
||||
}),
|
||||
.then((ok) => [app.id, ok] as const),
|
||||
),
|
||||
).then((entries) => {
|
||||
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
|
||||
@@ -294,8 +190,10 @@ export function SessionHeader() {
|
||||
|
||||
const options = createMemo(() => {
|
||||
return [
|
||||
{ id: "finder", label: fileManager().label, icon: fileManager().icon },
|
||||
...apps().filter((app) => exists[app.id]),
|
||||
{ id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
|
||||
...apps()
|
||||
.filter((app) => exists[app.id])
|
||||
.map((app) => ({ ...app, label: language.t(app.label) })),
|
||||
] as const
|
||||
})
|
||||
|
||||
@@ -323,6 +221,9 @@ export function SessionHeader() {
|
||||
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
|
||||
)
|
||||
const opening = createMemo(() => openRequest.app !== undefined)
|
||||
const tint = createMemo(() =>
|
||||
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
|
||||
)
|
||||
|
||||
const selectApp = (app: OpenApp) => {
|
||||
if (!options().some((item) => item.id === app)) return
|
||||
@@ -361,14 +262,6 @@ export function SessionHeader() {
|
||||
.catch((err: unknown) => showRequestError(language, err))
|
||||
}
|
||||
|
||||
const share = useSessionShare({
|
||||
globalSDK,
|
||||
currentSession,
|
||||
sessionID: () => params.id,
|
||||
projectDirectory,
|
||||
platform,
|
||||
})
|
||||
|
||||
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
|
||||
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
|
||||
|
||||
@@ -381,12 +274,11 @@ export function SessionHeader() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
class="hidden md:flex w-[240px] max-w-full min-w-0 pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
|
||||
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 gap-1.5 overflow-visible">
|
||||
<Icon name="magnifying-glass" size="small" class="icon-base shrink-0 size-4" />
|
||||
<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(),
|
||||
@@ -396,7 +288,9 @@ export function SessionHeader() {
|
||||
|
||||
<Show when={hotkey()}>
|
||||
{(keybind) => (
|
||||
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind>
|
||||
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
|
||||
{keybind()}
|
||||
</Keybind>
|
||||
)}
|
||||
</Show>
|
||||
</Button>
|
||||
@@ -407,7 +301,6 @@ export function SessionHeader() {
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusPopover />
|
||||
<Show when={projectDirectory()}>
|
||||
<div class="hidden xl:flex items-center">
|
||||
<Show
|
||||
@@ -432,7 +325,7 @@ export function SessionHeader() {
|
||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none disabled:!cursor-default"
|
||||
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
|
||||
classList={{
|
||||
"bg-surface-raised-base-active": opening(),
|
||||
}}
|
||||
@@ -440,17 +333,12 @@ export function SessionHeader() {
|
||||
disabled={opening()}
|
||||
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
|
||||
>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||
<Show
|
||||
when={opening()}
|
||||
fallback={<AppIcon id={current().icon} class={openIconSize(current().icon)} />}
|
||||
>
|
||||
<Spinner class="size-3.5 text-icon-base" />
|
||||
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
|
||||
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
|
||||
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
|
||||
</Show>
|
||||
</div>
|
||||
<span class="text-12-regular text-text-strong">{language.t("common.open")}</span>
|
||||
</Button>
|
||||
<div class="self-stretch w-px bg-border-weak-base" />
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
@@ -462,17 +350,20 @@ export function SessionHeader() {
|
||||
icon="chevron-down"
|
||||
variant="ghost"
|
||||
disabled={opening()}
|
||||
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
|
||||
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
|
||||
classList={{
|
||||
"bg-surface-raised-base-active": opening(),
|
||||
}}
|
||||
aria-label={language.t("session.header.open.menu")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
|
||||
<DropdownMenu.GroupLabel class="!px-1 !py-1">
|
||||
{language.t("session.header.openIn")}
|
||||
</DropdownMenu.GroupLabel>
|
||||
<DropdownMenu.RadioGroup
|
||||
class="mt-1"
|
||||
value={current().id}
|
||||
onChange={(value) => {
|
||||
if (!OPEN_APPS.includes(value as OpenApp)) return
|
||||
@@ -489,8 +380,8 @@ export function SessionHeader() {
|
||||
openDir(o.id)
|
||||
}}
|
||||
>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||
<AppIcon id={o.icon} class={openIconSize(o.icon)} />
|
||||
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
|
||||
<AppIcon id={o.icon} />
|
||||
</div>
|
||||
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemIndicator>
|
||||
@@ -523,113 +414,10 @@ export function SessionHeader() {
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={showShare()}>
|
||||
<div class="flex items-center">
|
||||
<Popover
|
||||
title={language.t("session.share.popover.title")}
|
||||
description={
|
||||
share.shareUrl()
|
||||
? language.t("session.share.popover.description.shared")
|
||||
: language.t("session.share.popover.description.unshared")
|
||||
}
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
shift={-64}
|
||||
class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
class:
|
||||
"rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
|
||||
classList: {
|
||||
"rounded-r-none": share.shareUrl() !== undefined,
|
||||
"border-r-0": share.shareUrl() !== undefined,
|
||||
},
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Show
|
||||
when={share.shareUrl()}
|
||||
fallback={
|
||||
<div class="flex">
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-1/2"
|
||||
onClick={share.shareSession}
|
||||
disabled={share.state.share}
|
||||
>
|
||||
{share.state.share
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TextField
|
||||
value={share.shareUrl() ?? ""}
|
||||
readOnly
|
||||
copyable
|
||||
copyKind="link"
|
||||
tabIndex={-1}
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={share.unshareSession}
|
||||
disabled={share.state.unshare}
|
||||
>
|
||||
{share.state.unshare
|
||||
? language.t("session.share.action.unpublishing")
|
||||
: language.t("session.share.action.unpublish")}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={share.viewShare}
|
||||
disabled={share.state.unshare}
|
||||
>
|
||||
{language.t("session.share.action.view")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Popover>
|
||||
<Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}>
|
||||
<Tooltip
|
||||
value={
|
||||
share.state.copied
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("session.share.copy.copyLink")
|
||||
}
|
||||
placement="top"
|
||||
gutter={8}
|
||||
>
|
||||
<IconButton
|
||||
icon={share.state.copied ? "check" : "link"}
|
||||
variant="ghost"
|
||||
class="rounded-l-none h-[24px] border border-border-weak-base bg-surface-panel shadow-none"
|
||||
onClick={() => share.copyLink((error) => showRequestError(language, error))}
|
||||
disabled={share.state.unshare}
|
||||
aria-label={
|
||||
share.state.copied
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("session.share.copy.copyLink")
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</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")}
|
||||
@@ -642,23 +430,7 @@ export function SessionHeader() {
|
||||
aria-expanded={view().terminal.opened()}
|
||||
aria-controls="terminal-panel"
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
|
||||
class="group-hover/terminal-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name="layout-bottom-partial"
|
||||
class="hidden group-hover/terminal-toggle:inline-block"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
|
||||
class="hidden group-active/terminal-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
@@ -675,23 +447,7 @@ export function SessionHeader() {
|
||||
aria-expanded={view().reviewPanel.opened()}
|
||||
aria-controls="review-panel"
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-right"}
|
||||
class="group-hover/review-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name="layout-right-partial"
|
||||
class="hidden group-hover/review-toggle:inline-block"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-partial"}
|
||||
class="hidden group-active/review-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ const ROOT_CLASS = "size-full flex flex-col"
|
||||
|
||||
interface NewSessionViewProps {
|
||||
worktree: string
|
||||
onWorktreeChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function NewSessionView(props: NewSessionViewProps) {
|
||||
|
||||
@@ -6,8 +6,10 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title"
|
||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { focusTerminalById } from "@/pages/session/helpers"
|
||||
|
||||
export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element {
|
||||
const terminal = useTerminal()
|
||||
@@ -22,15 +24,12 @@ 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
|
||||
if (!Number.isFinite(number) || number <= 0) return false
|
||||
const match = props.terminal.title.match(/^Terminal (\d+)$/)
|
||||
if (!match) return false
|
||||
const parsed = Number(match[1])
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return false
|
||||
return parsed === number
|
||||
return isDefaultTerminalTitle(props.terminal.title, number)
|
||||
}
|
||||
|
||||
const label = () => {
|
||||
@@ -53,21 +52,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
|
||||
const focus = () => {
|
||||
if (store.editing) return
|
||||
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
const wrapper = document.getElementById(`terminal-wrapper-${props.terminal.id}`)
|
||||
const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
|
||||
if (!element) return
|
||||
|
||||
const textarea = element.querySelector("textarea") as HTMLTextAreaElement
|
||||
if (textarea) {
|
||||
textarea.focus()
|
||||
return
|
||||
}
|
||||
element.focus()
|
||||
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||
focusTerminalById(props.terminal.id)
|
||||
}
|
||||
|
||||
const edit = (e?: Event) => {
|
||||
@@ -183,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,16 +0,0 @@
|
||||
import { Component } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export const SettingsAgents: Component = () => {
|
||||
// TODO: Replace this placeholder with full agents settings controls.
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.agents.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{language.t("settings.agents.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Component } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export const SettingsCommands: Component = () => {
|
||||
// TODO: Replace this placeholder with full commands settings controls.
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.commands.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{language.t("settings.commands.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { usePlatform } from "@/context/platform"
|
||||
import { useSettings, monoFontFamily } from "@/context/settings"
|
||||
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "./link"
|
||||
import { SettingsList } from "./settings-list"
|
||||
|
||||
let demoSoundState = {
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
@@ -113,6 +114,11 @@ export const SettingsGeneral: Component = () => {
|
||||
{ value: "dark", label: language.t("theme.scheme.dark") },
|
||||
])
|
||||
|
||||
const followupOptions = createMemo((): { value: "queue" | "steer"; label: string }[] => [
|
||||
{ value: "queue", label: language.t("settings.general.row.followup.option.queue") },
|
||||
{ value: "steer", label: language.t("settings.general.row.followup.option.steer") },
|
||||
])
|
||||
|
||||
const languageOptions = createMemo(() =>
|
||||
language.locales.map((locale) => ({
|
||||
value: locale,
|
||||
@@ -170,11 +176,9 @@ export const SettingsGeneral: Component = () => {
|
||||
triggerVariant: "settings" as const,
|
||||
})
|
||||
|
||||
const AppearanceSection = () => (
|
||||
const GeneralSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.language.title")}
|
||||
description={language.t("settings.general.row.language.description")}
|
||||
@@ -193,8 +197,70 @@ export const SettingsGeneral: Component = () => {
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.appearance.title")}
|
||||
description={language.t("settings.general.row.appearance.description")}
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
||||
>
|
||||
<div data-action="settings-feed-reasoning-summaries">
|
||||
<Switch
|
||||
checked={settings.general.showReasoningSummaries()}
|
||||
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-shell-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.shellToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-edit-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.editToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.followup.title")}
|
||||
description={language.t("settings.general.row.followup.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-followup"
|
||||
options={followupOptions()}
|
||||
current={followupOptions().find((o) => o.value === settings.general.followup())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && settings.general.setFollowup(option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
triggerStyle={{ "min-width": "180px" }}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.colorScheme.title")}
|
||||
description={language.t("settings.general.row.colorScheme.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-color-scheme"
|
||||
@@ -211,6 +277,7 @@ export const SettingsGeneral: Component = () => {
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
triggerStyle={{ "min-width": "220px" }}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
@@ -267,51 +334,7 @@ export const SettingsGeneral: Component = () => {
|
||||
)}
|
||||
</Select>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const FeedSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.feed")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
||||
>
|
||||
<div data-action="settings-feed-reasoning-summaries">
|
||||
<Switch
|
||||
checked={settings.general.showReasoningSummaries()}
|
||||
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-shell-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.shellToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-edit-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.editToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -319,7 +342,7 @@ export const SettingsGeneral: Component = () => {
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.notifications.agent.title")}
|
||||
description={language.t("settings.general.notifications.agent.description")}
|
||||
@@ -355,7 +378,7 @@ export const SettingsGeneral: Component = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -363,7 +386,7 @@ export const SettingsGeneral: Component = () => {
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.sounds.agent.title")}
|
||||
description={language.t("settings.general.sounds.agent.description")}
|
||||
@@ -408,7 +431,7 @@ export const SettingsGeneral: Component = () => {
|
||||
)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -416,7 +439,7 @@ export const SettingsGeneral: Component = () => {
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.updates.row.startup.title")}
|
||||
description={language.t("settings.updates.row.startup.description")}
|
||||
@@ -452,7 +475,7 @@ export const SettingsGeneral: Component = () => {
|
||||
: language.t("settings.updates.action.checkNow")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -465,9 +488,9 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 w-full">
|
||||
<AppearanceSection />
|
||||
<GeneralSection />
|
||||
|
||||
<FeedSection />
|
||||
<AppearanceSection />
|
||||
|
||||
<NotificationsSection />
|
||||
|
||||
@@ -482,7 +505,7 @@ export const SettingsGeneral: Component = () => {
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.desktop.wsl.title")}
|
||||
description={language.t("settings.desktop.wsl.description")}
|
||||
@@ -495,7 +518,7 @@ export const SettingsGeneral: Component = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
@@ -515,7 +538,7 @@ export const SettingsGeneral: Component = () => {
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -533,7 +556,7 @@ export const SettingsGeneral: Component = () => {
|
||||
<Switch checked={value() === "wayland"} onChange={onChange} />
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
@@ -551,12 +574,12 @@ interface SettingsRowProps {
|
||||
|
||||
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
||||
return (
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-4 py-3 border-b border-border-weak-base last:border-none sm:flex-nowrap">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="text-14-medium text-text-strong">{props.title}</span>
|
||||
<span class="text-12-regular text-text-weak">{props.description}</span>
|
||||
</div>
|
||||
<div class="flex-shrink-0">{props.children}</div>
|
||||
<div class="flex w-full justify-end sm:w-auto sm:shrink-0">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import fuzzysort from "fuzzysort"
|
||||
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { SettingsList } from "./settings-list"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
const PALETTE_ID = "command.palette"
|
||||
@@ -238,7 +239,7 @@ function useKeyCapture(input: {
|
||||
showToast({
|
||||
title: input.language.t("settings.shortcuts.conflict.title"),
|
||||
description: input.language.t("settings.shortcuts.conflict.description", {
|
||||
keybind: formatKeybind(next),
|
||||
keybind: formatKeybind(next, input.language.t),
|
||||
titles: [...conflicts.values()].join(", "),
|
||||
}),
|
||||
})
|
||||
@@ -406,7 +407,7 @@ export const SettingsKeybinds: Component = () => {
|
||||
<Show when={(filtered().get(group) ?? []).length > 0}>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t(groupKey[group])}</h3>
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsList>
|
||||
<For each={filtered().get(group) ?? []}>
|
||||
{(id) => (
|
||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
@@ -432,7 +433,7 @@ export const SettingsKeybinds: Component = () => {
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
|
||||
5
packages/app/src/components/settings-list.tsx
Normal file
5
packages/app/src/components/settings-list.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { type Component, type JSX } from "solid-js"
|
||||
|
||||
export const SettingsList: Component<{ children: JSX.Element }> = (props) => {
|
||||
return <div class="bg-surface-base px-4 rounded-lg">{props.children}</div>
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Component } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export const SettingsMcp: Component = () => {
|
||||
// TODO: Replace this placeholder with full MCP settings controls.
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.mcp.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{language.t("settings.mcp.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useModels } from "@/context/models"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { SettingsList } from "./settings-list"
|
||||
|
||||
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
||||
|
||||
@@ -100,7 +101,7 @@ export const SettingsModels: Component = () => {
|
||||
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
|
||||
</div>
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsList>
|
||||
<For each={group.items}>
|
||||
{(item) => {
|
||||
const key = { providerID: item.provider.id, modelID: item.id }
|
||||
@@ -124,7 +125,7 @@ export const SettingsModels: Component = () => {
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Component, For, createMemo, type JSX } from "solid-js"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
type PermissionAction = "allow" | "ask" | "deny"
|
||||
|
||||
type PermissionObject = Record<string, PermissionAction>
|
||||
type PermissionValue = PermissionAction | PermissionObject | string[] | undefined
|
||||
type PermissionMap = Record<string, PermissionValue>
|
||||
|
||||
type PermissionItem = {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const ACTIONS = [
|
||||
{ value: "allow", label: "settings.permissions.action.allow" },
|
||||
{ value: "ask", label: "settings.permissions.action.ask" },
|
||||
{ value: "deny", label: "settings.permissions.action.deny" },
|
||||
] as const
|
||||
|
||||
const ITEMS = [
|
||||
{
|
||||
id: "read",
|
||||
title: "settings.permissions.tool.read.title",
|
||||
description: "settings.permissions.tool.read.description",
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
title: "settings.permissions.tool.edit.title",
|
||||
description: "settings.permissions.tool.edit.description",
|
||||
},
|
||||
{
|
||||
id: "glob",
|
||||
title: "settings.permissions.tool.glob.title",
|
||||
description: "settings.permissions.tool.glob.description",
|
||||
},
|
||||
{
|
||||
id: "grep",
|
||||
title: "settings.permissions.tool.grep.title",
|
||||
description: "settings.permissions.tool.grep.description",
|
||||
},
|
||||
{
|
||||
id: "list",
|
||||
title: "settings.permissions.tool.list.title",
|
||||
description: "settings.permissions.tool.list.description",
|
||||
},
|
||||
{
|
||||
id: "bash",
|
||||
title: "settings.permissions.tool.bash.title",
|
||||
description: "settings.permissions.tool.bash.description",
|
||||
},
|
||||
{
|
||||
id: "task",
|
||||
title: "settings.permissions.tool.task.title",
|
||||
description: "settings.permissions.tool.task.description",
|
||||
},
|
||||
{
|
||||
id: "skill",
|
||||
title: "settings.permissions.tool.skill.title",
|
||||
description: "settings.permissions.tool.skill.description",
|
||||
},
|
||||
{
|
||||
id: "lsp",
|
||||
title: "settings.permissions.tool.lsp.title",
|
||||
description: "settings.permissions.tool.lsp.description",
|
||||
},
|
||||
{
|
||||
id: "todoread",
|
||||
title: "settings.permissions.tool.todoread.title",
|
||||
description: "settings.permissions.tool.todoread.description",
|
||||
},
|
||||
{
|
||||
id: "todowrite",
|
||||
title: "settings.permissions.tool.todowrite.title",
|
||||
description: "settings.permissions.tool.todowrite.description",
|
||||
},
|
||||
{
|
||||
id: "webfetch",
|
||||
title: "settings.permissions.tool.webfetch.title",
|
||||
description: "settings.permissions.tool.webfetch.description",
|
||||
},
|
||||
{
|
||||
id: "websearch",
|
||||
title: "settings.permissions.tool.websearch.title",
|
||||
description: "settings.permissions.tool.websearch.description",
|
||||
},
|
||||
{
|
||||
id: "codesearch",
|
||||
title: "settings.permissions.tool.codesearch.title",
|
||||
description: "settings.permissions.tool.codesearch.description",
|
||||
},
|
||||
{
|
||||
id: "external_directory",
|
||||
title: "settings.permissions.tool.external_directory.title",
|
||||
description: "settings.permissions.tool.external_directory.description",
|
||||
},
|
||||
{
|
||||
id: "doom_loop",
|
||||
title: "settings.permissions.tool.doom_loop.title",
|
||||
description: "settings.permissions.tool.doom_loop.description",
|
||||
},
|
||||
] as const
|
||||
|
||||
const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"])
|
||||
|
||||
function toMap(value: unknown): PermissionMap {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap
|
||||
|
||||
const action = getAction(value)
|
||||
if (action) return { "*": action }
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
function getAction(value: unknown): PermissionAction | undefined {
|
||||
if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction
|
||||
return
|
||||
}
|
||||
|
||||
function getRuleDefault(value: unknown): PermissionAction | undefined {
|
||||
const action = getAction(value)
|
||||
if (action) return action
|
||||
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return
|
||||
|
||||
return getAction((value as Record<string, unknown>)["*"])
|
||||
}
|
||||
|
||||
export const SettingsPermissions: Component = () => {
|
||||
const globalSync = useGlobalSync()
|
||||
const language = useLanguage()
|
||||
|
||||
const actions = createMemo(
|
||||
(): Array<{ value: PermissionAction; label: string }> =>
|
||||
ACTIONS.map((action) => ({
|
||||
value: action.value,
|
||||
label: language.t(action.label),
|
||||
})),
|
||||
)
|
||||
|
||||
const permission = createMemo(() => {
|
||||
return toMap(globalSync.data.config.permission)
|
||||
})
|
||||
|
||||
const actionFor = (id: string): PermissionAction => {
|
||||
const value = permission()[id]
|
||||
const direct = getRuleDefault(value)
|
||||
if (direct) return direct
|
||||
|
||||
const wildcard = getRuleDefault(permission()["*"])
|
||||
if (wildcard) return wildcard
|
||||
|
||||
return "allow"
|
||||
}
|
||||
|
||||
const setPermission = async (id: string, action: PermissionAction) => {
|
||||
const before = globalSync.data.config.permission
|
||||
const map = toMap(before)
|
||||
const existing = map[id]
|
||||
|
||||
const nextValue =
|
||||
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
|
||||
|
||||
const rollback = (err: unknown) => {
|
||||
globalSync.set("config", "permission", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
|
||||
}
|
||||
|
||||
globalSync.set("config", "permission", { ...map, [id]: nextValue })
|
||||
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6 px-4 py-6 sm:p-8 sm:pt-6 max-w-[720px]">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-14-medium text-text-strong">{language.t("settings.permissions.section.tools")}</h3>
|
||||
<div class="border border-border-weak-base rounded-lg overflow-hidden">
|
||||
<For each={ITEMS}>
|
||||
{(item) => (
|
||||
<SettingsRow title={language.t(item.title)} description={language.t(item.description)}>
|
||||
<Select
|
||||
options={actions()}
|
||||
current={actions().find((o) => o.value === actionFor(item.id))}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && setPermission(item.id, option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingsRowProps {
|
||||
title: string
|
||||
description: string
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
||||
return (
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-14-medium text-text-strong">{props.title}</span>
|
||||
<span class="text-12-regular text-text-weak">{props.description}</span>
|
||||
</div>
|
||||
<div class="flex-shrink-0">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useGlobalSync } from "@/context/global-sync"
|
||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
import { SettingsList } from "./settings-list"
|
||||
|
||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
||||
@@ -136,7 +137,7 @@ export const SettingsProviders: Component = () => {
|
||||
<div class="flex flex-col gap-8 max-w-[720px]">
|
||||
<div class="flex flex-col gap-1" data-component="connected-providers-section">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsList>
|
||||
<Show
|
||||
when={connected().length > 0}
|
||||
fallback={
|
||||
@@ -169,12 +170,12 @@ export const SettingsProviders: Component = () => {
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.popular")}</h3>
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsList>
|
||||
<For each={popular()}>
|
||||
{(item) => (
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
||||
@@ -232,7 +233,7 @@ export const SettingsProviders: Component = () => {
|
||||
{language.t("common.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsList>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -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"
|
||||
@@ -14,7 +15,7 @@ import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
import { DialogSelectServer } from "./dialog-select-server"
|
||||
|
||||
const pollMs = 10_000
|
||||
@@ -53,7 +54,8 @@ const listServersByHealth = (
|
||||
})
|
||||
}
|
||||
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||
|
||||
createEffect(() => {
|
||||
@@ -64,7 +66,7 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
|
||||
const results: Record<string, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
list.map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
}),
|
||||
)
|
||||
if (dead) return
|
||||
@@ -85,15 +87,17 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
|
||||
const useDefaultServerKey = (
|
||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||
) => {
|
||||
const [url, setUrl] = createSignal<string | undefined>()
|
||||
const [tick, setTick] = createSignal(0)
|
||||
const [state, setState] = createStore({
|
||||
url: undefined as string | undefined,
|
||||
tick: 0,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
tick()
|
||||
state.tick
|
||||
let dead = false
|
||||
const result = get?.()
|
||||
if (!result) {
|
||||
setUrl(undefined)
|
||||
setState("url", undefined)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
@@ -103,7 +107,7 @@ const useDefaultServerKey = (
|
||||
if (result instanceof Promise) {
|
||||
void result.then((next) => {
|
||||
if (dead) return
|
||||
setUrl(next ? normalizeServerUrl(next) : undefined)
|
||||
setState("url", next ? normalizeServerUrl(next) : undefined)
|
||||
})
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
@@ -111,7 +115,7 @@ const useDefaultServerKey = (
|
||||
return
|
||||
}
|
||||
|
||||
setUrl(normalizeServerUrl(result))
|
||||
setState("url", normalizeServerUrl(result))
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
@@ -119,56 +123,45 @@ const useDefaultServerKey = (
|
||||
|
||||
return {
|
||||
key: () => {
|
||||
const u = url()
|
||||
const u = state.url
|
||||
if (!u) return
|
||||
return ServerConnection.key({ type: "http", http: { url: u } })
|
||||
},
|
||||
refresh: () => setTick((value) => value + 1),
|
||||
refresh: () => setState("tick", (value) => value + 1),
|
||||
}
|
||||
}
|
||||
|
||||
const useMcpToggle = (input: {
|
||||
sync: ReturnType<typeof useSync>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) => {
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
const 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()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const servers = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
@@ -176,10 +169,10 @@ 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, fetcher)
|
||||
const health = useServerHealth(servers)
|
||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||
const mcp = useMcpToggle({ sync, sdk, language })
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
|
||||
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
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
@@ -199,18 +192,23 @@ export function StatusPopover() {
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={shown()}
|
||||
onOpenChange={setShown}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
class: "titlebar-icon w-6 h-6 p-0 box-border",
|
||||
class: "titlebar-icon w-8 h-6 p-0 box-border",
|
||||
"aria-label": language.t("status.popover.trigger"),
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={
|
||||
<div class="flex size-4 items-center justify-center">
|
||||
<div class="relative size-4">
|
||||
<div class="badge-mask-tight size-4 flex items-center justify-center">
|
||||
<Icon name={shown() ? "status-active" : "status"} size="small" />
|
||||
</div>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full": true,
|
||||
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
||||
"bg-icon-success-base": overallHealthy(),
|
||||
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
||||
"bg-border-weak-base": server.healthy() === undefined,
|
||||
@@ -269,8 +267,8 @@ export function StatusPopover() {
|
||||
aria-disabled={isBlocked()}
|
||||
onClick={() => {
|
||||
if (isBlocked()) return
|
||||
server.setActive(key)
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(key))
|
||||
}}
|
||||
>
|
||||
<ServerHealthIndicator health={health[key]} />
|
||||
@@ -329,8 +327,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={{
|
||||
@@ -346,8 +347,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>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSDK } from "@/context/sdk"
|
||||
import { useServer } from "@/context/server"
|
||||
import { monoFontFamily, useSettings } from "@/context/settings"
|
||||
import type { LocalPTY } from "@/context/terminal"
|
||||
import { terminalAttr, terminalProbe } from "@/testing/terminal"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||
import { terminalWriter } from "@/utils/terminal-writer"
|
||||
|
||||
@@ -64,6 +65,13 @@ const debugTerminal = (...values: unknown[]) => {
|
||||
console.debug("[terminal]", ...values)
|
||||
}
|
||||
|
||||
const errorName = (err: unknown) => {
|
||||
if (!err || typeof err !== "object") return
|
||||
if (!("name" in err)) return
|
||||
const errorName = err.name
|
||||
return typeof errorName === "string" ? errorName : undefined
|
||||
}
|
||||
|
||||
const useTerminalUiBindings = (input: {
|
||||
container: HTMLDivElement
|
||||
term: Term
|
||||
@@ -157,9 +165,16 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const url = sdk.url
|
||||
const auth = server.current?.http
|
||||
const username = auth?.username ?? "opencode"
|
||||
const password = auth?.password ?? ""
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||
const id = local.pty.id
|
||||
const probe = terminalProbe(id)
|
||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||
const restoreSize =
|
||||
restore &&
|
||||
@@ -187,7 +202,11 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const start =
|
||||
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
|
||||
let cursor = start ?? 0
|
||||
let seek = start !== undefined ? start : restore ? -1 : 0
|
||||
let output: ReturnType<typeof terminalWriter> | undefined
|
||||
let drop: VoidFunction | undefined
|
||||
let reconn: ReturnType<typeof setTimeout> | undefined
|
||||
let tries = 0
|
||||
|
||||
const cleanup = () => {
|
||||
if (!cleanups.length) return
|
||||
@@ -202,7 +221,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
const pushSize = (cols: number, rows: number) => {
|
||||
return sdk.client.pty
|
||||
return client.pty
|
||||
.update({
|
||||
ptyID: id,
|
||||
size: { cols, rows },
|
||||
@@ -326,6 +345,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
probe.init()
|
||||
cleanups.push(() => probe.drop())
|
||||
|
||||
const run = async () => {
|
||||
const loaded = await loadGhostty()
|
||||
if (disposed) return
|
||||
@@ -353,7 +375,13 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
ghostty = g
|
||||
term = t
|
||||
output = terminalWriter((data, done) => t.write(data, done))
|
||||
output = terminalWriter((data, done) =>
|
||||
t.write(data, () => {
|
||||
probe.render(data)
|
||||
probe.settle()
|
||||
done?.()
|
||||
}),
|
||||
)
|
||||
|
||||
t.attachCustomKeyEventHandler((event) => {
|
||||
const key = event.key.toLowerCase()
|
||||
@@ -441,89 +469,136 @@ export const Terminal = (props: TerminalProps) => {
|
||||
startResize()
|
||||
}
|
||||
|
||||
// t.onScroll((ydisp) => {
|
||||
// console.log("Scroll position:", ydisp)
|
||||
// })
|
||||
|
||||
const once = { value: false }
|
||||
let closing = false
|
||||
|
||||
const url = new URL(sdk.url + `/pty/${id}/connect`)
|
||||
url.searchParams.set("directory", sdk.directory)
|
||||
url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0))
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
url.username = server.current?.http.username ?? ""
|
||||
url.password = server.current?.http.password ?? ""
|
||||
|
||||
const socket = new WebSocket(url)
|
||||
socket.binaryType = "arraybuffer"
|
||||
ws = socket
|
||||
|
||||
const handleOpen = () => {
|
||||
local.onConnect?.()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
}
|
||||
socket.addEventListener("open", handleOpen)
|
||||
if (socket.readyState === WebSocket.OPEN) handleOpen()
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (disposed) return
|
||||
if (closing) return
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(event.data)
|
||||
if (bytes[0] !== 0) return
|
||||
const json = decoder.decode(bytes.subarray(1))
|
||||
try {
|
||||
const meta = JSON.parse(json) as { cursor?: unknown }
|
||||
const next = meta?.cursor
|
||||
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
|
||||
cursor = next
|
||||
}
|
||||
} catch (err) {
|
||||
debugTerminal("invalid websocket control frame", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const data = typeof event.data === "string" ? event.data : ""
|
||||
if (!data) return
|
||||
output?.push(data)
|
||||
cursor += data.length
|
||||
}
|
||||
socket.addEventListener("message", handleMessage)
|
||||
|
||||
const handleError = (error: Event) => {
|
||||
const fail = (err: unknown) => {
|
||||
if (disposed) return
|
||||
if (closing) return
|
||||
if (once.value) return
|
||||
once.value = true
|
||||
console.error("WebSocket error:", error)
|
||||
local.onConnectError?.(error)
|
||||
local.onConnectError?.(err)
|
||||
}
|
||||
socket.addEventListener("error", handleError)
|
||||
|
||||
const handleClose = (event: CloseEvent) => {
|
||||
const gone = () =>
|
||||
client.pty
|
||||
.get({ ptyID: id })
|
||||
.then(() => false)
|
||||
.catch((err) => {
|
||||
if (errorName(err) === "NotFoundError") return true
|
||||
debugTerminal("failed to inspect terminal session", err)
|
||||
return false
|
||||
})
|
||||
|
||||
const retry = (err: unknown) => {
|
||||
if (disposed) return
|
||||
if (closing) return
|
||||
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
|
||||
// For other codes (network issues, server restart), trigger error handler
|
||||
if (event.code !== 1000) {
|
||||
if (once.value) return
|
||||
once.value = true
|
||||
local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`))
|
||||
}
|
||||
}
|
||||
socket.addEventListener("close", handleClose)
|
||||
if (reconn !== undefined) return
|
||||
|
||||
cleanups.push(() => {
|
||||
closing = true
|
||||
socket.removeEventListener("open", handleOpen)
|
||||
socket.removeEventListener("message", handleMessage)
|
||||
socket.removeEventListener("error", handleError)
|
||||
socket.removeEventListener("close", handleClose)
|
||||
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
|
||||
const ms = Math.min(250 * 2 ** Math.min(tries, 4), 4_000)
|
||||
reconn = setTimeout(async () => {
|
||||
reconn = undefined
|
||||
if (disposed) return
|
||||
if (await gone()) {
|
||||
if (disposed) return
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
if (disposed) return
|
||||
tries += 1
|
||||
open()
|
||||
}, ms)
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
if (disposed) return
|
||||
drop?.()
|
||||
|
||||
const next = new URL(url + `/pty/${id}/connect`)
|
||||
next.searchParams.set("directory", directory)
|
||||
next.searchParams.set("cursor", String(seek))
|
||||
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||
next.username = username
|
||||
next.password = password
|
||||
|
||||
const socket = new WebSocket(next)
|
||||
socket.binaryType = "arraybuffer"
|
||||
ws = socket
|
||||
|
||||
const handleOpen = () => {
|
||||
if (disposed) return
|
||||
tries = 0
|
||||
probe.connect()
|
||||
local.onConnect?.()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
}
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (disposed) return
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(event.data)
|
||||
if (bytes[0] !== 0) return
|
||||
const json = decoder.decode(bytes.subarray(1))
|
||||
try {
|
||||
const meta = JSON.parse(json) as { cursor?: unknown }
|
||||
const next = meta?.cursor
|
||||
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
|
||||
cursor = next
|
||||
seek = next
|
||||
}
|
||||
} catch (err) {
|
||||
debugTerminal("invalid websocket control frame", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const data = typeof event.data === "string" ? event.data : ""
|
||||
if (!data) return
|
||||
output?.push(data)
|
||||
cursor += data.length
|
||||
seek = cursor
|
||||
}
|
||||
|
||||
const handleError = (error: Event) => {
|
||||
if (disposed) return
|
||||
debugTerminal("websocket error", error)
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
socket.removeEventListener("open", handleOpen)
|
||||
socket.removeEventListener("message", handleMessage)
|
||||
socket.removeEventListener("error", handleError)
|
||||
socket.removeEventListener("close", handleClose)
|
||||
if (ws === socket) ws = undefined
|
||||
if (drop === stop) drop = undefined
|
||||
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
|
||||
}
|
||||
|
||||
const handleClose = (event: CloseEvent) => {
|
||||
if (ws === socket) ws = undefined
|
||||
if (drop === stop) drop = undefined
|
||||
socket.removeEventListener("open", handleOpen)
|
||||
socket.removeEventListener("message", handleMessage)
|
||||
socket.removeEventListener("error", handleError)
|
||||
socket.removeEventListener("close", handleClose)
|
||||
if (disposed) return
|
||||
if (event.code === 1000) return
|
||||
retry(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code })))
|
||||
}
|
||||
|
||||
drop = stop
|
||||
socket.addEventListener("open", handleOpen)
|
||||
socket.addEventListener("message", handleMessage)
|
||||
socket.addEventListener("error", handleError)
|
||||
socket.addEventListener("close", handleClose)
|
||||
}
|
||||
|
||||
probe.control({
|
||||
disconnect: () => {
|
||||
if (!ws) return
|
||||
ws.close(4_000, "e2e")
|
||||
},
|
||||
})
|
||||
|
||||
open()
|
||||
}
|
||||
|
||||
void run().catch((err) => {
|
||||
@@ -541,6 +616,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
disposed = true
|
||||
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
|
||||
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
|
||||
if (reconn !== undefined) clearTimeout(reconn)
|
||||
drop?.()
|
||||
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
|
||||
|
||||
const finalize = () => {
|
||||
@@ -560,6 +637,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
<div
|
||||
ref={container}
|
||||
data-component="terminal"
|
||||
{...{ [terminalAttr]: id }}
|
||||
data-prevent-autofocus
|
||||
tabIndex={-1}
|
||||
style={{ "background-color": terminalColors().background }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, Show, untrack } from "solid-js"
|
||||
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -58,6 +58,12 @@ export function Titlebar() {
|
||||
})
|
||||
|
||||
const path = () => `${location.pathname}${location.search}${location.hash}`
|
||||
const creating = createMemo(() => {
|
||||
if (!params.dir) return false
|
||||
if (params.id) return false
|
||||
const parts = location.pathname.replace(/\/+$/, "").split("/")
|
||||
return parts.at(-1) === "session"
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const current = path()
|
||||
@@ -71,6 +77,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 back = () => {
|
||||
const next = backPath(history)
|
||||
@@ -206,63 +213,77 @@ export function Titlebar() {
|
||||
aria-label={language.t("command.sidebar.toggle")}
|
||||
aria-expanded={layout.sidebar.opened()}
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-left"}
|
||||
class="group-hover/sidebar-toggle:hidden"
|
||||
/>
|
||||
<Icon size="small" name="layout-left-partial" class="hidden group-hover/sidebar-toggle:inline-block" />
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.sidebar.opened() ? "layout-left" : "layout-left-partial"}
|
||||
class="hidden group-active/sidebar-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
<div class="hidden xl:flex items-center shrink-0">
|
||||
<Show when={params.dir}>
|
||||
<TooltipKeybind
|
||||
placement="bottom"
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
openDelay={2000}
|
||||
<div
|
||||
class="flex items-center shrink-0 w-8 mr-1"
|
||||
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="new-session"
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={() => {
|
||||
if (!params.dir) return
|
||||
navigate(`/${params.dir}/session`)
|
||||
<div
|
||||
class="transition-opacity"
|
||||
classList={{
|
||||
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
|
||||
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
|
||||
}}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="bottom"
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
openDelay={2000}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon={creating() ? "new-session-active" : "new-session"}
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
disabled={layout.sidebar.opened()}
|
||||
tabIndex={layout.sidebar.opened() ? -1 : undefined}
|
||||
onClick={() => {
|
||||
if (!params.dir) return
|
||||
navigate(`/${params.dir}/session`)
|
||||
}}
|
||||
aria-label={language.t("command.session.new")}
|
||||
aria-current={creating() ? "page" : undefined}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={hasProjects()}>
|
||||
<div
|
||||
class="flex items-center gap-0 transition-transform"
|
||||
classList={{
|
||||
"translate-x-0": !layout.sidebar.opened(),
|
||||
"-translate-x-[36px]": layout.sidebar.opened(),
|
||||
"duration-180 ease-out": !layout.sidebar.opened(),
|
||||
"duration-180 ease-in": layout.sidebar.opened(),
|
||||
}}
|
||||
>
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-left"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canBack()}
|
||||
onClick={back}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-right"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canForward()}
|
||||
onClick={forward}
|
||||
aria-label={language.t("common.goForward")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-0" classList={{ "ml-1": !!params.dir }}>
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-left"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canBack()}
|
||||
onClick={back}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-right"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canForward()}
|
||||
onClick={forward}
|
||||
aria-label={language.t("common.goForward")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
@@ -282,7 +303,7 @@ export function Titlebar() {
|
||||
>
|
||||
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
||||
<Show when={windows()}>
|
||||
<div class="w-6 shrink-0" />
|
||||
{!tauriApi() && <div class="w-36 shrink-0" />}
|
||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
89
packages/app/src/constants/file-picker.ts
Normal file
89
packages/app/src/constants/file-picker.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||
|
||||
export const ACCEPTED_FILE_TYPES = [
|
||||
...ACCEPTED_IMAGE_TYPES,
|
||||
"application/pdf",
|
||||
"text/*",
|
||||
"application/json",
|
||||
"application/ld+json",
|
||||
"application/toml",
|
||||
"application/x-toml",
|
||||
"application/x-yaml",
|
||||
"application/xml",
|
||||
"application/yaml",
|
||||
".c",
|
||||
".cc",
|
||||
".cjs",
|
||||
".conf",
|
||||
".cpp",
|
||||
".css",
|
||||
".csv",
|
||||
".cts",
|
||||
".env",
|
||||
".go",
|
||||
".gql",
|
||||
".graphql",
|
||||
".h",
|
||||
".hh",
|
||||
".hpp",
|
||||
".htm",
|
||||
".html",
|
||||
".ini",
|
||||
".java",
|
||||
".js",
|
||||
".json",
|
||||
".jsx",
|
||||
".log",
|
||||
".md",
|
||||
".mdx",
|
||||
".mjs",
|
||||
".mts",
|
||||
".py",
|
||||
".rb",
|
||||
".rs",
|
||||
".sass",
|
||||
".scss",
|
||||
".sh",
|
||||
".sql",
|
||||
".toml",
|
||||
".ts",
|
||||
".tsx",
|
||||
".txt",
|
||||
".xml",
|
||||
".yaml",
|
||||
".yml",
|
||||
".zsh",
|
||||
]
|
||||
|
||||
const MIME_EXT = new Map([
|
||||
["image/png", "png"],
|
||||
["image/jpeg", "jpg"],
|
||||
["image/gif", "gif"],
|
||||
["image/webp", "webp"],
|
||||
["application/pdf", "pdf"],
|
||||
["application/json", "json"],
|
||||
["application/ld+json", "jsonld"],
|
||||
["application/toml", "toml"],
|
||||
["application/x-toml", "toml"],
|
||||
["application/x-yaml", "yaml"],
|
||||
["application/xml", "xml"],
|
||||
["application/yaml", "yaml"],
|
||||
])
|
||||
|
||||
const TEXT_EXT = ["txt", "text", "md", "markdown", "log", "csv"]
|
||||
|
||||
export const ACCEPTED_FILE_EXTENSIONS = Array.from(
|
||||
new Set(
|
||||
ACCEPTED_FILE_TYPES.flatMap((item) => {
|
||||
if (item.startsWith(".")) return [item.slice(1)]
|
||||
if (item === "text/*") return TEXT_EXT
|
||||
const out = MIME_EXT.get(item)
|
||||
return out ? [out] : []
|
||||
}),
|
||||
),
|
||||
).sort()
|
||||
|
||||
export function filePickerFilters(ext?: string[]) {
|
||||
if (!ext || ext.length === 0) return undefined
|
||||
return [{ name: "Files", extensions: ext }]
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
@@ -13,6 +14,27 @@ const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
||||
const SUGGESTED_PREFIX = "suggested."
|
||||
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
|
||||
|
||||
type KeyLabel =
|
||||
| "common.key.ctrl"
|
||||
| "common.key.alt"
|
||||
| "common.key.shift"
|
||||
| "common.key.meta"
|
||||
| "common.key.space"
|
||||
| "common.key.backspace"
|
||||
| "common.key.enter"
|
||||
| "common.key.tab"
|
||||
| "common.key.delete"
|
||||
| "common.key.home"
|
||||
| "common.key.end"
|
||||
| "common.key.pageUp"
|
||||
| "common.key.pageDown"
|
||||
| "common.key.insert"
|
||||
| "common.key.esc"
|
||||
|
||||
function keyText(key: KeyLabel, t?: (key: KeyLabel) => string) {
|
||||
return t ? t(key) : en[key]
|
||||
}
|
||||
|
||||
function actionId(id: string) {
|
||||
if (!id.startsWith(SUGGESTED_PREFIX)) return id
|
||||
return id.slice(SUGGESTED_PREFIX.length)
|
||||
@@ -145,7 +167,7 @@ export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean
|
||||
return false
|
||||
}
|
||||
|
||||
export function formatKeybind(config: string): string {
|
||||
export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string {
|
||||
if (!config || config === "none") return ""
|
||||
|
||||
const keybinds = parseKeybind(config)
|
||||
@@ -154,10 +176,10 @@ export function formatKeybind(config: string): string {
|
||||
const kb = keybinds[0]
|
||||
const parts: string[] = []
|
||||
|
||||
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
|
||||
if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
|
||||
if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
|
||||
if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
|
||||
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t))
|
||||
if (kb.alt) parts.push(IS_MAC ? "⌥" : keyText("common.key.alt", t))
|
||||
if (kb.shift) parts.push(IS_MAC ? "⇧" : keyText("common.key.shift", t))
|
||||
if (kb.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t))
|
||||
|
||||
if (kb.key) {
|
||||
const keys: Record<string, string> = {
|
||||
@@ -167,10 +189,29 @@ export function formatKeybind(config: string): string {
|
||||
arrowright: "→",
|
||||
comma: ",",
|
||||
plus: "+",
|
||||
space: "Space",
|
||||
}
|
||||
const named: Record<string, KeyLabel> = {
|
||||
backspace: "common.key.backspace",
|
||||
delete: "common.key.delete",
|
||||
end: "common.key.end",
|
||||
enter: "common.key.enter",
|
||||
esc: "common.key.esc",
|
||||
escape: "common.key.esc",
|
||||
home: "common.key.home",
|
||||
insert: "common.key.insert",
|
||||
pagedown: "common.key.pageDown",
|
||||
pageup: "common.key.pageUp",
|
||||
space: "common.key.space",
|
||||
tab: "common.key.tab",
|
||||
}
|
||||
const key = kb.key.toLowerCase()
|
||||
const displayKey = keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1))
|
||||
const displayKey =
|
||||
keys[key] ??
|
||||
(named[key]
|
||||
? keyText(named[key], t)
|
||||
: key.length === 1
|
||||
? key.toUpperCase()
|
||||
: key.charAt(0).toUpperCase() + key.slice(1))
|
||||
parts.push(displayKey)
|
||||
}
|
||||
|
||||
@@ -197,9 +238,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
})
|
||||
const warnedDuplicates = new Set<string>()
|
||||
|
||||
type CommandCatalog = Record<string, CommandCatalogItem>
|
||||
const [catalog, setCatalog, _, catalogReady] = persisted(
|
||||
Persist.global("command.catalog.v1"),
|
||||
createStore<Record<string, CommandCatalogItem>>({}),
|
||||
createStore<CommandCatalog>({}),
|
||||
)
|
||||
|
||||
const bind = (id: string, def: KeybindConfig | undefined) => {
|
||||
@@ -218,7 +260,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
if (seen.has(opt.id)) {
|
||||
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
|
||||
warnedDuplicates.add(opt.id)
|
||||
console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`)
|
||||
console.warn(`[command] duplicate command id "${opt.id}" registered; keeping first entry`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -233,16 +275,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
createEffect(() => {
|
||||
if (!catalogReady()) return
|
||||
|
||||
for (const opt of registered()) {
|
||||
const id = actionId(opt.id)
|
||||
setCatalog(id, {
|
||||
title: opt.title,
|
||||
description: opt.description,
|
||||
category: opt.category,
|
||||
keybind: opt.keybind,
|
||||
slash: opt.slash,
|
||||
})
|
||||
}
|
||||
setCatalog(
|
||||
registered().reduce((acc, opt) => {
|
||||
const id = actionId(opt.id)
|
||||
acc[id] = {
|
||||
title: opt.title,
|
||||
description: opt.description,
|
||||
category: opt.category,
|
||||
keybind: opt.keybind,
|
||||
slash: opt.slash,
|
||||
}
|
||||
return acc
|
||||
}, {} as CommandCatalog),
|
||||
)
|
||||
})
|
||||
|
||||
const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
|
||||
@@ -364,17 +409,17 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
},
|
||||
keybind(id: string) {
|
||||
if (id === PALETTE_ID) {
|
||||
return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
|
||||
return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND, language.t)
|
||||
}
|
||||
|
||||
const base = actionId(id)
|
||||
const option = options().find((x) => actionId(x.id) === base)
|
||||
if (option?.keybind) return formatKeybind(option.keybind)
|
||||
if (option?.keybind) return formatKeybind(option.keybind, language.t)
|
||||
|
||||
const meta = catalog[base]
|
||||
const config = bind(base, meta?.keybind)
|
||||
if (!config) return ""
|
||||
return formatKeybind(config)
|
||||
return formatKeybind(config, language.t)
|
||||
},
|
||||
show: showPalette,
|
||||
keybinds(enabled: boolean) {
|
||||
|
||||
@@ -43,10 +43,10 @@ export {
|
||||
touchFileContent,
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
if (typeof error === "string" && error) return error
|
||||
return "Unknown error"
|
||||
return fallback
|
||||
}
|
||||
|
||||
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
@@ -184,7 +184,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
})
|
||||
.catch((e) => {
|
||||
if (scope() !== directory) return
|
||||
setLoadError(file, errorMessage(e))
|
||||
setLoadError(file, errorMessage(e, language.t("error.chain.unknown")))
|
||||
})
|
||||
.finally(() => {
|
||||
inflight.delete(key)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { batch, onCleanup } from "solid-js"
|
||||
import z from "zod"
|
||||
import { createSdkForServer } from "@/utils/server"
|
||||
import { useLanguage } from "./language"
|
||||
import { usePlatform } from "./platform"
|
||||
import { useServer } from "./server"
|
||||
|
||||
@@ -14,6 +15,7 @@ const abortError = z.object({
|
||||
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
||||
name: "GlobalSDK",
|
||||
init: () => {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const abort = new AbortController()
|
||||
@@ -30,7 +32,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
})()
|
||||
|
||||
const currentServer = server.current
|
||||
if (!currentServer) throw new Error("No server available")
|
||||
if (!currentServer) throw new Error(language.t("error.globalSDK.noServerAvailable"))
|
||||
|
||||
const eventSdk = createSdkForServer({
|
||||
signal: abort.signal,
|
||||
@@ -218,7 +220,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
event: emitter,
|
||||
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
||||
const s = server.current
|
||||
if (!s) throw new Error("Server not available")
|
||||
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
|
||||
return createSdkForServer({
|
||||
server: s.http,
|
||||
fetch: platform.fetch,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
canDisposeDirectory,
|
||||
estimateRootSessionTotal,
|
||||
loadRootSessionsWithFallback,
|
||||
pickDirectoriesToEvict,
|
||||
} from "./global-sync"
|
||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
|
||||
describe("pickDirectoriesToEvict", () => {
|
||||
test("keeps pinned stores and evicts idle stores", () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import { trimSessions } from "./global-sync/session-trim"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
@@ -161,7 +162,9 @@ function createGlobalSync() {
|
||||
queue.clear(directory)
|
||||
sessionMeta.delete(directory)
|
||||
sdkCache.delete(directory)
|
||||
clearSessionPrefetchDirectory(directory)
|
||||
},
|
||||
translate: language.t,
|
||||
})
|
||||
|
||||
const sdkFor = (directory: string) => {
|
||||
@@ -375,6 +378,7 @@ function createGlobalSync() {
|
||||
return globalStore.error
|
||||
},
|
||||
child: children.child,
|
||||
peek: children.peek,
|
||||
bootstrap,
|
||||
updateConfig,
|
||||
project: projectApi,
|
||||
@@ -402,6 +406,3 @@ export function useGlobalSync() {
|
||||
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
|
||||
return context
|
||||
}
|
||||
|
||||
export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
|
||||
@@ -139,7 +139,7 @@ export async function bootstrapDirectory(input: {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: `Failed to reload ${project}`,
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
input.setStore("status", "partial")
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("createChildStoreManager", () => {
|
||||
isLoadingSessions: () => false,
|
||||
onBootstrap() {},
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
})
|
||||
|
||||
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
|
||||
|
||||
@@ -21,6 +21,7 @@ export function createChildStoreManager(input: {
|
||||
isLoadingSessions: (directory: string) => boolean
|
||||
onBootstrap: (directory: string) => void
|
||||
onDispose: (directory: string) => void
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
}) {
|
||||
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||
const vcsCache = new Map<string, VcsCache>()
|
||||
@@ -129,7 +130,7 @@ export function createChildStoreManager(input: {
|
||||
createStore({ value: undefined as VcsInfo | undefined }),
|
||||
),
|
||||
)
|
||||
if (!vcs) throw new Error("Failed to create persisted cache")
|
||||
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
|
||||
const vcsStore = vcs[0]
|
||||
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
|
||||
|
||||
@@ -139,7 +140,7 @@ export function createChildStoreManager(input: {
|
||||
createStore({ value: undefined as ProjectMeta | undefined }),
|
||||
),
|
||||
)
|
||||
if (!meta) throw new Error("Failed to create persisted project metadata")
|
||||
if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed"))
|
||||
metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
|
||||
|
||||
const icon = runWithOwner(input.owner, () =>
|
||||
@@ -148,7 +149,7 @@ export function createChildStoreManager(input: {
|
||||
createStore({ value: undefined as string | undefined }),
|
||||
),
|
||||
)
|
||||
if (!icon) throw new Error("Failed to create persisted project icon")
|
||||
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
|
||||
iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
|
||||
|
||||
const init = () =>
|
||||
@@ -211,7 +212,7 @@ export function createChildStoreManager(input: {
|
||||
}
|
||||
mark(directory)
|
||||
const childStore = children[directory]
|
||||
if (!childStore) throw new Error("Failed to create store")
|
||||
if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed"))
|
||||
return childStore
|
||||
}
|
||||
|
||||
@@ -225,6 +226,15 @@ export function createChildStoreManager(input: {
|
||||
return childStore
|
||||
}
|
||||
|
||||
function peek(directory: string, options: ChildOptions = {}) {
|
||||
const childStore = ensureChild(directory)
|
||||
const shouldBootstrap = options.bootstrap ?? true
|
||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||
input.onBootstrap(directory)
|
||||
}
|
||||
return childStore
|
||||
}
|
||||
|
||||
function projectMeta(directory: string, patch: ProjectMeta) {
|
||||
const [store, setStore] = ensureChild(directory)
|
||||
const cached = metaCache.get(directory)
|
||||
@@ -255,6 +265,7 @@ export function createChildStoreManager(input: {
|
||||
children,
|
||||
ensureChild,
|
||||
child,
|
||||
peek,
|
||||
projectMeta,
|
||||
projectIcon,
|
||||
mark,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
clearSessionPrefetch,
|
||||
clearSessionPrefetchDirectory,
|
||||
getSessionPrefetch,
|
||||
runSessionPrefetch,
|
||||
setSessionPrefetch,
|
||||
shouldSkipSessionPrefetch,
|
||||
} from "./session-prefetch"
|
||||
|
||||
describe("session prefetch", () => {
|
||||
test("stores and clears message metadata by directory", () => {
|
||||
clearSessionPrefetch("/tmp/a", ["ses_1"])
|
||||
clearSessionPrefetch("/tmp/b", ["ses_1"])
|
||||
|
||||
setSessionPrefetch({
|
||||
directory: "/tmp/a",
|
||||
sessionID: "ses_1",
|
||||
limit: 200,
|
||||
cursor: "abc",
|
||||
complete: false,
|
||||
at: 123,
|
||||
})
|
||||
|
||||
expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, cursor: "abc", complete: false, at: 123 })
|
||||
expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined()
|
||||
|
||||
clearSessionPrefetch("/tmp/a", ["ses_1"])
|
||||
|
||||
expect(getSessionPrefetch("/tmp/a", "ses_1")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("dedupes inflight work", async () => {
|
||||
clearSessionPrefetch("/tmp/c", ["ses_2"])
|
||||
|
||||
let calls = 0
|
||||
const run = () =>
|
||||
runSessionPrefetch({
|
||||
directory: "/tmp/c",
|
||||
sessionID: "ses_2",
|
||||
task: async () => {
|
||||
calls += 1
|
||||
return { limit: 100, cursor: "next", complete: true, at: 456 }
|
||||
},
|
||||
})
|
||||
|
||||
const [a, b] = await Promise.all([run(), run()])
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(a).toEqual({ limit: 100, cursor: "next", complete: true, at: 456 })
|
||||
expect(b).toEqual({ limit: 100, cursor: "next", complete: true, at: 456 })
|
||||
})
|
||||
|
||||
test("clears a whole directory", () => {
|
||||
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, cursor: "a", complete: true, at: 1 })
|
||||
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, cursor: "b", complete: false, at: 2 })
|
||||
setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, cursor: "c", complete: true, at: 3 })
|
||||
|
||||
clearSessionPrefetchDirectory("/tmp/d")
|
||||
|
||||
expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined()
|
||||
expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined()
|
||||
expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 })
|
||||
})
|
||||
|
||||
test("refreshes stale first-page prefetched history", () => {
|
||||
expect(
|
||||
shouldSkipSessionPrefetch({
|
||||
message: true,
|
||||
info: { limit: 200, cursor: "x", complete: false, at: 1 },
|
||||
chunk: 200,
|
||||
now: 1 + 15_001,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps deeper or complete history cached", () => {
|
||||
expect(
|
||||
shouldSkipSessionPrefetch({
|
||||
message: true,
|
||||
info: { limit: 400, cursor: "x", complete: false, at: 1 },
|
||||
chunk: 200,
|
||||
now: 1 + 15_001,
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
shouldSkipSessionPrefetch({
|
||||
message: true,
|
||||
info: { limit: 120, complete: true, at: 1 },
|
||||
chunk: 200,
|
||||
now: 1 + 15_001,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
100
packages/app/src/context/global-sync/session-prefetch.ts
Normal file
100
packages/app/src/context/global-sync/session-prefetch.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}`
|
||||
|
||||
export const SESSION_PREFETCH_TTL = 15_000
|
||||
|
||||
type Meta = {
|
||||
limit: number
|
||||
cursor?: string
|
||||
complete: boolean
|
||||
at: number
|
||||
}
|
||||
|
||||
export function shouldSkipSessionPrefetch(input: { message: boolean; info?: Meta; chunk: number; now?: number }) {
|
||||
if (input.message) {
|
||||
if (!input.info) return true
|
||||
if (input.info.complete) return true
|
||||
if (input.info.limit > input.chunk) return true
|
||||
} else {
|
||||
if (!input.info) return false
|
||||
}
|
||||
|
||||
return (input.now ?? Date.now()) - input.info.at < SESSION_PREFETCH_TTL
|
||||
}
|
||||
|
||||
const cache = new Map<string, Meta>()
|
||||
const inflight = new Map<string, Promise<Meta | undefined>>()
|
||||
const rev = new Map<string, number>()
|
||||
|
||||
const version = (id: string) => rev.get(id) ?? 0
|
||||
|
||||
export function getSessionPrefetch(directory: string, sessionID: string) {
|
||||
return cache.get(key(directory, sessionID))
|
||||
}
|
||||
|
||||
export function getSessionPrefetchPromise(directory: string, sessionID: string) {
|
||||
return inflight.get(key(directory, sessionID))
|
||||
}
|
||||
|
||||
export function clearSessionPrefetchInflight() {
|
||||
inflight.clear()
|
||||
}
|
||||
|
||||
export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) {
|
||||
return version(key(directory, sessionID)) === value
|
||||
}
|
||||
|
||||
export function runSessionPrefetch(input: {
|
||||
directory: string
|
||||
sessionID: string
|
||||
task: (value: number) => Promise<Meta | undefined>
|
||||
}) {
|
||||
const id = key(input.directory, input.sessionID)
|
||||
const pending = inflight.get(id)
|
||||
if (pending) return pending
|
||||
|
||||
const value = version(id)
|
||||
|
||||
const promise = input.task(value).finally(() => {
|
||||
if (inflight.get(id) === promise) inflight.delete(id)
|
||||
})
|
||||
|
||||
inflight.set(id, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
export function setSessionPrefetch(input: {
|
||||
directory: string
|
||||
sessionID: string
|
||||
limit: number
|
||||
cursor?: string
|
||||
complete: boolean
|
||||
at?: number
|
||||
}) {
|
||||
cache.set(key(input.directory, input.sessionID), {
|
||||
limit: input.limit,
|
||||
cursor: input.cursor,
|
||||
complete: input.complete,
|
||||
at: input.at ?? Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export function clearSessionPrefetch(directory: string, sessionIDs: Iterable<string>) {
|
||||
for (const sessionID of sessionIDs) {
|
||||
if (!sessionID) continue
|
||||
const id = key(directory, sessionID)
|
||||
rev.set(id, version(id) + 1)
|
||||
cache.delete(id)
|
||||
inflight.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSessionPrefetchDirectory(directory: string) {
|
||||
const prefix = `${directory}\n`
|
||||
const keys = new Set([...cache.keys(), ...inflight.keys()])
|
||||
for (const id of keys) {
|
||||
if (!id.startsWith(prefix)) continue
|
||||
rev.set(id, version(id) + 1)
|
||||
cache.delete(id)
|
||||
inflight.delete(id)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -146,8 +146,10 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
||||
const settings = useSettings()
|
||||
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
|
||||
|
||||
const [from, setFrom] = createSignal<string | undefined>(undefined)
|
||||
const [to, setTo] = createSignal<string | undefined>(undefined)
|
||||
const [range, setRange] = createStore({
|
||||
from: undefined as string | undefined,
|
||||
to: undefined as string | undefined,
|
||||
})
|
||||
const state = { started: false }
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
@@ -214,15 +216,14 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
||||
|
||||
if (previous === platform.version) return
|
||||
|
||||
setFrom(previous)
|
||||
setTo(platform.version)
|
||||
setRange({ from: previous, to: platform.version })
|
||||
start(previous)
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
from,
|
||||
to,
|
||||
from: () => range.from,
|
||||
to: () => range.to,
|
||||
get last() {
|
||||
return store.version
|
||||
},
|
||||
|
||||
@@ -793,20 +793,67 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
},
|
||||
review: {
|
||||
open: createMemo(() => s().reviewOpen),
|
||||
open: createMemo(() => s().reviewOpen ?? []),
|
||||
setOpen(open: string[]) {
|
||||
const session = key()
|
||||
const next = Array.from(new Set(open))
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, {
|
||||
scroll: {},
|
||||
reviewOpen: next,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (same(current.reviewOpen, next)) return
|
||||
setStore("sessionView", session, "reviewOpen", next)
|
||||
},
|
||||
openPath(path: string) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, {
|
||||
scroll: {},
|
||||
reviewOpen: open,
|
||||
reviewOpen: [path],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (same(current.reviewOpen, open)) return
|
||||
setStore("sessionView", session, "reviewOpen", open)
|
||||
if (!current.reviewOpen) {
|
||||
setStore("sessionView", session, "reviewOpen", [path])
|
||||
return
|
||||
}
|
||||
|
||||
if (current.reviewOpen.includes(path)) return
|
||||
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
|
||||
},
|
||||
closePath(path: string) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]?.reviewOpen
|
||||
if (!current) return
|
||||
|
||||
const index = current.indexOf(path)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"sessionView",
|
||||
session,
|
||||
"reviewOpen",
|
||||
produce((draft) => {
|
||||
if (!draft) return
|
||||
draft.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
},
|
||||
togglePath(path: string) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]?.reviewOpen
|
||||
if (!current || !current.includes(path)) {
|
||||
this.openPath(path)
|
||||
return
|
||||
}
|
||||
|
||||
this.closePath(path)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,252 +1,422 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { batch, createMemo } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useModels } from "@/context/models"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { modelEnabled, modelProbe } from "@/testing/model-selection"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useModels } from "@/context/models"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||
|
||||
export type ModelKey = { providerID: string; modelID: string }
|
||||
|
||||
type State = {
|
||||
agent?: string
|
||||
model?: ModelKey
|
||||
variant?: string | null
|
||||
}
|
||||
|
||||
type Saved = {
|
||||
session: Record<string, State | undefined>
|
||||
}
|
||||
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
const handoff = new Map<string, State>()
|
||||
|
||||
const handoffKey = (dir: string, id: string) => `${dir}\n${id}`
|
||||
|
||||
const migrate = (value: unknown) => {
|
||||
if (!value || typeof value !== "object") return { session: {} }
|
||||
|
||||
const item = value as {
|
||||
session?: Record<string, State | undefined>
|
||||
pick?: Record<string, State | undefined>
|
||||
}
|
||||
|
||||
if (item.session && typeof item.session === "object") return { session: item.session }
|
||||
if (!item.pick || typeof item.pick !== "object") return { session: {} }
|
||||
|
||||
return {
|
||||
session: Object.fromEntries(Object.entries(item.pick).filter(([key]) => key !== WORKSPACE_KEY)),
|
||||
}
|
||||
}
|
||||
|
||||
const clone = (value: State | undefined) => {
|
||||
if (!value) return undefined
|
||||
return {
|
||||
...value,
|
||||
model: value.model ? { ...value.model } : undefined,
|
||||
} satisfies State
|
||||
}
|
||||
|
||||
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
name: "Local",
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const providers = useProviders()
|
||||
const connected = createMemo(() => new Set(providers.connected().map((provider) => provider.id)))
|
||||
const models = useModels()
|
||||
|
||||
function isModelValid(model: ModelKey) {
|
||||
const provider = providers.all().find((x) => x.id === model.providerID)
|
||||
const id = createMemo(() => params.id || undefined)
|
||||
const list = createMemo(() => sync.data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||
|
||||
const [saved, setSaved] = persisted(
|
||||
{
|
||||
...Persist.workspace(sdk.directory, "model-selection", ["model-selection.v1"]),
|
||||
migrate,
|
||||
},
|
||||
createStore<Saved>({
|
||||
session: {},
|
||||
}),
|
||||
)
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
current?: string
|
||||
draft?: State
|
||||
last?: {
|
||||
type: "agent" | "model" | "variant"
|
||||
agent?: string
|
||||
model?: ModelKey | null
|
||||
variant?: string | null
|
||||
}
|
||||
}>({
|
||||
current: list()[0]?.name,
|
||||
draft: undefined,
|
||||
last: undefined,
|
||||
})
|
||||
|
||||
const validModel = (model: ModelKey) => {
|
||||
const provider = providers.all().find((item) => item.id === model.providerID)
|
||||
return !!provider?.models[model.modelID] && connected().has(model.providerID)
|
||||
}
|
||||
|
||||
function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
|
||||
for (const modelFn of modelFns) {
|
||||
const model = modelFn()
|
||||
const firstModel = (...items: Array<() => ModelKey | undefined>) => {
|
||||
for (const item of items) {
|
||||
const model = item()
|
||||
if (!model) continue
|
||||
if (isModelValid(model)) return model
|
||||
if (validModel(model)) return model
|
||||
}
|
||||
}
|
||||
|
||||
let setModel: (model: ModelKey | undefined, options?: { recent?: boolean }) => void = () => undefined
|
||||
const pickAgent = (name: string | undefined) => {
|
||||
const items = list()
|
||||
if (items.length === 0) return undefined
|
||||
return items.find((item) => item.name === name) ?? items[0]
|
||||
}
|
||||
|
||||
const agent = (() => {
|
||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
||||
const models = useModels()
|
||||
createEffect(() => {
|
||||
const items = list()
|
||||
if (items.length === 0) {
|
||||
if (store.current !== undefined) setStore("current", undefined)
|
||||
return
|
||||
}
|
||||
if (items.some((item) => item.name === store.current)) return
|
||||
setStore("current", items[0]?.name)
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
current?: string
|
||||
}>({
|
||||
current: list()[0]?.name,
|
||||
const scope = createMemo<State | undefined>(() => {
|
||||
const session = id()
|
||||
if (!session) return store.draft
|
||||
return saved.session[session] ?? handoff.get(handoffKey(sdk.directory, session))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const session = id()
|
||||
if (!session) return
|
||||
|
||||
const key = handoffKey(sdk.directory, session)
|
||||
const next = handoff.get(key)
|
||||
if (!next) return
|
||||
if (saved.session[session] !== undefined) {
|
||||
handoff.delete(key)
|
||||
return
|
||||
}
|
||||
|
||||
setSaved("session", session, clone(next))
|
||||
handoff.delete(key)
|
||||
})
|
||||
|
||||
const configuredModel = () => {
|
||||
if (!sync.data.config.model) return
|
||||
const [providerID, modelID] = sync.data.config.model.split("/")
|
||||
const model = { providerID, modelID }
|
||||
if (validModel(model)) return model
|
||||
}
|
||||
|
||||
const recentModel = () => {
|
||||
for (const item of models.recent.list()) {
|
||||
if (validModel(item)) return item
|
||||
}
|
||||
}
|
||||
|
||||
const defaultModel = () => {
|
||||
const defaults = providers.default()
|
||||
for (const provider of providers.connected()) {
|
||||
const configured = defaults[provider.id]
|
||||
if (configured) {
|
||||
const model = { providerID: provider.id, modelID: configured }
|
||||
if (validModel(model)) return model
|
||||
}
|
||||
|
||||
const first = Object.values(provider.models)[0]
|
||||
if (!first) continue
|
||||
const model = { providerID: provider.id, modelID: first.id }
|
||||
if (validModel(model)) return model
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = createMemo<ModelKey | undefined>(() => configuredModel() ?? recentModel() ?? defaultModel())
|
||||
|
||||
const agent = {
|
||||
list,
|
||||
current() {
|
||||
return pickAgent(scope()?.agent ?? store.current)
|
||||
},
|
||||
set(name: string | undefined) {
|
||||
const item = pickAgent(name)
|
||||
if (!item) {
|
||||
setStore("current", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
setStore("current", item.name)
|
||||
setStore("last", {
|
||||
type: "agent",
|
||||
agent: item.name,
|
||||
model: item.model,
|
||||
variant: item.variant ?? null,
|
||||
})
|
||||
const prev = scope()
|
||||
const next = {
|
||||
agent: item.name,
|
||||
model: item.model ?? prev?.model,
|
||||
variant: item.variant ?? prev?.variant,
|
||||
} satisfies State
|
||||
const session = id()
|
||||
if (session) {
|
||||
setSaved("session", session, next)
|
||||
return
|
||||
}
|
||||
setStore("draft", next)
|
||||
})
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
const items = list()
|
||||
if (items.length === 0) {
|
||||
setStore("current", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
let next = items.findIndex((item) => item.name === agent.current()?.name) + direction
|
||||
if (next < 0) next = items.length - 1
|
||||
if (next >= items.length) next = 0
|
||||
const item = items[next]
|
||||
if (!item) return
|
||||
agent.set(item.name)
|
||||
},
|
||||
}
|
||||
|
||||
const current = () => {
|
||||
const item = firstModel(
|
||||
() => scope()?.model,
|
||||
() => agent.current()?.model,
|
||||
fallback,
|
||||
)
|
||||
if (!item) return undefined
|
||||
return models.find(item)
|
||||
}
|
||||
|
||||
const configured = () => {
|
||||
const item = agent.current()
|
||||
const model = current()
|
||||
if (!item || !model) return undefined
|
||||
return getConfiguredAgentVariant({
|
||||
agent: { model: item.model, variant: item.variant },
|
||||
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
|
||||
})
|
||||
}
|
||||
|
||||
const selected = () => scope()?.variant
|
||||
|
||||
const snapshot = () => {
|
||||
const model = current()
|
||||
return {
|
||||
list,
|
||||
current() {
|
||||
const available = list()
|
||||
if (available.length === 0) return undefined
|
||||
return available.find((x) => x.name === store.current) ?? available[0]
|
||||
},
|
||||
set(name: string | undefined) {
|
||||
const available = list()
|
||||
if (available.length === 0) {
|
||||
setStore("current", undefined)
|
||||
return
|
||||
}
|
||||
const match = name ? available.find((x) => x.name === name) : undefined
|
||||
const value = match ?? available[0]
|
||||
if (!value) return
|
||||
setStore("current", value.name)
|
||||
if (!value.model) return
|
||||
setModel({
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
if (value.variant)
|
||||
models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
const available = list()
|
||||
if (available.length === 0) {
|
||||
setStore("current", undefined)
|
||||
return
|
||||
}
|
||||
let next = available.findIndex((x) => x.name === store.current) + direction
|
||||
if (next < 0) next = available.length - 1
|
||||
if (next >= available.length) next = 0
|
||||
const value = available[next]
|
||||
if (!value) return
|
||||
setStore("current", value.name)
|
||||
if (!value.model) return
|
||||
setModel({
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
if (value.variant)
|
||||
models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
|
||||
},
|
||||
agent: agent.current()?.name,
|
||||
model: model ? { providerID: model.provider.id, modelID: model.id } : undefined,
|
||||
variant: selected(),
|
||||
} satisfies State
|
||||
}
|
||||
|
||||
const write = (next: Partial<State>) => {
|
||||
const state = {
|
||||
...(scope() ?? { agent: agent.current()?.name }),
|
||||
...next,
|
||||
} satisfies State
|
||||
|
||||
const session = id()
|
||||
if (session) {
|
||||
setSaved("session", session, state)
|
||||
return
|
||||
}
|
||||
})()
|
||||
setStore("draft", state)
|
||||
}
|
||||
|
||||
const model = (() => {
|
||||
const models = useModels()
|
||||
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
|
||||
|
||||
const [ephemeral, setEphemeral] = createStore<{
|
||||
model: Record<string, ModelKey | undefined>
|
||||
}>({
|
||||
model: {},
|
||||
})
|
||||
const model = {
|
||||
ready: models.ready,
|
||||
current,
|
||||
recent,
|
||||
list: models.list,
|
||||
cycle(direction: 1 | -1) {
|
||||
const items = recent()
|
||||
const item = current()
|
||||
if (!item) return
|
||||
|
||||
const resolveConfigured = () => {
|
||||
if (!sync.data.config.model) return
|
||||
const [providerID, modelID] = sync.data.config.model.split("/")
|
||||
const key = { providerID, modelID }
|
||||
if (isModelValid(key)) return key
|
||||
}
|
||||
|
||||
const resolveRecent = () => {
|
||||
for (const item of models.recent.list()) {
|
||||
if (isModelValid(item)) return item
|
||||
}
|
||||
}
|
||||
|
||||
const resolveDefault = () => {
|
||||
const defaults = providers.default()
|
||||
for (const provider of providers.connected()) {
|
||||
const configured = defaults[provider.id]
|
||||
if (configured) {
|
||||
const key = { providerID: provider.id, modelID: configured }
|
||||
if (isModelValid(key)) return key
|
||||
}
|
||||
|
||||
const first = Object.values(provider.models)[0]
|
||||
if (!first) continue
|
||||
const key = { providerID: provider.id, modelID: first.id }
|
||||
if (isModelValid(key)) return key
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackModel = createMemo<ModelKey | undefined>(() => {
|
||||
return resolveConfigured() ?? resolveRecent() ?? resolveDefault()
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
const a = agent.current()
|
||||
if (!a) return undefined
|
||||
const key = getFirstValidModel(
|
||||
() => ephemeral.model[a.name],
|
||||
() => a.model,
|
||||
fallbackModel,
|
||||
)
|
||||
if (!key) return undefined
|
||||
return models.find(key)
|
||||
})
|
||||
|
||||
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
|
||||
|
||||
const cycle = (direction: 1 | -1) => {
|
||||
const recentList = recent()
|
||||
const currentModel = current()
|
||||
if (!currentModel) return
|
||||
|
||||
const index = recentList.findIndex(
|
||||
(x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id,
|
||||
)
|
||||
const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id)
|
||||
if (index === -1) return
|
||||
|
||||
let next = index + direction
|
||||
if (next < 0) next = recentList.length - 1
|
||||
if (next >= recentList.length) next = 0
|
||||
if (next < 0) next = items.length - 1
|
||||
if (next >= items.length) next = 0
|
||||
|
||||
const val = recentList[next]
|
||||
if (!val) return
|
||||
|
||||
model.set({
|
||||
providerID: val.provider.id,
|
||||
modelID: val.id,
|
||||
})
|
||||
}
|
||||
|
||||
const set = (model: ModelKey | undefined, options?: { recent?: boolean }) => {
|
||||
const entry = items[next]
|
||||
if (!entry) return
|
||||
model.set({ providerID: entry.provider.id, modelID: entry.id })
|
||||
},
|
||||
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
const currentAgent = agent.current()
|
||||
const next = model ?? fallbackModel()
|
||||
if (currentAgent) setEphemeral("model", currentAgent.name, next)
|
||||
if (model) models.setVisibility(model, true)
|
||||
if (options?.recent && model) models.recent.push(model)
|
||||
setStore("last", {
|
||||
type: "model",
|
||||
agent: agent.current()?.name,
|
||||
model: item ?? null,
|
||||
variant: selected(),
|
||||
})
|
||||
write({ model: item })
|
||||
if (!item) return
|
||||
models.setVisibility(item, true)
|
||||
if (!options?.recent) return
|
||||
models.recent.push(item)
|
||||
})
|
||||
}
|
||||
|
||||
setModel = set
|
||||
|
||||
return {
|
||||
ready: models.ready,
|
||||
current,
|
||||
recent,
|
||||
list: models.list,
|
||||
cycle,
|
||||
set,
|
||||
visible(model: ModelKey) {
|
||||
return models.visible(model)
|
||||
},
|
||||
visible(item: ModelKey) {
|
||||
return models.visible(item)
|
||||
},
|
||||
setVisibility(item: ModelKey, visible: boolean) {
|
||||
models.setVisibility(item, visible)
|
||||
},
|
||||
variant: {
|
||||
configured,
|
||||
selected,
|
||||
current() {
|
||||
return resolveModelVariant({
|
||||
variants: this.list(),
|
||||
selected: this.selected(),
|
||||
configured: this.configured(),
|
||||
})
|
||||
},
|
||||
setVisibility(model: ModelKey, visible: boolean) {
|
||||
models.setVisibility(model, visible)
|
||||
list() {
|
||||
const item = current()
|
||||
if (!item?.variants) return []
|
||||
return Object.keys(item.variants)
|
||||
},
|
||||
variant: {
|
||||
configured() {
|
||||
const a = agent.current()
|
||||
const m = current()
|
||||
if (!a || !m) return undefined
|
||||
return getConfiguredAgentVariant({
|
||||
agent: { model: a.model, variant: a.variant },
|
||||
model: { providerID: m.provider.id, modelID: m.id, variants: m.variants },
|
||||
set(value: string | undefined) {
|
||||
batch(() => {
|
||||
const model = current()
|
||||
setStore("last", {
|
||||
type: "variant",
|
||||
agent: agent.current()?.name,
|
||||
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
|
||||
variant: value ?? null,
|
||||
})
|
||||
},
|
||||
selected() {
|
||||
const m = current()
|
||||
if (!m) return undefined
|
||||
return models.variant.get({ providerID: m.provider.id, modelID: m.id })
|
||||
},
|
||||
current() {
|
||||
return resolveModelVariant({
|
||||
variants: this.list(),
|
||||
write({ variant: value ?? null })
|
||||
})
|
||||
},
|
||||
cycle() {
|
||||
const items = this.list()
|
||||
if (items.length === 0) return
|
||||
this.set(
|
||||
cycleModelVariant({
|
||||
variants: items,
|
||||
selected: this.selected(),
|
||||
configured: this.configured(),
|
||||
})
|
||||
},
|
||||
list() {
|
||||
const m = current()
|
||||
if (!m) return []
|
||||
if (!m.variants) return []
|
||||
return Object.keys(m.variants)
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
const m = current()
|
||||
if (!m) return
|
||||
models.variant.set({ providerID: m.provider.id, modelID: m.id }, value)
|
||||
},
|
||||
cycle() {
|
||||
const variants = this.list()
|
||||
if (variants.length === 0) return
|
||||
this.set(
|
||||
cycleModelVariant({
|
||||
variants,
|
||||
selected: this.selected(),
|
||||
configured: this.configured(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
})()
|
||||
},
|
||||
}
|
||||
|
||||
const result = {
|
||||
slug: createMemo(() => base64Encode(sdk.directory)),
|
||||
model,
|
||||
agent,
|
||||
session: {
|
||||
reset() {
|
||||
setStore("draft", undefined)
|
||||
},
|
||||
promote(dir: string, session: string) {
|
||||
const next = clone(snapshot())
|
||||
if (!next) return
|
||||
|
||||
if (dir === sdk.directory) {
|
||||
setSaved("session", session, next)
|
||||
setStore("draft", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
handoff.set(handoffKey(dir, session), next)
|
||||
setStore("draft", undefined)
|
||||
},
|
||||
restore(msg: { sessionID: string; agent: string; model: ModelKey; variant?: string }) {
|
||||
const session = id()
|
||||
if (!session) return
|
||||
if (msg.sessionID !== session) return
|
||||
if (saved.session[session] !== undefined) return
|
||||
if (handoff.has(handoffKey(sdk.directory, session))) return
|
||||
|
||||
setSaved("session", session, {
|
||||
agent: msg.agent,
|
||||
model: msg.model,
|
||||
variant: msg.variant ?? null,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if (modelEnabled()) {
|
||||
createEffect(() => {
|
||||
const agent = result.agent.current()
|
||||
const model = result.model.current()
|
||||
modelProbe.set({
|
||||
dir: sdk.directory,
|
||||
sessionID: id(),
|
||||
last: store.last,
|
||||
agent: agent?.name,
|
||||
model: model
|
||||
? {
|
||||
providerID: model.provider.id,
|
||||
modelID: model.id,
|
||||
name: model.name,
|
||||
}
|
||||
: undefined,
|
||||
variant: result.model.variant.current() ?? null,
|
||||
selected: result.model.variant.selected(),
|
||||
configured: result.model.variant.configured(),
|
||||
pick: scope(),
|
||||
base: undefined,
|
||||
current: store.current,
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => modelProbe.clear())
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
@@ -44,6 +44,16 @@ describe("model variant", () => {
|
||||
expect(value).toBe("high")
|
||||
})
|
||||
|
||||
test("lets an explicit default override the configured variant", () => {
|
||||
const value = resolveModelVariant({
|
||||
variants: ["low", "high", "xhigh"],
|
||||
selected: null,
|
||||
configured: "xhigh",
|
||||
})
|
||||
|
||||
expect(value).toBeUndefined()
|
||||
})
|
||||
|
||||
test("cycles from configured variant to next", () => {
|
||||
const value = cycleModelVariant({
|
||||
variants: ["low", "high", "xhigh"],
|
||||
@@ -63,4 +73,14 @@ describe("model variant", () => {
|
||||
|
||||
expect(value).toBe("low")
|
||||
})
|
||||
|
||||
test("cycles from an explicit default to the first variant", () => {
|
||||
const value = cycleModelVariant({
|
||||
variants: ["low", "high", "xhigh"],
|
||||
selected: null,
|
||||
configured: "xhigh",
|
||||
})
|
||||
|
||||
expect(value).toBe("low")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ type Model = AgentModel & {
|
||||
|
||||
type VariantInput = {
|
||||
variants: string[]
|
||||
selected: string | undefined
|
||||
selected: string | null | undefined
|
||||
configured: string | undefined
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export function getConfiguredAgentVariant(input: { agent: Agent | undefined; mod
|
||||
}
|
||||
|
||||
export function resolveModelVariant(input: VariantInput) {
|
||||
if (input.selected === null) return undefined
|
||||
if (input.selected && input.variants.includes(input.selected)) return input.selected
|
||||
if (input.configured && input.variants.includes(input.configured)) return input.configured
|
||||
return undefined
|
||||
@@ -36,6 +37,7 @@ export function resolveModelVariant(input: VariantInput) {
|
||||
|
||||
export function cycleModelVariant(input: VariantInput) {
|
||||
if (input.variants.length === 0) return undefined
|
||||
if (input.selected === null) return input.variants[0]
|
||||
if (input.selected && input.variants.includes(input.selected)) {
|
||||
const index = input.variants.indexOf(input.selected)
|
||||
if (index === input.variants.length - 1) return undefined
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
type NotificationIndexItem = {
|
||||
directory?: string
|
||||
session?: string
|
||||
viewed: boolean
|
||||
type: string
|
||||
}
|
||||
|
||||
export function buildNotificationIndex<T extends NotificationIndexItem>(list: T[]) {
|
||||
const sessionAll = new Map<string, T[]>()
|
||||
const sessionUnseen = new Map<string, T[]>()
|
||||
const sessionUnseenCount = new Map<string, number>()
|
||||
const sessionUnseenHasError = new Map<string, boolean>()
|
||||
const projectAll = new Map<string, T[]>()
|
||||
const projectUnseen = new Map<string, T[]>()
|
||||
const projectUnseenCount = new Map<string, number>()
|
||||
const projectUnseenHasError = new Map<string, boolean>()
|
||||
|
||||
for (const notification of list) {
|
||||
const session = notification.session
|
||||
if (session) {
|
||||
const all = sessionAll.get(session)
|
||||
if (all) all.push(notification)
|
||||
else sessionAll.set(session, [notification])
|
||||
|
||||
if (!notification.viewed) {
|
||||
const unseen = sessionUnseen.get(session)
|
||||
if (unseen) unseen.push(notification)
|
||||
else sessionUnseen.set(session, [notification])
|
||||
|
||||
sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1)
|
||||
if (notification.type === "error") sessionUnseenHasError.set(session, true)
|
||||
}
|
||||
}
|
||||
|
||||
const directory = notification.directory
|
||||
if (directory) {
|
||||
const all = projectAll.get(directory)
|
||||
if (all) all.push(notification)
|
||||
else projectAll.set(directory, [notification])
|
||||
|
||||
if (!notification.viewed) {
|
||||
const unseen = projectUnseen.get(directory)
|
||||
if (unseen) unseen.push(notification)
|
||||
else projectUnseen.set(directory, [notification])
|
||||
|
||||
projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1)
|
||||
if (notification.type === "error") projectUnseenHasError.set(directory, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
session: {
|
||||
all: sessionAll,
|
||||
unseen: sessionUnseen,
|
||||
unseenCount: sessionUnseenCount,
|
||||
unseenHasError: sessionUnseenHasError,
|
||||
},
|
||||
project: {
|
||||
all: projectAll,
|
||||
unseen: projectUnseen,
|
||||
unseenCount: projectUnseenCount,
|
||||
unseenHasError: projectUnseenHasError,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildNotificationIndex } from "./notification-index"
|
||||
|
||||
type Notification = {
|
||||
type: "turn-complete" | "error"
|
||||
session: string
|
||||
directory: string
|
||||
viewed: boolean
|
||||
time: number
|
||||
}
|
||||
|
||||
const turn = (session: string, directory: string, viewed = false): Notification => ({
|
||||
type: "turn-complete",
|
||||
session,
|
||||
directory,
|
||||
viewed,
|
||||
time: 1,
|
||||
})
|
||||
|
||||
const error = (session: string, directory: string, viewed = false): Notification => ({
|
||||
type: "error",
|
||||
session,
|
||||
directory,
|
||||
viewed,
|
||||
time: 1,
|
||||
})
|
||||
|
||||
describe("buildNotificationIndex", () => {
|
||||
test("builds unseen counts and unseen error flags", () => {
|
||||
const list = [
|
||||
turn("s1", "d1", false),
|
||||
error("s1", "d1", false),
|
||||
turn("s1", "d1", true),
|
||||
turn("s2", "d1", false),
|
||||
error("s3", "d2", true),
|
||||
]
|
||||
|
||||
const index = buildNotificationIndex(list)
|
||||
|
||||
expect(index.session.all.get("s1")?.length).toBe(3)
|
||||
expect(index.session.unseen.get("s1")?.length).toBe(2)
|
||||
expect(index.session.unseenCount.get("s1")).toBe(2)
|
||||
expect(index.session.unseenHasError.get("s1")).toBe(true)
|
||||
|
||||
expect(index.session.unseenCount.get("s2")).toBe(1)
|
||||
expect(index.session.unseenHasError.get("s2") ?? false).toBe(false)
|
||||
expect(index.session.unseenCount.get("s3") ?? 0).toBe(0)
|
||||
expect(index.session.unseenHasError.get("s3") ?? false).toBe(false)
|
||||
|
||||
expect(index.project.unseenCount.get("d1")).toBe(3)
|
||||
expect(index.project.unseenHasError.get("d1")).toBe(true)
|
||||
expect(index.project.unseenCount.get("d2") ?? 0).toBe(0)
|
||||
expect(index.project.unseenHasError.get("d2") ?? false).toBe(false)
|
||||
})
|
||||
|
||||
test("updates selectors after viewed transitions", () => {
|
||||
const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)]
|
||||
const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item))
|
||||
|
||||
const before = buildNotificationIndex(list)
|
||||
const after = buildNotificationIndex(next)
|
||||
|
||||
expect(before.session.unseenCount.get("s1")).toBe(2)
|
||||
expect(before.session.unseenHasError.get("s1")).toBe(true)
|
||||
expect(before.project.unseenCount.get("d1")).toBe(3)
|
||||
expect(before.project.unseenHasError.get("d1")).toBe(true)
|
||||
|
||||
expect(after.session.unseenCount.get("s1") ?? 0).toBe(0)
|
||||
expect(after.session.unseenHasError.get("s1") ?? false).toBe(false)
|
||||
expect(after.project.unseenCount.get("d1")).toBe(1)
|
||||
expect(after.project.unseenHasError.get("d1") ?? false).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||
import type { Accessor } from "solid-js"
|
||||
import { ServerConnection } from "./server"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
type OpenFilePickerOptions = { title?: string; multiple?: boolean }
|
||||
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
|
||||
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
||||
type UpdateInfo = { updateAvailable: boolean; version?: string }
|
||||
|
||||
@@ -58,10 +59,10 @@ export type Platform = {
|
||||
fetch?: typeof fetch
|
||||
|
||||
/** Get the configured default server URL (platform-specific) */
|
||||
getDefaultServerUrl?(): Promise<string | null>
|
||||
getDefaultServer?(): Promise<ServerConnection.Key | null>
|
||||
|
||||
/** Set the default server URL to use on app startup (platform-specific) */
|
||||
setDefaultServerUrl?(url: string | null): Promise<void> | void
|
||||
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
|
||||
|
||||
/** Get the configured WSL integration (desktop only) */
|
||||
getWslEnabled?(): Promise<boolean>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
@@ -151,6 +151,11 @@ const MAX_PROMPT_SESSIONS = 20
|
||||
|
||||
type PromptSession = ReturnType<typeof createPromptSession>
|
||||
|
||||
type Scope = {
|
||||
dir: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
type PromptCacheEntry = {
|
||||
value: PromptSession
|
||||
dispose: VoidFunction
|
||||
@@ -245,6 +250,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
}
|
||||
}
|
||||
|
||||
const owner = getOwner()
|
||||
const load = (dir: string, id: string | undefined) => {
|
||||
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
||||
const existing = cache.get(key)
|
||||
@@ -254,10 +260,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
return existing.value
|
||||
}
|
||||
|
||||
const entry = createRoot((dispose) => ({
|
||||
value: createPromptSession(dir, id),
|
||||
dispose,
|
||||
}))
|
||||
const entry = createRoot(
|
||||
(dispose) => ({
|
||||
value: createPromptSession(dir, id),
|
||||
dispose,
|
||||
}),
|
||||
owner,
|
||||
)
|
||||
|
||||
cache.set(key, entry)
|
||||
prune()
|
||||
@@ -265,6 +274,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
const session = createMemo(() => load(params.dir!, params.id))
|
||||
const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session())
|
||||
|
||||
return {
|
||||
ready: () => session().ready(),
|
||||
@@ -280,8 +290,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
session().context.updateComment(path, commentID, next),
|
||||
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
|
||||
},
|
||||
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
|
||||
reset: () => session().reset(),
|
||||
set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition),
|
||||
reset: (scope?: Scope) => pick(scope).reset(),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user