Compare commits
358
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
106eeb8536 | ||
|
|
888361892d | ||
|
|
a96f3010a6 | ||
|
|
619127c2ed | ||
|
|
6963f96d4b | ||
|
|
05a9e7ce7a | ||
|
|
896d18ab3f | ||
|
|
893888536a | ||
|
|
c6221fc8b3 | ||
|
|
ae67f43ff0 | ||
|
|
76880dce0d | ||
|
|
aafffb5b4b | ||
|
|
a71c9e3f2e | ||
|
|
0156f03e0e | ||
|
|
e0bb96a9f9 | ||
|
|
82e5d6d458 | ||
|
|
a4411c21b6 | ||
|
|
9d61370ac4 | ||
|
|
f3febd6e39 | ||
|
|
f12d55bf1e | ||
|
|
0c19b71f42 | ||
|
|
70fa66397e | ||
|
|
6e8cd3174c | ||
|
|
5bfffbe083 | ||
|
|
29d8557d41 | ||
|
|
ffd20b4477 | ||
|
|
2abaa46e23 | ||
|
|
0cbbb20d22 | ||
|
|
81c5e7b9ed | ||
|
|
ddf4897eaa | ||
|
|
040939fb72 | ||
|
|
f89b83a6d7 | ||
|
|
82a876da4d | ||
|
|
69a15ae9c1 | ||
|
|
18c8e5f451 | ||
|
|
ba3a1cfa0b | ||
|
|
d8563160f7 | ||
|
|
4a9ff9412e | ||
|
|
d6db6ff198 | ||
|
|
79c263494f | ||
|
|
1b5bf32ce5 | ||
|
|
2e972b3fdc | ||
|
|
d70e9fb01e | ||
|
|
fc082a0f14 | ||
|
|
953e4e9446 | ||
|
|
7ea0d37ee3 | ||
|
|
e35d97f9d7 | ||
|
|
2c0d9a46cb | ||
|
|
2fe7a7f2d3 | ||
|
|
8a2f4ddf70 | ||
|
|
7a94d7a2c5 | ||
|
|
de28fafb47 | ||
|
|
9d485dd307 | ||
|
|
613813ac12 | ||
|
|
7617f59441 | ||
|
|
7aecb43e84 | ||
|
|
21eba5f987 | ||
|
|
c523ca4127 | ||
|
|
685f3ea324 | ||
|
|
4667d57e3c | ||
|
|
e6b9988fa4 | ||
|
|
3c02d5d338 | ||
|
|
bfb9787361 | ||
|
|
1bcc72c477 | ||
|
|
4385fa4dd7 | ||
|
|
2b054bec95 | ||
|
|
2cdc88d295 | ||
|
|
f8fb08b3b4 | ||
|
|
ed06de5e30 | ||
|
|
52b99622ad | ||
|
|
a15397cd89 | ||
|
|
da394439a1 | ||
|
|
390b0a79b3 | ||
|
|
b2f45d574f | ||
|
|
1e2ef07c97 | ||
|
|
664e6bf2d0 | ||
|
|
160c8ab7cc | ||
|
|
1626341a4a | ||
|
|
61ddd1716d | ||
|
|
053a10e515 | ||
|
|
e1c1b1340b | ||
|
|
7a5fbdf67c | ||
|
|
9afc451020 | ||
|
|
f4fdf0eb03 | ||
|
|
505068d5a6 | ||
|
|
2e10ffac6b | ||
|
|
4abaa052db | ||
|
|
1bcf8d8806 | ||
|
|
25c68c8061 | ||
|
|
b0e4408ecf | ||
|
|
8416db03ef | ||
|
|
d5b47d9128 | ||
|
|
634559760a | ||
|
|
155ba794cf | ||
|
|
f1ab427f0e | ||
|
|
2333af6ed3 | ||
|
|
54588b4570 | ||
|
|
26e7043718 | ||
|
|
dd569c927a | ||
|
|
cf38884778 | ||
|
|
2946a6d9a7 | ||
|
|
3522c460e3 | ||
|
|
b6a264819e | ||
|
|
46c7a41d5f | ||
|
|
7cc4b24ac2 | ||
|
|
281ce4c0c3 | ||
|
|
f59d274d0f | ||
|
|
8886c78dce | ||
|
|
d9f0f58277 | ||
|
|
effa7b45cf | ||
|
|
b307075063 | ||
|
|
aaf9a5d434 | ||
|
|
e9c2f1f3f3 | ||
|
|
7469cba7cf | ||
|
|
5420702f69 | ||
|
|
583751ecae | ||
|
|
d0a1b5ef96 | ||
|
|
42f2bc7199 | ||
|
|
603dae562a | ||
|
|
650bd76370 | ||
|
|
8aa3520683 | ||
|
|
5b5b8c57d9 | ||
|
|
f057b22e20 | ||
|
|
388d40e41f | ||
|
|
f397c92ddf | ||
|
|
6f9bea4e1f | ||
|
|
5c49b4cbfc | ||
|
|
b746e831e2 | ||
|
|
2178deef91 | ||
|
|
b1d2fb5319 | ||
|
|
2284a4e6df | ||
|
|
ad852d9186 | ||
|
|
8a9b4245b4 | ||
|
|
76ac1ccb6b | ||
|
|
e71bc8c0b0 | ||
|
|
a5301e2ab7 | ||
|
|
8eac72341f | ||
|
|
bd139b4bd6 | ||
|
|
508578bf17 | ||
|
|
607d8aafb7 | ||
|
|
5843eca7d6 | ||
|
|
ff3b68bd36 | ||
|
|
474b6fd3d1 | ||
|
|
6145b197f3 | ||
|
|
918eff9233 | ||
|
|
987e444828 | ||
|
|
99633cb299 | ||
|
|
f822331eb8 | ||
|
|
0f053769db | ||
|
|
ceeaf494c4 | ||
|
|
126d887e57 | ||
|
|
e5cfc24d6b | ||
|
|
7f8d659737 | ||
|
|
4b061653f2 | ||
|
|
eeed89f985 | ||
|
|
8ab533b616 | ||
|
|
09a399d8d6 | ||
|
|
b75575884a | ||
|
|
5688c9fd61 | ||
|
|
08a075df61 | ||
|
|
a2e8737114 | ||
|
|
776a394b02 | ||
|
|
5788b33fdf | ||
|
|
0f270c3da4 | ||
|
|
376019e347 | ||
|
|
44b773a6f6 | ||
|
|
df97774f7f | ||
|
|
eeff62a912 | ||
|
|
3fc6c42f5f | ||
|
|
967d8238be | ||
|
|
bff7518a24 | ||
|
|
8eab677094 | ||
|
|
db57e7023a | ||
|
|
ede4e467db | ||
|
|
aa1c560e5e | ||
|
|
3aca9e5fa5 | ||
|
|
9e96d83164 | ||
|
|
4275907df6 | ||
|
|
6097d6af86 | ||
|
|
09d2febe27 | ||
|
|
2c5c1ecb5e | ||
|
|
99e2112807 | ||
|
|
4b6575999d | ||
|
|
1a9ee3080c | ||
|
|
f4d61be8bd | ||
|
|
8b40e38cd7 | ||
|
|
7396d495ee | ||
|
|
f9b5ce180a | ||
|
|
12ee9d51c3 | ||
|
|
2730e0c9cd | ||
|
|
d6c81d6e14 | ||
|
|
e8ac0b663b | ||
|
|
2806f240ea | ||
|
|
9898fbe8ef | ||
|
|
1bd8e61719 | ||
|
|
b6c07cb1b8 | ||
|
|
83f23817ce | ||
|
|
23b1d7c755 | ||
|
|
ef033db9c2 | ||
|
|
e30d5d8e34 | ||
|
|
698cfb57a1 | ||
|
|
27e72f2652 | ||
|
|
10eed6ee7e | ||
|
|
59b87f60f7 | ||
|
|
d10089a0bf | ||
|
|
ae7286c031 | ||
|
|
52048c327d | ||
|
|
4e1a9b6216 | ||
|
|
1995be3599 | ||
|
|
86b9b7b15a | ||
|
|
a90f2b9723 | ||
|
|
c73a17f8af | ||
|
|
48898fda07 | ||
|
|
c573732ddb | ||
|
|
ab2a6c45a3 | ||
|
|
66563fb974 | ||
|
|
fbece0dc4d | ||
|
|
1d9e181da0 | ||
|
|
c81721e9fc | ||
|
|
a94899ed36 | ||
|
|
b18d22498c | ||
|
|
c75584a31b | ||
|
|
b474f65547 | ||
|
|
c352999b41 | ||
|
|
f4cd708ca0 | ||
|
|
c20f2731ab | ||
|
|
01ca1a384a | ||
|
|
f330dadd89 | ||
|
|
43e92b4932 | ||
|
|
83397ebde2 | ||
|
|
fde74a72bb | ||
|
|
10ee8e5b3d | ||
|
|
96d3f1fe7c | ||
|
|
1a2b656c4d | ||
|
|
161e9287a8 | ||
|
|
968543af39 | ||
|
|
5af35117db | ||
|
|
eab177f5e7 | ||
|
|
279dc04b3c | ||
|
|
cbc5903aa1 | ||
|
|
81c3c63895 | ||
|
|
b76bd4141d | ||
|
|
794fe8f381 | ||
|
|
a4eebf9f08 | ||
|
|
680a63e3de | ||
|
|
3a54ab68d1 | ||
|
|
44fd0eee64 | ||
|
|
ac371d2987 | ||
|
|
a7baa5ce18 | ||
|
|
b129f809b9 | ||
|
|
92c0ab51e2 | ||
|
|
b25418e68b | ||
|
|
046e351140 | ||
|
|
b9029afa22 | ||
|
|
b229aeec0b | ||
|
|
c9140c6bab | ||
|
|
38551bda38 | ||
|
|
cd16d31510 | ||
|
|
54ba1af5d6 | ||
|
|
fe3144ce5b | ||
|
|
a1c0bae3af | ||
|
|
85f8655dfd | ||
|
|
9b6c9f64f7 | ||
|
|
1aae1c795d | ||
|
|
526c723e62 | ||
|
|
6011200128 | ||
|
|
740fcd243c | ||
|
|
e4d8a117c4 | ||
|
|
8c4a816cf6 | ||
|
|
5605fc3f38 | ||
|
|
009b096004 | ||
|
|
64f898601b | ||
|
|
224e5466c1 | ||
|
|
87b5b34280 | ||
|
|
855fd07d22 | ||
|
|
f9be2bab3a | ||
|
|
25f1643e8e | ||
|
|
e015bea462 | ||
|
|
7dc55ac3ca | ||
|
|
cd8ecf9722 | ||
|
|
eb021a5f92 | ||
|
|
7f5e30834f | ||
|
|
750a936ae1 | ||
|
|
8dfef670b3 | ||
|
|
1b1b73b5b3 | ||
|
|
6baee0791f | ||
|
|
291b65977c | ||
|
|
90f232d7f1 | ||
|
|
af214d35cb | ||
|
|
3f0afd7cf6 | ||
|
|
0545c5da2d | ||
|
|
4a32fa6f02 | ||
|
|
29c99ed4ab | ||
|
|
753abbe164 | ||
|
|
8e01f6cc13 | ||
|
|
33c0b125cb | ||
|
|
dab2e54df8 | ||
|
|
60db171b44 | ||
|
|
c6e9a5c800 | ||
|
|
2c16b9fa61 | ||
|
|
240ad31edd | ||
|
|
a97631f769 | ||
|
|
dbaac79039 | ||
|
|
a05915ddc8 | ||
|
|
eebbd73346 | ||
|
|
d4c981495a | ||
|
|
653c206688 | ||
|
|
580f46b589 | ||
|
|
986d12fd20 | ||
|
|
d04a72a4ad | ||
|
|
5fd873a35a | ||
|
|
a9fbd786b3 | ||
|
|
abde984b3e | ||
|
|
a95aa037a3 | ||
|
|
11a92b24c2 | ||
|
|
f9c10c62d8 | ||
|
|
6339f39871 | ||
|
|
68b09b30a1 | ||
|
|
92ade2a320 | ||
|
|
cb1a1fb26c | ||
|
|
af5ebabd03 | ||
|
|
fe2626a4ea | ||
|
|
45447e3336 | ||
|
|
7a3e82ec5d | ||
|
|
345f4801e8 | ||
|
|
ac4b8d62e3 | ||
|
|
236ce7a8c0 | ||
|
|
8bdc0c8f79 | ||
|
|
04650f01fe | ||
|
|
02d4594abf | ||
|
|
c1894b4e3d | ||
|
|
2062247e72 | ||
|
|
8785bec29c | ||
|
|
d4b7f75ce3 | ||
|
|
4f73d58031 | ||
|
|
b906f2de88 | ||
|
|
4035afe5c8 | ||
|
|
8fe0715928 | ||
|
|
cb8af962cd | ||
|
|
c333ffa38b | ||
|
|
3456f4ed80 | ||
|
|
2536e9f45b | ||
|
|
9188bc542c | ||
|
|
cbaba10994 | ||
|
|
85d3604309 | ||
|
|
507ba644cf | ||
|
|
3d6f62746a | ||
|
|
2f48c8c05f | ||
|
|
4828fd1eac | ||
|
|
10375263ef | ||
|
|
ae00001aa0 | ||
|
|
f53ebafbab | ||
|
|
23ebc50da9 | ||
|
|
673c6f97b7 | ||
|
|
ec46f71258 | ||
|
|
8865e524cb | ||
|
|
36bb02ae45 | ||
|
|
5072331f04 |
@@ -0,0 +1,69 @@
|
|||||||
|
name: Docs Update
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 */12 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-docs:
|
||||||
|
if: github.repository == 'sst/opencode'
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Fetch full history to access commits
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Get recent commits
|
||||||
|
id: commits
|
||||||
|
run: |
|
||||||
|
COMMITS=$(git log --since="4 hours ago" --pretty=format:"- %h %s" 2>/dev/null || echo "")
|
||||||
|
if [ -z "$COMMITS" ]; then
|
||||||
|
echo "No commits in the last 4 hours"
|
||||||
|
echo "has_commits=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "has_commits=true" >> $GITHUB_OUTPUT
|
||||||
|
{
|
||||||
|
echo "list<<EOF"
|
||||||
|
echo "$COMMITS"
|
||||||
|
echo "EOF"
|
||||||
|
} >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run opencode
|
||||||
|
if: steps.commits.outputs.has_commits == 'true'
|
||||||
|
uses: sst/opencode/github@latest
|
||||||
|
env:
|
||||||
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
|
with:
|
||||||
|
model: opencode/gpt-5.2
|
||||||
|
agent: docs
|
||||||
|
prompt: |
|
||||||
|
Review the following commits from the last 4 hours and identify any new features that may need documentation.
|
||||||
|
|
||||||
|
<recent_commits>
|
||||||
|
${{ steps.commits.outputs.list }}
|
||||||
|
</recent_commits>
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. For each commit that looks like a new feature or significant change:
|
||||||
|
- Read the changed files to understand what was added
|
||||||
|
- Check if the feature is already documented in packages/web/src/content/docs/*
|
||||||
|
2. If you find undocumented features:
|
||||||
|
- Update the relevant documentation files in packages/web/src/content/docs/*
|
||||||
|
- Follow the existing documentation style and structure
|
||||||
|
- Make sure to document the feature clearly with examples where appropriate
|
||||||
|
3. If all new features are already documented, report that no updates are needed
|
||||||
|
4. If you are creating a new documentation file be sure to update packages/web/astro.config.mjs too.
|
||||||
|
|
||||||
|
Focus on user-facing features and API changes. Skip internal refactors, bug fixes, and test updates unless they affect user-facing behavior.
|
||||||
|
Don't feel the need to document every little thing. It is perfectly okay to make 0 changes at all.
|
||||||
|
Try to keep documentation only for large features or changes that already have a good spot to be documented.
|
||||||
@@ -79,6 +79,12 @@ jobs:
|
|||||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
||||||
NPM_CONFIG_PROVENANCE: false
|
NPM_CONFIG_PROVENANCE: false
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: opencode-cli
|
||||||
|
path: packages/opencode/dist
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
release: ${{ steps.publish.outputs.release }}
|
release: ${{ steps.publish.outputs.release }}
|
||||||
tag: ${{ steps.publish.outputs.tag }}
|
tag: ${{ steps.publish.outputs.tag }}
|
||||||
@@ -99,6 +105,8 @@ jobs:
|
|||||||
target: x86_64-pc-windows-msvc
|
target: x86_64-pc-windows-msvc
|
||||||
- host: blacksmith-4vcpu-ubuntu-2404
|
- host: blacksmith-4vcpu-ubuntu-2404
|
||||||
target: x86_64-unknown-linux-gnu
|
target: x86_64-unknown-linux-gnu
|
||||||
|
- host: blacksmith-4vcpu-ubuntu-2404-arm
|
||||||
|
target: aarch64-unknown-linux-gnu
|
||||||
runs-on: ${{ matrix.settings.host }}
|
runs-on: ${{ matrix.settings.host }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
@@ -143,13 +151,12 @@ jobs:
|
|||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
workspaces: packages/tauri/src-tauri
|
workspaces: packages/desktop/src-tauri
|
||||||
shared-key: ${{ matrix.settings.target }}
|
shared-key: ${{ matrix.settings.target }}
|
||||||
|
|
||||||
- name: Prepare
|
- name: Prepare
|
||||||
if: inputs.bump || inputs.version
|
|
||||||
run: |
|
run: |
|
||||||
cd packages/tauri
|
cd packages/desktop
|
||||||
bun ./scripts/prepare.ts
|
bun ./scripts/prepare.ts
|
||||||
env:
|
env:
|
||||||
OPENCODE_VERSION: ${{ needs.publish.outputs.version }}
|
OPENCODE_VERSION: ${{ needs.publish.outputs.version }}
|
||||||
@@ -159,6 +166,7 @@ jobs:
|
|||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
RUST_TARGET: ${{ matrix.settings.target }}
|
RUST_TARGET: ${{ matrix.settings.target }}
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
|
|
||||||
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
|
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
|
||||||
- name: Install tauri-cli from portable appimage branch
|
- name: Install tauri-cli from portable appimage branch
|
||||||
@@ -183,10 +191,10 @@ jobs:
|
|||||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
||||||
with:
|
with:
|
||||||
projectPath: packages/tauri
|
projectPath: packages/desktop
|
||||||
uploadWorkflowArtifacts: true
|
uploadWorkflowArtifacts: true
|
||||||
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
|
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
|
||||||
args: --target ${{ matrix.settings.target }} --config src-tauri/tauri.prod.conf.json
|
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
|
||||||
updaterJsonPreferNsis: true
|
updaterJsonPreferNsis: true
|
||||||
releaseId: ${{ needs.publish.outputs.release }}
|
releaseId: ${{ needs.publish.outputs.release }}
|
||||||
tagName: ${{ needs.publish.outputs.tag }}
|
tagName: ${{ needs.publish.outputs.tag }}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ jobs:
|
|||||||
Please check all the code changes in this pull request against the style guide, also look for any bugs if they exist. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do
|
Please check all the code changes in this pull request against the style guide, also look for any bugs if they exist. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do
|
||||||
|
|
||||||
When critiquing code against the style guide, be sure that the code is ACTUALLY in violation, don't complain about else statements if they already use early returns there. You may complain about excessive nesting though, regardless of else statement usage.
|
When critiquing code against the style guide, be sure that the code is ACTUALLY in violation, don't complain about else statements if they already use early returns there. You may complain about excessive nesting though, regardless of else statement usage.
|
||||||
When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simpliest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts)
|
When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simplest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts)
|
||||||
|
|
||||||
Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block.
|
Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block.
|
||||||
If you are writing suggested fixes, BE SURE THAT the change you are recommending is actually valid typescript, often I have seen missing closing "}" or other syntax errors.
|
If you are writing suggested fixes, BE SURE THAT the change you are recommending is actually valid typescript, often I have seen missing closing "}" or other syntax errors.
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
name: "Auto-close stale issues"
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "30 1 * * *" # Daily at 1:30 AM
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v10
|
||||||
|
with:
|
||||||
|
days-before-stale: 90
|
||||||
|
days-before-close: 7
|
||||||
|
stale-issue-label: "stale"
|
||||||
|
close-issue-message: |
|
||||||
|
[automated] Closing due to 90+ days of inactivity.
|
||||||
|
|
||||||
|
Feel free to reopen if you still need this!
|
||||||
|
stale-issue-message: |
|
||||||
|
[automated] This issue has had no activity for 90 days.
|
||||||
|
|
||||||
|
It will be closed in 7 days if there's no new activity.
|
||||||
|
remove-stale-when-updated: true
|
||||||
|
exempt-issue-labels: "pinned,security,feature-request,on-hold"
|
||||||
|
start-date: "2025-12-27"
|
||||||
@@ -5,8 +5,11 @@ on:
|
|||||||
- cron: "0 12 * * *" # Run daily at 12:00 UTC
|
- cron: "0 12 * * *" # Run daily at 12:00 UTC
|
||||||
workflow_dispatch: # Allow manual trigger
|
workflow_dispatch: # Allow manual trigger
|
||||||
|
|
||||||
|
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
stats:
|
stats:
|
||||||
|
if: github.repository == 'sst/opencode'
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ name: "sync-zed-extension"
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
# release:
|
release:
|
||||||
# types: [published]
|
types: [published]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
zed:
|
zed:
|
||||||
@@ -31,4 +31,4 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }}
|
./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
ZED_EXTENSIONS_PAT: ${{ secrets.ZED_EXTENSIONS_PAT }}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ You are an expert technical documentation writer
|
|||||||
|
|
||||||
You are not verbose
|
You are not verbose
|
||||||
|
|
||||||
|
Use a relaxed and friendly tone
|
||||||
|
|
||||||
The title of the page should be a word or a 2-3 word phrase
|
The title of the page should be a word or a 2-3 word phrase
|
||||||
|
|
||||||
The description should be one short line, should not start with "The", should
|
The description should be one short line, should not start with "The", should
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
name: test-skill
|
||||||
|
description: use this when asked to test skill
|
||||||
|
---
|
||||||
|
|
||||||
|
woah this is a test skill
|
||||||
@@ -4,31 +4,4 @@
|
|||||||
|
|
||||||
## Tool Calling
|
## Tool Calling
|
||||||
|
|
||||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. Here is an example illustrating how to execute 3 parallel file reads in this chat environment:
|
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||||
|
|
||||||
json
|
|
||||||
{
|
|
||||||
"recipient_name": "multi_tool_use.parallel",
|
|
||||||
"parameters": {
|
|
||||||
"tool_uses": [
|
|
||||||
{
|
|
||||||
"recipient_name": "functions.read",
|
|
||||||
"parameters": {
|
|
||||||
"filePath": "path/to/file.tsx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient_name": "functions.read",
|
|
||||||
"parameters": {
|
|
||||||
"filePath": "path/to/file.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient_name": "functions.read",
|
|
||||||
"parameters": {
|
|
||||||
"filePath": "path/to/file.md"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+4
-4
@@ -53,12 +53,12 @@ your debugger via that URL. Other methods can result in breakpoints being mapped
|
|||||||
|
|
||||||
Caveats:
|
Caveats:
|
||||||
|
|
||||||
- `*.tsx` files won't have their breakpoints correctly mapped. This seems due to Bun currently not supporting source maps on code transformed
|
|
||||||
via `BunPlugin`s (currently necessary due to our dependency on `@opentui/solid`). Currently, the best you can do in terms of debugging `*.tsx`
|
|
||||||
files is writing a `debugger;` statement. Debugging facilities like stepping won't work, but at least you will be informed if a specific code
|
|
||||||
is triggered.
|
|
||||||
- If you want to run the OpenCode TUI and have breakpoints triggered in the server code, you might need to run `bun dev spawn` instead of
|
- If you want to run the OpenCode TUI and have breakpoints triggered in the server code, you might need to run `bun dev spawn` instead of
|
||||||
the usual `bun dev`. This is because `bun dev` runs the server in a worker thread and breakpoints might not work there.
|
the usual `bun dev`. This is because `bun dev` runs the server in a worker thread and breakpoints might not work there.
|
||||||
|
- If `spawn` does not work for you, you can debug the server separately:
|
||||||
|
- Debug server: `bun run --inspect=ws://localhost:6499/ ./src/index.ts serve --port 4096`,
|
||||||
|
then attach TUI with `opencode attach http://localhost:4096`
|
||||||
|
- Debug TUI: `bun run --inspect=ws://localhost:6499/ --conditions=browser ./src/index.ts`
|
||||||
|
|
||||||
Other tips and tricks:
|
Other tips and tricks:
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ you can switch between these using the `Tab` key.
|
|||||||
- Asks permission before running bash commands
|
- Asks permission before running bash commands
|
||||||
- Ideal for exploring unfamiliar codebases or planning changes
|
- Ideal for exploring unfamiliar codebases or planning changes
|
||||||
|
|
||||||
Also, included is a **general** subagent for complex searches and multi-step tasks.
|
Also, included is a **general** subagent for complex searches and multistep tasks.
|
||||||
This is used internally and can be invoked using `@general` in messages.
|
This is used internally and can be invoked using `@general` in messages.
|
||||||
|
|
||||||
Learn more about [agents](https://opencode.ai/docs/agents).
|
Learn more about [agents](https://opencode.ai/docs/agents).
|
||||||
@@ -98,7 +98,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
|
|||||||
|
|
||||||
### FAQ
|
### FAQ
|
||||||
|
|
||||||
#### How is this different than Claude Code?
|
#### 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:
|
||||||
|
|
||||||
|
|||||||
@@ -176,3 +176,11 @@
|
|||||||
| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) |
|
| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) |
|
||||||
| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) |
|
| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) |
|
||||||
| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) |
|
| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) |
|
||||||
|
| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
|
||||||
|
| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) |
|
||||||
|
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
|
||||||
|
| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) |
|
||||||
|
| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) |
|
||||||
|
| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) |
|
||||||
|
| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
|
||||||
|
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
|
||||||
|
|||||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1766125104,
|
"lastModified": 1766840161,
|
||||||
"narHash": "sha256-l/YGrEpLromL4viUo5GmFH3K5M1j0Mb9O+LiaeCPWEM=",
|
"narHash": "sha256-Ss/LHpJJsng8vz1Pe33RSGIWUOcqM1fjrehjUkdrWio=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "7d853e518814cca2a657b72eeba67ae20ebf7059",
|
"rev": "3edc4a30ed3903fdf6f90c837f961fa6b49582d1",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
+4
-1
@@ -574,10 +574,13 @@ async function subscribeSessionEvents() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function summarize(response: string) {
|
async function summarize(response: string) {
|
||||||
const payload = useContext().payload as IssueCommentEvent
|
|
||||||
try {
|
try {
|
||||||
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
|
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (isScheduleEvent()) {
|
||||||
|
return "Scheduled task changes"
|
||||||
|
}
|
||||||
|
const payload = useContext().payload as IssueCommentEvent
|
||||||
return `Fix issue: ${payload.issue.title}`
|
return `Fix issue: ${payload.issue.title}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,3 +44,12 @@ new sst.cloudflare.x.Astro("Web", {
|
|||||||
VITE_API_URL: api.url.apply((url) => url!),
|
VITE_API_URL: api.url.apply((url) => url!),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
new sst.cloudflare.StaticSite("WebApp", {
|
||||||
|
domain: "app." + domain,
|
||||||
|
path: "packages/app",
|
||||||
|
build: {
|
||||||
|
command: "bun turbo build",
|
||||||
|
output: "./dist",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ const ZEN_MODELS = [
|
|||||||
new sst.Secret("ZEN_MODELS3"),
|
new sst.Secret("ZEN_MODELS3"),
|
||||||
new sst.Secret("ZEN_MODELS4"),
|
new sst.Secret("ZEN_MODELS4"),
|
||||||
new sst.Secret("ZEN_MODELS5"),
|
new sst.Secret("ZEN_MODELS5"),
|
||||||
|
new sst.Secret("ZEN_MODELS6"),
|
||||||
]
|
]
|
||||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||||
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
||||||
@@ -118,6 +119,7 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
|
|||||||
////////////////
|
////////////////
|
||||||
|
|
||||||
const bucket = new sst.cloudflare.Bucket("ZenData")
|
const bucket = new sst.cloudflare.Bucket("ZenData")
|
||||||
|
const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
||||||
|
|
||||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
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 AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||||
@@ -136,6 +138,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
path: "packages/console/app",
|
path: "packages/console/app",
|
||||||
link: [
|
link: [
|
||||||
bucket,
|
bucket,
|
||||||
|
bucketNew,
|
||||||
database,
|
database,
|
||||||
AUTH_API_URL,
|
AUTH_API_URL,
|
||||||
STRIPE_WEBHOOK_SECRET,
|
STRIPE_WEBHOOK_SECRET,
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { domain } from "./stage"
|
|
||||||
|
|
||||||
new sst.cloudflare.StaticSite("Desktop", {
|
|
||||||
domain: "desktop." + domain,
|
|
||||||
path: "packages/desktop",
|
|
||||||
build: {
|
|
||||||
command: "bun turbo build",
|
|
||||||
output: "./dist",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -7,7 +7,51 @@ RED='\033[0;31m'
|
|||||||
ORANGE='\033[38;5;214m'
|
ORANGE='\033[38;5;214m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
OpenCode Installer
|
||||||
|
|
||||||
|
Usage: install.sh [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help Display this help message
|
||||||
|
-v, --version <version> Install a specific version (e.g., 1.0.180)
|
||||||
|
--no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
curl -fsSL https://opencode.ai/install | bash
|
||||||
|
curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
requested_version=${VERSION:-}
|
requested_version=${VERSION:-}
|
||||||
|
no_modify_path=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
-v|--version)
|
||||||
|
if [[ -n "${2:-}" ]]; then
|
||||||
|
requested_version="$2"
|
||||||
|
shift 2
|
||||||
|
else
|
||||||
|
echo -e "${RED}Error: --version requires a version argument${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
--no-modify-path)
|
||||||
|
no_modify_path=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${ORANGE}Warning: Unknown option '$1'${NC}" >&2
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
raw_os=$(uname -s)
|
raw_os=$(uname -s)
|
||||||
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
|
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
|
||||||
@@ -111,8 +155,18 @@ if [ -z "$requested_version" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
|
# Strip leading 'v' if present
|
||||||
|
requested_version="${requested_version#v}"
|
||||||
url="https://github.com/sst/opencode/releases/download/v${requested_version}/$filename"
|
url="https://github.com/sst/opencode/releases/download/v${requested_version}/$filename"
|
||||||
specific_version=$requested_version
|
specific_version=$requested_version
|
||||||
|
|
||||||
|
# Verify the release exists before downloading
|
||||||
|
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/sst/opencode/releases/tag/v${requested_version}")
|
||||||
|
if [ "$http_status" = "404" ]; then
|
||||||
|
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
|
||||||
|
echo -e "${MUTED}Available releases: https://github.com/sst/opencode/releases${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
print_message() {
|
print_message() {
|
||||||
@@ -304,42 +358,42 @@ case $current_shell in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
config_file=""
|
if [[ "$no_modify_path" != "true" ]]; then
|
||||||
for file in $config_files; do
|
config_file=""
|
||||||
if [[ -f $file ]]; then
|
for file in $config_files; do
|
||||||
config_file=$file
|
if [[ -f $file ]]; then
|
||||||
break
|
config_file=$file
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z $config_file ]]; then
|
||||||
|
print_message warning "No config file found for $current_shell. You may need to manually add to PATH:"
|
||||||
|
print_message info " export PATH=$INSTALL_DIR:\$PATH"
|
||||||
|
elif [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
|
||||||
|
case $current_shell in
|
||||||
|
fish)
|
||||||
|
add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
|
||||||
|
;;
|
||||||
|
zsh)
|
||||||
|
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||||
|
;;
|
||||||
|
bash)
|
||||||
|
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||||
|
;;
|
||||||
|
ash)
|
||||||
|
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||||
|
;;
|
||||||
|
sh)
|
||||||
|
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
export PATH=$INSTALL_DIR:$PATH
|
||||||
|
print_message warning "Manually add the directory to $config_file (or similar):"
|
||||||
|
print_message info " export PATH=$INSTALL_DIR:\$PATH"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
fi
|
fi
|
||||||
done
|
|
||||||
|
|
||||||
if [[ -z $config_file ]]; then
|
|
||||||
print_message error "No config file found for $current_shell. Checked files: ${config_files[@]}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
|
|
||||||
case $current_shell in
|
|
||||||
fish)
|
|
||||||
add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
|
|
||||||
;;
|
|
||||||
zsh)
|
|
||||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
|
||||||
;;
|
|
||||||
bash)
|
|
||||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
|
||||||
;;
|
|
||||||
ash)
|
|
||||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
|
||||||
;;
|
|
||||||
sh)
|
|
||||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
export PATH=$INSTALL_DIR:$PATH
|
|
||||||
print_message warning "Manually add the directory to $config_file (or similar):"
|
|
||||||
print_message info " export PATH=$INSTALL_DIR:\$PATH"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
|
if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": "sha256-cpXmqJQJeFj3eED/aOb4YLUdkZFV//7u4f0STBxzUhk="
|
"nodeModules": "sha256-lloUZt5mLyNWAcbQrJB4wGUKvKu24WFEhOLfZD5/FMg="
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -31,7 +31,7 @@
|
|||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@pierre/diffs": "1.0.0-beta.3",
|
"@pierre/diffs": "1.0.2",
|
||||||
"@solid-primitives/storage": "4.3.3",
|
"@solid-primitives/storage": "4.3.3",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
@@ -40,10 +40,13 @@
|
|||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
|
"marked": "17.0.1",
|
||||||
|
"marked-shiki": "1.2.1",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
"zod": "4.1.8",
|
"zod": "4.1.8",
|
||||||
"remeda": "2.26.0",
|
"remeda": "2.26.0",
|
||||||
|
"shiki": "3.20.0",
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
"virtua": "0.42.3",
|
"virtua": "0.42.3",
|
||||||
@@ -56,6 +59,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@actions/artifact": "5.0.1",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
@@ -64,6 +68,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.933.0",
|
"@aws-sdk/client-s3": "3.933.0",
|
||||||
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
@@ -80,7 +85,6 @@
|
|||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"protobufjs",
|
"protobufjs",
|
||||||
"sharp",
|
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
"tree-sitter-bash",
|
"tree-sitter-bash",
|
||||||
"web-tree-sitter"
|
"web-tree-sitter"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
src/assets/theme.css
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
## Usage
|
||||||
|
|
||||||
|
Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`.
|
||||||
|
|
||||||
|
This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install # or pnpm install or yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
In the project directory, you can run:
|
||||||
|
|
||||||
|
### `npm run dev` or `npm start`
|
||||||
|
|
||||||
|
Runs the app in the development mode.<br>
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||||
|
|
||||||
|
The page will reload if you make edits.<br>
|
||||||
|
|
||||||
|
### `npm run build`
|
||||||
|
|
||||||
|
Builds the app for production to the `dist` folder.<br>
|
||||||
|
It correctly bundles Solid in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
|
The build is minified and the filenames include the hashes.<br>
|
||||||
|
Your app is ready to be deployed!
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" style="background-color: var(--background-base)">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>OpenCode</title>
|
||||||
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<meta name="theme-color" content="#F8F7F7" />
|
||||||
|
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
||||||
|
<meta property="og:image" content="/social-share.png" />
|
||||||
|
<meta property="twitter:image" content="/social-share.png" />
|
||||||
|
<!-- Theme preload script - applies cached theme to avoid FOUC -->
|
||||||
|
<script id="oc-theme-preload-script">
|
||||||
|
;(function () {
|
||||||
|
var themeId = localStorage.getItem("opencode-theme-id")
|
||||||
|
if (!themeId) return
|
||||||
|
|
||||||
|
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||||
|
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||||
|
var mode = isDark ? "dark" : "light"
|
||||||
|
|
||||||
|
document.documentElement.dataset.theme = themeId
|
||||||
|
document.documentElement.dataset.colorScheme = mode
|
||||||
|
|
||||||
|
if (themeId === "oc-1") return
|
||||||
|
|
||||||
|
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
||||||
|
if (css) {
|
||||||
|
var style = document.createElement("style")
|
||||||
|
style.id = "oc-theme-preload"
|
||||||
|
style.textContent =
|
||||||
|
":root{color-scheme:" +
|
||||||
|
mode +
|
||||||
|
";--text-mix-blend-mode:" +
|
||||||
|
(isDark ? "plus-lighter" : "multiply") +
|
||||||
|
";" +
|
||||||
|
css +
|
||||||
|
"}"
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root" class="flex flex-col h-screen"></div>
|
||||||
|
<script src="/src/entry.tsx" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"name": "@opencode-ai/app",
|
||||||
|
"version": "1.0.207",
|
||||||
|
"description": "",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./vite": "./vite.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsgo -b",
|
||||||
|
"start": "vite",
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"serve": "vite preview"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@happy-dom/global-registrator": "20.0.11",
|
||||||
|
"@tailwindcss/vite": "catalog:",
|
||||||
|
"@tsconfig/bun": "1.0.9",
|
||||||
|
"@types/bun": "catalog:",
|
||||||
|
"@types/luxon": "catalog:",
|
||||||
|
"@types/node": "catalog:",
|
||||||
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"typescript": "catalog:",
|
||||||
|
"vite": "catalog:",
|
||||||
|
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||||
|
"vite-plugin-solid": "catalog:"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@kobalte/core": "catalog:",
|
||||||
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
"@opencode-ai/util": "workspace:*",
|
||||||
|
"@shikijs/transformers": "3.9.2",
|
||||||
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
|
"@solid-primitives/audio": "1.4.2",
|
||||||
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
|
"@solid-primitives/media": "2.3.3",
|
||||||
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
|
"@solid-primitives/scroll": "2.1.3",
|
||||||
|
"@solid-primitives/storage": "catalog:",
|
||||||
|
"@solid-primitives/websocket": "1.3.1",
|
||||||
|
"@solidjs/meta": "catalog:",
|
||||||
|
"@solidjs/router": "catalog:",
|
||||||
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
|
"diff": "catalog:",
|
||||||
|
"fuzzysort": "catalog:",
|
||||||
|
"ghostty-web": "0.3.0",
|
||||||
|
"luxon": "catalog:",
|
||||||
|
"marked": "catalog:",
|
||||||
|
"marked-shiki": "catalog:",
|
||||||
|
"remeda": "catalog:",
|
||||||
|
"shiki": "catalog:",
|
||||||
|
"solid-js": "catalog:",
|
||||||
|
"solid-list": "catalog:",
|
||||||
|
"tailwindcss": "catalog:",
|
||||||
|
"virtua": "catalog:",
|
||||||
|
"zod": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/assets/*.js
|
||||||
|
Content-Type: application/javascript
|
||||||
|
|
||||||
|
/assets/*.mjs
|
||||||
|
Content-Type: application/javascript
|
||||||
|
|
||||||
|
/assets/*.css
|
||||||
|
Content-Type: text/css
|
||||||
|
|
||||||
|
/*.js
|
||||||
|
Content-Type: application/javascript
|
||||||
|
|
||||||
|
/*.mjs
|
||||||
|
Content-Type: application/javascript
|
||||||
|
|
||||||
|
/*.css
|
||||||
|
Content-Type: text/css
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import "@/index.css"
|
||||||
|
import { ErrorBoundary, Show } from "solid-js"
|
||||||
|
import { Router, Route, Navigate } from "@solidjs/router"
|
||||||
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
|
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||||
|
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
|
||||||
|
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
|
||||||
|
import { Diff } from "@opencode-ai/ui/diff"
|
||||||
|
import { Code } from "@opencode-ai/ui/code"
|
||||||
|
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||||
|
import { GlobalSyncProvider } from "@/context/global-sync"
|
||||||
|
import { LayoutProvider } from "@/context/layout"
|
||||||
|
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||||
|
import { TerminalProvider } from "@/context/terminal"
|
||||||
|
import { PromptProvider } from "@/context/prompt"
|
||||||
|
import { NotificationProvider } from "@/context/notification"
|
||||||
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { CommandProvider } from "@/context/command"
|
||||||
|
import Layout from "@/pages/layout"
|
||||||
|
import Home from "@/pages/home"
|
||||||
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
|
import Session from "@/pages/session"
|
||||||
|
import { ErrorPage } from "./pages/error"
|
||||||
|
import { iife } from "@opencode-ai/util/iife"
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__OPENCODE__?: { updaterEnabled?: boolean; port?: number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = iife(() => {
|
||||||
|
const param = new URLSearchParams(document.location.search).get("url")
|
||||||
|
if (param) return param
|
||||||
|
|
||||||
|
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||||
|
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
|
||||||
|
if (import.meta.env.DEV)
|
||||||
|
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
|
return window.location.origin
|
||||||
|
})
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<MetaProvider>
|
||||||
|
<Font />
|
||||||
|
<ThemeProvider>
|
||||||
|
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||||
|
<DialogProvider>
|
||||||
|
<MarkedProvider>
|
||||||
|
<DiffComponentProvider component={Diff}>
|
||||||
|
<CodeComponentProvider component={Code}>
|
||||||
|
<GlobalSDKProvider url={url}>
|
||||||
|
<GlobalSyncProvider>
|
||||||
|
<LayoutProvider>
|
||||||
|
<NotificationProvider>
|
||||||
|
<Router
|
||||||
|
root={(props) => (
|
||||||
|
<CommandProvider>
|
||||||
|
<Layout>{props.children}</Layout>
|
||||||
|
</CommandProvider>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Route path="/" component={Home} />
|
||||||
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
|
<Route
|
||||||
|
path="/session/:id?"
|
||||||
|
component={(p) => (
|
||||||
|
<Show when={p.params.id || true} keyed>
|
||||||
|
<TerminalProvider>
|
||||||
|
<PromptProvider>
|
||||||
|
<Session />
|
||||||
|
</PromptProvider>
|
||||||
|
</TerminalProvider>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
</Router>
|
||||||
|
</NotificationProvider>
|
||||||
|
</LayoutProvider>
|
||||||
|
</GlobalSyncProvider>
|
||||||
|
</GlobalSDKProvider>
|
||||||
|
</CodeComponentProvider>
|
||||||
|
</DiffComponentProvider>
|
||||||
|
</MarkedProvider>
|
||||||
|
</DialogProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</ThemeProvider>
|
||||||
|
</MetaProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
+23
-21
@@ -1,24 +1,24 @@
|
|||||||
|
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||||
|
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||||
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { iife } from "@opencode-ai/util/iife"
|
||||||
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
||||||
import { List, ListRef } from "@opencode-ai/ui/list"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|
||||||
import { IconName } from "@opencode-ai/ui/icons/provider"
|
|
||||||
import { iife } from "@opencode-ai/util/iife"
|
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
import { DialogSelectModel } from "./dialog-select-model"
|
import { DialogSelectModel } from "./dialog-select-model"
|
||||||
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
|
||||||
export function DialogConnectProvider(props: { provider: string }) {
|
export function DialogConnectProvider(props: { provider: string }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -154,7 +154,9 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
<div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
|
<div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
|
||||||
<div class="">
|
<div class="">
|
||||||
<List
|
<List
|
||||||
ref={(ref) => (listRef = ref)}
|
ref={(ref) => {
|
||||||
|
listRef = ref
|
||||||
|
}}
|
||||||
items={methods}
|
items={methods}
|
||||||
key={(m) => m?.label}
|
key={(m) => m?.label}
|
||||||
onSelect={async (method, index) => {
|
onSelect={async (method, index) => {
|
||||||
@@ -163,7 +165,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center gap-x-4">
|
<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-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 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
<div class="w-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
||||||
</div>
|
</div>
|
||||||
@@ -175,7 +177,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={store.state === "pending"}>
|
<Match when={store.state === "pending"}>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
<div class="flex items-center gap-x-4">
|
<div class="flex items-center gap-x-2">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
<span>Authorization in progress...</span>
|
<span>Authorization in progress...</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,7 +185,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={store.state === "error"}>
|
<Match when={store.state === "error"}>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
<div class="flex items-center gap-x-4">
|
<div class="flex items-center gap-x-2">
|
||||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||||
<span>Authorization failed: {store.error}</span>
|
<span>Authorization failed: {store.error}</span>
|
||||||
</div>
|
</div>
|
||||||
+14
-7
@@ -1,16 +1,15 @@
|
|||||||
import { Component } from "solid-js"
|
|
||||||
import { useLocal } from "@/context/local"
|
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
|
import type { Component } from "solid-js"
|
||||||
|
import { useLocal } from "@/context/local"
|
||||||
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
|
|
||||||
export const DialogManageModels: Component = () => {
|
export const DialogManageModels: Component = () => {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
return (
|
return (
|
||||||
<Dialog title="Manage models" description="Customize which models appear in the model selector.">
|
<Dialog title="Manage models" description="Customize which models appear in the model selector.">
|
||||||
<List
|
<List
|
||||||
class="px-2.5"
|
|
||||||
search={{ placeholder: "Search models", autofocus: true }}
|
search={{ placeholder: "Search models", autofocus: true }}
|
||||||
emptyMessage="No model results"
|
emptyMessage="No model results"
|
||||||
key={(x) => `${x?.provider?.id}:${x?.id}`}
|
key={(x) => `${x?.provider?.id}:${x?.id}`}
|
||||||
@@ -27,16 +26,24 @@ export const DialogManageModels: Component = () => {
|
|||||||
}}
|
}}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x) return
|
if (!x) return
|
||||||
const visible = local.model.visible({ modelID: x.id, providerID: x.provider.id })
|
const visible = local.model.visible({
|
||||||
|
modelID: x.id,
|
||||||
|
providerID: x.provider.id,
|
||||||
|
})
|
||||||
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
|
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center justify-between gap-x-2.5">
|
<div class="w-full flex items-center justify-between gap-x-3">
|
||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
|
checked={
|
||||||
|
!!local.model.visible({
|
||||||
|
modelID: i.id,
|
||||||
|
providerID: i.provider.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
onChange={(checked) => {
|
onChange={(checked) => {
|
||||||
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
|
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
|
||||||
}}
|
}}
|
||||||
+7
-8
@@ -1,12 +1,12 @@
|
|||||||
import { useLocal } from "@/context/local"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
||||||
import { List } from "@opencode-ai/ui/list"
|
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
|
||||||
import { useLayout } from "@/context/layout"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
|
import { List } from "@opencode-ai/ui/list"
|
||||||
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useLocal } from "@/context/local"
|
||||||
|
|
||||||
export function DialogSelectFile() {
|
export function DialogSelectFile() {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
@@ -18,7 +18,6 @@ export function DialogSelectFile() {
|
|||||||
return (
|
return (
|
||||||
<Dialog title="Select file">
|
<Dialog title="Select file">
|
||||||
<List
|
<List
|
||||||
class="px-2.5"
|
|
||||||
search={{ placeholder: "Search files", autofocus: true }}
|
search={{ placeholder: "Search files", autofocus: true }}
|
||||||
emptyMessage="No files found"
|
emptyMessage="No files found"
|
||||||
items={local.file.searchFiles}
|
items={local.file.searchFiles}
|
||||||
@@ -32,7 +31,7 @@ export function DialogSelectFile() {
|
|||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center justify-between rounded-md">
|
<div class="w-full flex items-center justify-between rounded-md">
|
||||||
<div class="flex items-center gap-x-2 grow min-w-0">
|
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||||
<div class="flex items-center text-14-regular">
|
<div class="flex items-center text-14-regular">
|
||||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Component, createMemo, createSignal, Show } from "solid-js"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
import { List } from "@opencode-ai/ui/list"
|
||||||
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
|
|
||||||
|
export const DialogSelectMcp: Component = () => {
|
||||||
|
const sync = useSync()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const [loading, setLoading] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
const items = createMemo(() =>
|
||||||
|
Object.entries(sync.data.mcp ?? {})
|
||||||
|
.map(([name, status]) => ({ name, status: status.status }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggle = async (name: string) => {
|
||||||
|
if (loading()) return
|
||||||
|
setLoading(name)
|
||||||
|
const status = sync.data.mcp[name]
|
||||||
|
if (status?.status === "connected") {
|
||||||
|
await sdk.client.mcp.disconnect({ name })
|
||||||
|
} else {
|
||||||
|
await sdk.client.mcp.connect({ name })
|
||||||
|
}
|
||||||
|
const result = await sdk.client.mcp.status()
|
||||||
|
if (result.data) sync.set("mcp", result.data)
|
||||||
|
setLoading(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||||
|
const totalCount = createMemo(() => items().length)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title="MCPs" description={`${enabledCount()} of ${totalCount()} enabled`}>
|
||||||
|
<List
|
||||||
|
search={{ placeholder: "Search", autofocus: true }}
|
||||||
|
emptyMessage="No MCPs configured"
|
||||||
|
key={(x) => x?.name ?? ""}
|
||||||
|
items={items}
|
||||||
|
filterKeys={["name", "status"]}
|
||||||
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||||
|
onSelect={(x) => {
|
||||||
|
if (x) toggle(x.name)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(i) => {
|
||||||
|
const mcpStatus = () => sync.data.mcp[i.name]
|
||||||
|
const status = () => mcpStatus()?.status
|
||||||
|
const error = () => {
|
||||||
|
const s = mcpStatus()
|
||||||
|
return s?.status === "failed" ? s.error : undefined
|
||||||
|
}
|
||||||
|
const enabled = () => status() === "connected"
|
||||||
|
return (
|
||||||
|
<div class="w-full flex items-center justify-between gap-x-3">
|
||||||
|
<div class="flex flex-col gap-0.5 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="truncate">{i.name}</span>
|
||||||
|
<Show when={status() === "connected"}>
|
||||||
|
<span class="text-11-regular text-text-weaker">connected</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={status() === "failed"}>
|
||||||
|
<span class="text-11-regular text-text-weaker">failed</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={status() === "needs_auth"}>
|
||||||
|
<span class="text-11-regular text-text-weaker">needs auth</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={status() === "disabled"}>
|
||||||
|
<span class="text-11-regular text-text-weaker">disabled</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={loading() === i.name}>
|
||||||
|
<span class="text-11-regular text-text-weak">...</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={error()}>
|
||||||
|
<span class="text-11-regular text-text-weaker truncate">{error()}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</List>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
+11
-20
@@ -1,15 +1,15 @@
|
|||||||
import { Component, onCleanup, onMount, Show } from "solid-js"
|
|
||||||
import { useLocal } from "@/context/local"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List, ListRef } from "@opencode-ai/ui/list"
|
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||||
|
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { IconName } from "@opencode-ai/ui/icons/provider"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { type Component, onCleanup, onMount, Show } from "solid-js"
|
||||||
|
import { useLocal } from "@/context/local"
|
||||||
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||||
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
|
||||||
export const DialogSelectModelUnpaid: Component = () => {
|
export const DialogSelectModelUnpaid: Component = () => {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
@@ -64,7 +64,7 @@ export const DialogSelectModelUnpaid: Component = () => {
|
|||||||
<div class="px-2 text-14-medium text-text-base">Add more models from popular providers</div>
|
<div class="px-2 text-14-medium text-text-base">Add more models from popular providers</div>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<List
|
<List
|
||||||
class="w-full"
|
class="w-full px-0"
|
||||||
key={(x) => x?.id}
|
key={(x) => x?.id}
|
||||||
items={providers.popular}
|
items={providers.popular}
|
||||||
activeIcon="plus-small"
|
activeIcon="plus-small"
|
||||||
@@ -79,17 +79,8 @@ export const DialogSelectModelUnpaid: Component = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center gap-x-4">
|
<div class="w-full flex items-center gap-x-3">
|
||||||
<ProviderIcon
|
<ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
|
||||||
data-slot="list-item-extra-icon"
|
|
||||||
id={i.id as IconName}
|
|
||||||
// TODO: clean this up after we update icon in models.dev
|
|
||||||
classList={{
|
|
||||||
"text-icon-weak-base": true,
|
|
||||||
"size-4 mx-0.5": i.id === "opencode",
|
|
||||||
"size-5": i.id !== "opencode",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
<Show when={i.id === "opencode"}>
|
<Show when={i.id === "opencode"}>
|
||||||
<Tag>Recommended</Tag>
|
<Tag>Recommended</Tag>
|
||||||
+1
-2
@@ -35,7 +35,6 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<List
|
<List
|
||||||
class="px-2.5"
|
|
||||||
search={{ placeholder: "Search models", autofocus: true }}
|
search={{ placeholder: "Search models", autofocus: true }}
|
||||||
emptyMessage="No model results"
|
emptyMessage="No model results"
|
||||||
key={(x) => `${x.provider.id}:${x.id}`}
|
key={(x) => `${x.provider.id}:${x.id}`}
|
||||||
@@ -61,7 +60,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center gap-x-2.5">
|
<div class="w-full flex items-center gap-x-3">
|
||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
|
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
|
||||||
<Tag>Free</Tag>
|
<Tag>Free</Tag>
|
||||||
+2
-12
@@ -15,7 +15,6 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
return (
|
return (
|
||||||
<Dialog title="Connect provider">
|
<Dialog title="Connect provider">
|
||||||
<List
|
<List
|
||||||
class="px-2.5"
|
|
||||||
search={{ placeholder: "Search providers", autofocus: true }}
|
search={{ placeholder: "Search providers", autofocus: true }}
|
||||||
activeIcon="plus-small"
|
activeIcon="plus-small"
|
||||||
key={(x) => x?.id}
|
key={(x) => x?.id}
|
||||||
@@ -38,17 +37,8 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="px-1.25 w-full flex items-center gap-x-4">
|
<div class="px-1.25 w-full flex items-center gap-x-3">
|
||||||
<ProviderIcon
|
<ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
|
||||||
data-slot="list-item-extra-icon"
|
|
||||||
id={i.id as IconName}
|
|
||||||
// TODO: clean this up after we update icon in models.dev
|
|
||||||
classList={{
|
|
||||||
"text-icon-weak-base": true,
|
|
||||||
"size-4 mx-0.5": i.id === "opencode",
|
|
||||||
"size-5": i.id !== "opencode",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
<Show when={i.id === "opencode"}>
|
<Show when={i.id === "opencode"}>
|
||||||
<Tag>Recommended</Tag>
|
<Tag>Recommended</Tag>
|
||||||
+5
-5
@@ -2,7 +2,7 @@ import { useLocal, type LocalFile } from "@/context/local"
|
|||||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
|
import { For, Match, Switch, type ComponentProps, type ParentProps } from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
|
|
||||||
export default function FileTree(props: {
|
export default function FileTree(props: {
|
||||||
@@ -57,14 +57,14 @@ export default function FileTree(props: {
|
|||||||
"text-text-muted/40": p.node.ignored,
|
"text-text-muted/40": p.node.ignored,
|
||||||
"text-text-muted/80": !p.node.ignored,
|
"text-text-muted/80": !p.node.ignored,
|
||||||
// "!text-text": local.file.active()?.path === p.node.path,
|
// "!text-text": local.file.active()?.path === p.node.path,
|
||||||
"!text-primary": local.file.changed(p.node.path),
|
// "!text-primary": local.file.changed(p.node.path),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{p.node.name}
|
{p.node.name}
|
||||||
</span>
|
</span>
|
||||||
<Show when={local.file.changed(p.node.path)}>
|
{/* <Show when={local.file.changed(p.node.path)}> */}
|
||||||
<span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" />
|
{/* <span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" /> */}
|
||||||
</Show>
|
{/* </Show> */}
|
||||||
</Dynamic>
|
</Dynamic>
|
||||||
)
|
)
|
||||||
|
|
||||||
+85
-27
@@ -10,6 +10,7 @@ import { Select } from "@opencode-ai/ui/select"
|
|||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
|
import { useCommand } from "@/context/command"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { A, useParams } from "@solidjs/router"
|
import { A, useParams } from "@solidjs/router"
|
||||||
import { createMemo, createResource, Show } from "solid-js"
|
import { createMemo, createResource, Show } from "solid-js"
|
||||||
@@ -19,19 +20,29 @@ import { iife } from "@opencode-ai/util/iife"
|
|||||||
export function Header(props: {
|
export function Header(props: {
|
||||||
navigateToProject: (directory: string) => void
|
navigateToProject: (directory: string) => void
|
||||||
navigateToSession: (session: Session | undefined) => void
|
navigateToSession: (session: Session | undefined) => void
|
||||||
|
onMobileMenuToggle?: () => void
|
||||||
}) {
|
}) {
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const command = useCommand()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
|
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
|
||||||
|
onClick={props.onMobileMenuToggle}
|
||||||
|
>
|
||||||
|
<Icon name="menu" size="small" />
|
||||||
|
</button>
|
||||||
<A
|
<A
|
||||||
href="/"
|
href="/"
|
||||||
classList={{
|
classList={{
|
||||||
|
"hidden xl:flex": true,
|
||||||
"w-12 shrink-0 px-4 py-3.5": true,
|
"w-12 shrink-0 px-4 py-3.5": true,
|
||||||
"flex items-center justify-start self-stretch": true,
|
"items-center justify-start self-stretch": true,
|
||||||
"border-r border-border-weak-base": true,
|
"border-r border-border-weak-base": true,
|
||||||
}}
|
}}
|
||||||
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
|
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
|
||||||
@@ -44,30 +55,32 @@ export function Header(props: {
|
|||||||
{(directory) => {
|
{(directory) => {
|
||||||
const currentDirectory = createMemo(() => base64Decode(directory()))
|
const currentDirectory = createMemo(() => base64Decode(directory()))
|
||||||
const store = createMemo(() => globalSync.child(currentDirectory())[0])
|
const store = createMemo(() => globalSync.child(currentDirectory())[0])
|
||||||
const sessions = createMemo(() => store().session ?? [])
|
const sessions = createMemo(() => (store().session ?? []).filter((s) => !s.parentID))
|
||||||
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
|
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
|
||||||
const shareEnabled = createMemo(() => store().config.share !== "disabled")
|
const shareEnabled = createMemo(() => store().config.share !== "disabled")
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<Select
|
<div class="hidden xl:flex items-center gap-2">
|
||||||
options={layout.projects.list().map((project) => project.worktree)}
|
<Select
|
||||||
current={currentDirectory()}
|
options={layout.projects.list().map((project) => project.worktree)}
|
||||||
label={(x) => getFilename(x)}
|
current={currentDirectory()}
|
||||||
onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
|
label={(x) => getFilename(x)}
|
||||||
class="text-14-regular text-text-base"
|
onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
|
||||||
variant="ghost"
|
class="text-14-regular text-text-base"
|
||||||
>
|
variant="ghost"
|
||||||
{/* @ts-ignore */}
|
>
|
||||||
{(i) => (
|
{/* @ts-ignore */}
|
||||||
<div class="flex items-center gap-2">
|
{(i) => (
|
||||||
<Icon name="folder" size="small" />
|
<div class="flex items-center gap-2">
|
||||||
<div class="text-text-strong">{getFilename(i)}</div>
|
<Icon name="folder" size="small" />
|
||||||
</div>
|
<div class="text-text-strong">{getFilename(i)}</div>
|
||||||
)}
|
</div>
|
||||||
</Select>
|
)}
|
||||||
<div class="text-text-weaker">/</div>
|
</Select>
|
||||||
|
<div class="text-text-weaker">/</div>
|
||||||
|
</div>
|
||||||
<Select
|
<Select
|
||||||
options={sessions()}
|
options={sessions()}
|
||||||
current={currentSession()}
|
current={currentSession()}
|
||||||
@@ -75,23 +88,64 @@ export function Header(props: {
|
|||||||
label={(x) => x.title}
|
label={(x) => x.title}
|
||||||
value={(x) => x.id}
|
value={(x) => x.id}
|
||||||
onSelect={props.navigateToSession}
|
onSelect={props.navigateToSession}
|
||||||
class="text-14-regular text-text-base max-w-md"
|
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Show when={currentSession()}>
|
<Show when={currentSession()}>
|
||||||
<Button as={A} href={`/${params.dir}/session`} icon="plus-small">
|
<Tooltip
|
||||||
New session
|
class="hidden xl:block"
|
||||||
</Button>
|
value={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>New session</span>
|
||||||
|
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button as={A} href={`/${params.dir}/session`} icon="plus-small">
|
||||||
|
New session
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
<Show when={currentSession()?.summary?.files}>
|
||||||
|
<Tooltip
|
||||||
|
class="hidden md:block shrink-0"
|
||||||
|
value={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>Toggle review</span>
|
||||||
|
<span class="text-icon-base text-12-medium">{command.keybind("review.toggle")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
|
||||||
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
|
<Icon
|
||||||
|
name={layout.review.opened() ? "layout-right" : "layout-left"}
|
||||||
|
size="small"
|
||||||
|
class="group-hover/review-toggle:hidden"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
|
||||||
|
size="small"
|
||||||
|
class="hidden group-hover/review-toggle:inline-block"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
|
||||||
|
size="small"
|
||||||
|
class="hidden group-active/review-toggle:inline-block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Show>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
class="shrink-0"
|
class="hidden md:block shrink-0"
|
||||||
value={
|
value={
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span>Toggle terminal</span>
|
<span>Toggle terminal</span>
|
||||||
<span class="text-icon-base text-12-medium">Ctrl `</span>
|
<span class="text-icon-base text-12-medium">{command.keybind("terminal.toggle")}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -134,6 +188,10 @@ export function Header(props: {
|
|||||||
shareURL = await globalSDK.client.session
|
shareURL = await globalSDK.client.session
|
||||||
.share({ sessionID: session.id, directory: currentDirectory() })
|
.share({ sessionID: session.id, directory: currentDirectory() })
|
||||||
.then((r) => r.data?.share?.url)
|
.then((r) => r.data?.share?.url)
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to share session", e)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return shareURL
|
return shareURL
|
||||||
},
|
},
|
||||||
+323
-127
@@ -19,9 +19,10 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
import { DialogSelectModel } from "@/components/dialog-select-model"
|
||||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useCommand, formatKeybind } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||||
|
|
||||||
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||||
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
||||||
@@ -81,6 +82,37 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
let editorRef!: HTMLDivElement
|
let editorRef!: HTMLDivElement
|
||||||
let fileInputRef!: HTMLInputElement
|
let fileInputRef!: HTMLInputElement
|
||||||
|
let scrollRef!: HTMLDivElement
|
||||||
|
|
||||||
|
const scrollCursorIntoView = () => {
|
||||||
|
const container = scrollRef
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!container || !selection || selection.rangeCount === 0) return
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
if (!editorRef.contains(range.startContainer)) return
|
||||||
|
|
||||||
|
const rect = range.getBoundingClientRect()
|
||||||
|
if (!rect.height) return
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect()
|
||||||
|
const top = rect.top - containerRect.top + container.scrollTop
|
||||||
|
const bottom = rect.bottom - containerRect.top + container.scrollTop
|
||||||
|
const padding = 12
|
||||||
|
|
||||||
|
if (top < container.scrollTop + padding) {
|
||||||
|
container.scrollTop = Math.max(0, top - padding)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bottom > container.scrollTop + container.clientHeight - padding) {
|
||||||
|
container.scrollTop = bottom - container.clientHeight + padding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueScroll = () => {
|
||||||
|
requestAnimationFrame(scrollCursorIntoView)
|
||||||
|
}
|
||||||
|
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||||
@@ -102,7 +134,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
imageAttachments: ImageAttachmentPart[]
|
imageAttachments: ImageAttachmentPart[]
|
||||||
mode: "normal" | "shell"
|
mode: "normal" | "shell"
|
||||||
applyingHistory: boolean
|
applyingHistory: boolean
|
||||||
userHasEdited: boolean
|
|
||||||
}>({
|
}>({
|
||||||
popover: null,
|
popover: null,
|
||||||
historyIndex: -1,
|
historyIndex: -1,
|
||||||
@@ -112,7 +143,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
imageAttachments: [],
|
imageAttachments: [],
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
applyingHistory: false,
|
applyingHistory: false,
|
||||||
userHasEdited: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const MAX_HISTORY = 100
|
const MAX_HISTORY = 100
|
||||||
@@ -149,12 +179,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
|
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
|
||||||
const length = position === "start" ? 0 : promptLength(p)
|
const length = position === "start" ? 0 : promptLength(p)
|
||||||
setStore("applyingHistory", true)
|
setStore("applyingHistory", true)
|
||||||
setStore("userHasEdited", false)
|
|
||||||
prompt.set(p, length)
|
prompt.set(p, length)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
editorRef.focus()
|
editorRef.focus()
|
||||||
setCursorPosition(editorRef, length)
|
setCursorPosition(editorRef, length)
|
||||||
setStore("applyingHistory", false)
|
setStore("applyingHistory", false)
|
||||||
|
queueScroll()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +248,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePaste = async (event: ClipboardEvent) => {
|
const handlePaste = async (event: ClipboardEvent) => {
|
||||||
|
if (!isFocused()) return
|
||||||
const clipboardData = event.clipboardData
|
const clipboardData = event.clipboardData
|
||||||
if (!clipboardData) return
|
if (!clipboardData) return
|
||||||
|
|
||||||
@@ -240,7 +271,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragOver = (event: DragEvent) => {
|
const handleGlobalDragOver = (event: DragEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const hasFiles = event.dataTransfer?.types.includes("Files")
|
const hasFiles = event.dataTransfer?.types.includes("Files")
|
||||||
if (hasFiles) {
|
if (hasFiles) {
|
||||||
@@ -248,15 +279,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragLeave = (event: DragEvent) => {
|
const handleGlobalDragLeave = (event: DragEvent) => {
|
||||||
const related = event.relatedTarget as Node | null
|
// relatedTarget is null when leaving the document window
|
||||||
const form = event.currentTarget as HTMLElement
|
if (!event.relatedTarget) {
|
||||||
if (!related || !form.contains(related)) {
|
|
||||||
setStore("dragging", false)
|
setStore("dragging", false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDrop = async (event: DragEvent) => {
|
const handleGlobalDrop = async (event: DragEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setStore("dragging", false)
|
setStore("dragging", false)
|
||||||
|
|
||||||
@@ -272,17 +302,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
editorRef.addEventListener("paste", handlePaste)
|
editorRef.addEventListener("paste", handlePaste)
|
||||||
|
document.addEventListener("dragover", handleGlobalDragOver)
|
||||||
|
document.addEventListener("dragleave", handleGlobalDragLeave)
|
||||||
|
document.addEventListener("drop", handleGlobalDrop)
|
||||||
})
|
})
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
editorRef.removeEventListener("paste", handlePaste)
|
editorRef.removeEventListener("paste", handlePaste)
|
||||||
|
document.removeEventListener("dragover", handleGlobalDragOver)
|
||||||
|
document.removeEventListener("dragleave", handleGlobalDragLeave)
|
||||||
|
document.removeEventListener("drop", handleGlobalDrop)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (isFocused()) {
|
if (!isFocused()) setStore("popover", null)
|
||||||
handleInput()
|
|
||||||
} else {
|
|
||||||
setStore("popover", null)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleFileSelect = (path: string | undefined) => {
|
const handleFileSelect = (path: string | undefined) => {
|
||||||
@@ -362,7 +394,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
() => prompt.current(),
|
() => prompt.current(),
|
||||||
(currentParts) => {
|
(currentParts) => {
|
||||||
const domParts = parseFromDOM()
|
const domParts = parseFromDOM()
|
||||||
if (isPromptEqual(currentParts, domParts)) return
|
const normalized = Array.from(editorRef.childNodes).every((node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = node.textContent ?? ""
|
||||||
|
if (!text.includes("\u200B")) return true
|
||||||
|
if (text !== "\u200B") return false
|
||||||
|
|
||||||
|
const prev = node.previousSibling
|
||||||
|
const next = node.nextSibling
|
||||||
|
const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
|
||||||
|
const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
|
||||||
|
if (!prevIsBr && !nextIsBr) return false
|
||||||
|
if (nextIsBr && !prevIsBr && prev) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return false
|
||||||
|
const el = node as HTMLElement
|
||||||
|
if (el.dataset.type === "file") return true
|
||||||
|
return el.tagName === "BR"
|
||||||
|
})
|
||||||
|
if (normalized && isPromptEqual(currentParts, domParts)) return
|
||||||
|
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
let cursorPosition: number | null = null
|
let cursorPosition: number | null = null
|
||||||
@@ -373,7 +424,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
editorRef.innerHTML = ""
|
editorRef.innerHTML = ""
|
||||||
currentParts.forEach((part) => {
|
currentParts.forEach((part) => {
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
editorRef.appendChild(document.createTextNode(part.content))
|
editorRef.appendChild(createTextFragment(part.content))
|
||||||
} else if (part.type === "file") {
|
} else if (part.type === "file") {
|
||||||
const pill = document.createElement("span")
|
const pill = document.createElement("span")
|
||||||
pill.textContent = part.content
|
pill.textContent = part.content
|
||||||
@@ -394,34 +445,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const parseFromDOM = (): Prompt => {
|
const parseFromDOM = (): Prompt => {
|
||||||
const newParts: Prompt = []
|
const parts: Prompt = []
|
||||||
let position = 0
|
let position = 0
|
||||||
|
let buffer = ""
|
||||||
|
|
||||||
const pushText = (content: string) => {
|
const flushText = () => {
|
||||||
|
const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "")
|
||||||
|
buffer = ""
|
||||||
if (!content) return
|
if (!content) return
|
||||||
newParts.push({ type: "text", content, start: position, end: position + content.length })
|
parts.push({ type: "text", content, start: position, end: position + content.length })
|
||||||
position += content.length
|
position += content.length
|
||||||
}
|
}
|
||||||
|
|
||||||
const rangeText = (range: Range) => {
|
const pushFile = (file: HTMLElement) => {
|
||||||
const fragment = range.cloneContents()
|
|
||||||
const container = document.createElement("div")
|
|
||||||
container.append(fragment)
|
|
||||||
return container.innerText
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = Array.from(editorRef.querySelectorAll<HTMLElement>("[data-type=file]"))
|
|
||||||
let last: HTMLElement | undefined
|
|
||||||
|
|
||||||
files.forEach((file) => {
|
|
||||||
const before = document.createRange()
|
|
||||||
before.selectNodeContents(editorRef)
|
|
||||||
if (last) before.setStartAfter(last)
|
|
||||||
before.setEndBefore(file)
|
|
||||||
pushText(rangeText(before))
|
|
||||||
|
|
||||||
const content = file.textContent ?? ""
|
const content = file.textContent ?? ""
|
||||||
newParts.push({
|
parts.push({
|
||||||
type: "file",
|
type: "file",
|
||||||
path: file.dataset.path!,
|
path: file.dataset.path!,
|
||||||
content,
|
content,
|
||||||
@@ -429,16 +467,44 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
end: position + content.length,
|
end: position + content.length,
|
||||||
})
|
})
|
||||||
position += content.length
|
position += content.length
|
||||||
last = file
|
}
|
||||||
|
|
||||||
|
const visit = (node: Node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
buffer += node.textContent ?? ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return
|
||||||
|
|
||||||
|
const el = node as HTMLElement
|
||||||
|
if (el.dataset.type === "file") {
|
||||||
|
flushText()
|
||||||
|
pushFile(el)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (el.tagName === "BR") {
|
||||||
|
buffer += "\n"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Array.from(el.childNodes)) {
|
||||||
|
visit(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = Array.from(editorRef.childNodes)
|
||||||
|
children.forEach((child, index) => {
|
||||||
|
const isBlock = child.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((child as HTMLElement).tagName)
|
||||||
|
visit(child)
|
||||||
|
if (isBlock && index < children.length - 1) {
|
||||||
|
buffer += "\n"
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const after = document.createRange()
|
flushText()
|
||||||
after.selectNodeContents(editorRef)
|
|
||||||
if (last) after.setStartAfter(last)
|
|
||||||
pushText(rangeText(after))
|
|
||||||
|
|
||||||
if (newParts.length === 0) newParts.push(...DEFAULT_PROMPT)
|
if (parts.length === 0) parts.push(...DEFAULT_PROMPT)
|
||||||
return newParts
|
return parts
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInput = () => {
|
const handleInput = () => {
|
||||||
@@ -451,7 +517,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
if (shouldReset) {
|
if (shouldReset) {
|
||||||
setStore("popover", null)
|
setStore("popover", null)
|
||||||
setStore("userHasEdited", false)
|
|
||||||
if (store.historyIndex >= 0 && !store.applyingHistory) {
|
if (store.historyIndex >= 0 && !store.applyingHistory) {
|
||||||
setStore("historyIndex", -1)
|
setStore("historyIndex", -1)
|
||||||
setStore("savedPrompt", null)
|
setStore("savedPrompt", null)
|
||||||
@@ -459,6 +524,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (prompt.dirty()) {
|
if (prompt.dirty()) {
|
||||||
prompt.set(DEFAULT_PROMPT, 0)
|
prompt.set(DEFAULT_PROMPT, 0)
|
||||||
}
|
}
|
||||||
|
queueScroll()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,11 +552,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
setStore("savedPrompt", null)
|
setStore("savedPrompt", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!store.applyingHistory) {
|
|
||||||
setStore("userHasEdited", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt.set(rawParts, cursorPosition)
|
prompt.set(rawParts, cursorPosition)
|
||||||
|
queueScroll()
|
||||||
}
|
}
|
||||||
|
|
||||||
const addPart = (part: ContentPart) => {
|
const addPart = (part: ContentPart) => {
|
||||||
@@ -515,27 +578,40 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const gap = document.createTextNode(" ")
|
const gap = document.createTextNode(" ")
|
||||||
const range = selection.getRangeAt(0)
|
const range = selection.getRangeAt(0)
|
||||||
|
|
||||||
if (atMatch) {
|
const setEdge = (edge: "start" | "end", offset: number) => {
|
||||||
let runningLength = 0
|
let remaining = offset
|
||||||
|
const nodes = Array.from(editorRef.childNodes)
|
||||||
|
|
||||||
const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
|
for (const node of nodes) {
|
||||||
let currentNode = walker.nextNode()
|
const length = getNodeLength(node)
|
||||||
while (currentNode) {
|
const isText = node.nodeType === Node.TEXT_NODE
|
||||||
const textContent = currentNode.textContent || ""
|
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
|
||||||
if (runningLength + textContent.length >= atMatch.index!) {
|
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
|
||||||
const localStart = atMatch.index! - runningLength
|
|
||||||
const localEnd = cursorPosition - runningLength
|
if (isText && remaining <= length) {
|
||||||
if (currentNode === range.startContainer || runningLength + textContent.length >= cursorPosition) {
|
if (edge === "start") range.setStart(node, remaining)
|
||||||
range.setStart(currentNode, localStart)
|
if (edge === "end") range.setEnd(node, remaining)
|
||||||
range.setEnd(currentNode, Math.min(localEnd, textContent.length))
|
return
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
runningLength += textContent.length
|
|
||||||
currentNode = walker.nextNode()
|
if ((isFile || isBreak) && remaining <= length) {
|
||||||
|
if (edge === "start" && remaining === 0) range.setStartBefore(node)
|
||||||
|
if (edge === "start" && remaining > 0) range.setStartAfter(node)
|
||||||
|
if (edge === "end" && remaining === 0) range.setEndBefore(node)
|
||||||
|
if (edge === "end" && remaining > 0) range.setEndAfter(node)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining -= length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (atMatch) {
|
||||||
|
const start = atMatch.index ?? cursorPosition - atMatch[0].length
|
||||||
|
setEdge("start", start)
|
||||||
|
setEdge("end", cursorPosition)
|
||||||
|
}
|
||||||
|
|
||||||
range.deleteContents()
|
range.deleteContents()
|
||||||
range.insertNode(gap)
|
range.insertNode(gap)
|
||||||
range.insertNode(pill)
|
range.insertNode(pill)
|
||||||
@@ -544,11 +620,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
selection.removeAllRanges()
|
selection.removeAllRanges()
|
||||||
selection.addRange(range)
|
selection.addRange(range)
|
||||||
} else if (part.type === "text") {
|
} else if (part.type === "text") {
|
||||||
const textNode = document.createTextNode(part.content)
|
|
||||||
const range = selection.getRangeAt(0)
|
const range = selection.getRangeAt(0)
|
||||||
|
const fragment = createTextFragment(part.content)
|
||||||
|
const last = fragment.lastChild
|
||||||
range.deleteContents()
|
range.deleteContents()
|
||||||
range.insertNode(textNode)
|
range.insertNode(fragment)
|
||||||
range.setStartAfter(textNode)
|
if (last) {
|
||||||
|
if (last.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = last.textContent ?? ""
|
||||||
|
if (text === "\u200B") {
|
||||||
|
range.setStart(last, 0)
|
||||||
|
}
|
||||||
|
if (text !== "\u200B") {
|
||||||
|
range.setStart(last, text.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (last.nodeType !== Node.TEXT_NODE) {
|
||||||
|
range.setStartAfter(last)
|
||||||
|
}
|
||||||
|
}
|
||||||
range.collapse(true)
|
range.collapse(true)
|
||||||
selection.removeAllRanges()
|
selection.removeAllRanges()
|
||||||
selection.addRange(range)
|
selection.addRange(range)
|
||||||
@@ -559,9 +649,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const abort = () =>
|
const abort = () =>
|
||||||
sdk.client.session.abort({
|
sdk.client.session
|
||||||
sessionID: params.id!,
|
.abort({
|
||||||
})
|
sessionID: params.id!,
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||||
const text = prompt
|
const text = prompt
|
||||||
@@ -583,8 +675,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const navigateHistory = (direction: "up" | "down") => {
|
const navigateHistory = (direction: "up" | "down") => {
|
||||||
if (store.userHasEdited) return false
|
|
||||||
|
|
||||||
const entries = store.mode === "shell" ? shellHistory.entries : history.entries
|
const entries = store.mode === "shell" ? shellHistory.entries : history.entries
|
||||||
const current = store.historyIndex
|
const current = store.historyIndex
|
||||||
|
|
||||||
@@ -627,6 +717,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Backspace") {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (selection && selection.isCollapsed) {
|
||||||
|
const node = selection.anchorNode
|
||||||
|
const offset = selection.anchorOffset
|
||||||
|
if (node && node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = node.textContent ?? ""
|
||||||
|
if (/^\u200B+$/.test(text) && offset > 0) {
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStart(node, 0)
|
||||||
|
range.collapse(true)
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (event.key === "!" && store.mode === "normal") {
|
if (event.key === "!" && store.mode === "normal") {
|
||||||
const cursorPosition = getCursorPosition(editorRef)
|
const cursorPosition = getCursorPosition(editorRef)
|
||||||
if (cursorPosition === 0) {
|
if (cursorPosition === 0) {
|
||||||
@@ -667,7 +775,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const cursorPosition = getCursorPosition(editorRef)
|
const cursorPosition = getCursorPosition(editorRef)
|
||||||
const textLength = promptLength(prompt.current())
|
const textLength = promptLength(prompt.current())
|
||||||
const textContent = editorRef.textContent ?? ""
|
const textContent = prompt
|
||||||
|
.current()
|
||||||
|
.map((part) => ("content" in part ? part.content : ""))
|
||||||
|
.join("")
|
||||||
const isEmpty = textContent.trim() === "" || textLength <= 1
|
const isEmpty = textContent.trim() === "" || textLength <= 1
|
||||||
const hasNewlines = textContent.includes("\n")
|
const hasNewlines = textContent.includes("\n")
|
||||||
const inHistory = store.historyIndex >= 0
|
const inHistory = store.historyIndex >= 0
|
||||||
@@ -691,6 +802,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.key === "Enter" && event.shiftKey) {
|
||||||
|
addPart({ type: "text", content: "\n", start: 0, end: 0 })
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
handleSubmit(event)
|
handleSubmit(event)
|
||||||
}
|
}
|
||||||
@@ -716,7 +832,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
addToHistory(currentPrompt, store.mode)
|
addToHistory(currentPrompt, store.mode)
|
||||||
setStore("historyIndex", -1)
|
setStore("historyIndex", -1)
|
||||||
setStore("savedPrompt", null)
|
setStore("savedPrompt", null)
|
||||||
setStore("userHasEdited", false)
|
|
||||||
|
|
||||||
let existing = info()
|
let existing = info()
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -776,12 +891,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const agent = local.agent.current()!.name
|
const agent = local.agent.current()!.name
|
||||||
|
|
||||||
if (isShellMode) {
|
if (isShellMode) {
|
||||||
sdk.client.session.shell({
|
sdk.client.session
|
||||||
sessionID: existing.id,
|
.shell({
|
||||||
agent,
|
sessionID: existing.id,
|
||||||
model,
|
agent,
|
||||||
command: text,
|
model,
|
||||||
})
|
command: text,
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to send shell command", e)
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,13 +909,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const commandName = cmdName.slice(1)
|
const commandName = cmdName.slice(1)
|
||||||
const customCommand = sync.data.command.find((c) => c.name === commandName)
|
const customCommand = sync.data.command.find((c) => c.name === commandName)
|
||||||
if (customCommand) {
|
if (customCommand) {
|
||||||
sdk.client.session.command({
|
sdk.client.session
|
||||||
sessionID: existing.id,
|
.command({
|
||||||
command: commandName,
|
sessionID: existing.id,
|
||||||
arguments: args.join(" "),
|
command: commandName,
|
||||||
agent,
|
arguments: args.join(" "),
|
||||||
model: `${model.providerID}/${model.modelID}`,
|
agent,
|
||||||
})
|
model: `${model.providerID}/${model.modelID}`,
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to send command", e)
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -822,13 +945,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
model,
|
model,
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.client.session.prompt({
|
sdk.client.session
|
||||||
sessionID: existing.id,
|
.prompt({
|
||||||
agent,
|
sessionID: existing.id,
|
||||||
model,
|
agent,
|
||||||
messageID,
|
model,
|
||||||
parts: requestParts,
|
messageID,
|
||||||
})
|
parts: requestParts,
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to send prompt", e)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -889,8 +1016,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
custom
|
custom
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={cmd.keybind}>
|
<Show when={command.keybind(cmd.id)}>
|
||||||
<span class="text-12-regular text-text-subtle">{formatKeybind(cmd.keybind!)}</span>
|
<span class="text-12-regular text-text-subtle">{command.keybind(cmd.id)}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -903,9 +1030,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
classList={{
|
classList={{
|
||||||
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
|
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
|
||||||
"rounded-md overflow-clip focus-within:shadow-xs-border": true,
|
"rounded-md overflow-clip focus-within:shadow-xs-border": true,
|
||||||
@@ -955,7 +1079,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="relative max-h-[240px] overflow-y-auto">
|
<div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-input"
|
data-component="prompt-input"
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
@@ -966,18 +1090,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
classList={{
|
classList={{
|
||||||
"w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
"w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||||
"[&>[data-type=file]]:text-icon-info-active": true,
|
"[&_[data-type=file]]:text-icon-info-active": true,
|
||||||
"font-mono!": store.mode === "shell",
|
"font-mono!": store.mode === "shell",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
|
<Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
|
||||||
<div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none">
|
<div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
|
||||||
{store.mode === "shell"
|
{store.mode === "shell"
|
||||||
? "Enter shell command..."
|
? "Enter shell command..."
|
||||||
: `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
|
: `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
<div class="absolute top-4.5 right-4">
|
||||||
|
<SessionContextUsage />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative p-3 flex items-center justify-between">
|
<div class="relative p-3 flex items-center justify-between">
|
||||||
<div class="flex items-center justify-start gap-1">
|
<div class="flex items-center justify-start gap-1">
|
||||||
@@ -990,26 +1117,48 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.mode === "normal"}>
|
<Match when={store.mode === "normal"}>
|
||||||
<Select
|
<Tooltip
|
||||||
options={local.agent.list().map((agent) => agent.name)}
|
placement="top"
|
||||||
current={local.agent.current().name}
|
value={
|
||||||
onSelect={local.agent.set}
|
<div class="flex items-center gap-2">
|
||||||
class="capitalize"
|
<span>Cycle agent</span>
|
||||||
variant="ghost"
|
<span class="text-icon-base text-12-medium">{command.keybind("agent.cycle")}</span>
|
||||||
/>
|
</div>
|
||||||
<Button
|
|
||||||
as="div"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
dialog.show(() =>
|
|
||||||
providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{local.model.current()?.name ?? "Select model"}
|
<Select
|
||||||
<span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
|
options={local.agent.list().map((agent) => agent.name)}
|
||||||
<Icon name="chevron-down" size="small" />
|
current={local.agent.current().name}
|
||||||
</Button>
|
onSelect={local.agent.set}
|
||||||
|
class="capitalize"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
placement="top"
|
||||||
|
value={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>Choose model</span>
|
||||||
|
<span class="text-icon-base text-12-medium">{command.keybind("model.choose")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
as="div"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
dialog.show(() =>
|
||||||
|
providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{local.model.current()?.name ?? "Select model"}
|
||||||
|
<span class="hidden md:block ml-0.5 text-text-weak text-12-regular">
|
||||||
|
{local.model.current()?.provider.name}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
@@ -1071,23 +1220,56 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTextFragment(content: string): DocumentFragment {
|
||||||
|
const fragment = document.createDocumentFragment()
|
||||||
|
const segments = content.split("\n")
|
||||||
|
segments.forEach((segment, index) => {
|
||||||
|
if (segment) {
|
||||||
|
fragment.appendChild(document.createTextNode(segment))
|
||||||
|
} else if (segments.length > 1) {
|
||||||
|
fragment.appendChild(document.createTextNode("\u200B"))
|
||||||
|
}
|
||||||
|
if (index < segments.length - 1) {
|
||||||
|
fragment.appendChild(document.createElement("br"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return fragment
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeLength(node: Node): number {
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
|
||||||
|
return (node.textContent ?? "").replace(/\u200B/g, "").length
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextLength(node: Node): number {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
|
||||||
|
let length = 0
|
||||||
|
for (const child of Array.from(node.childNodes)) {
|
||||||
|
length += getTextLength(child)
|
||||||
|
}
|
||||||
|
return length
|
||||||
|
}
|
||||||
|
|
||||||
function getCursorPosition(parent: HTMLElement): number {
|
function getCursorPosition(parent: HTMLElement): number {
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (!selection || selection.rangeCount === 0) return 0
|
if (!selection || selection.rangeCount === 0) return 0
|
||||||
const range = selection.getRangeAt(0)
|
const range = selection.getRangeAt(0)
|
||||||
|
if (!parent.contains(range.startContainer)) return 0
|
||||||
const preCaretRange = range.cloneRange()
|
const preCaretRange = range.cloneRange()
|
||||||
preCaretRange.selectNodeContents(parent)
|
preCaretRange.selectNodeContents(parent)
|
||||||
preCaretRange.setEnd(range.startContainer, range.startOffset)
|
preCaretRange.setEnd(range.startContainer, range.startOffset)
|
||||||
return preCaretRange.toString().length
|
return getTextLength(preCaretRange.cloneContents())
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCursorPosition(parent: HTMLElement, position: number) {
|
function setCursorPosition(parent: HTMLElement, position: number) {
|
||||||
let remaining = position
|
let remaining = position
|
||||||
let node = parent.firstChild
|
let node = parent.firstChild
|
||||||
while (node) {
|
while (node) {
|
||||||
const length = node.textContent ? node.textContent.length : 0
|
const length = getNodeLength(node)
|
||||||
const isText = node.nodeType === Node.TEXT_NODE
|
const isText = node.nodeType === Node.TEXT_NODE
|
||||||
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
|
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
|
||||||
|
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
|
||||||
|
|
||||||
if (isText && remaining <= length) {
|
if (isText && remaining <= length) {
|
||||||
const range = document.createRange()
|
const range = document.createRange()
|
||||||
@@ -1099,10 +1281,24 @@ function setCursorPosition(parent: HTMLElement, position: number) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFile && remaining <= length) {
|
if ((isFile || isBreak) && remaining <= length) {
|
||||||
const range = document.createRange()
|
const range = document.createRange()
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
range.setStartAfter(node)
|
if (remaining === 0) {
|
||||||
|
range.setStartBefore(node)
|
||||||
|
}
|
||||||
|
if (remaining > 0 && isFile) {
|
||||||
|
range.setStartAfter(node)
|
||||||
|
}
|
||||||
|
if (remaining > 0 && isBreak) {
|
||||||
|
const next = node.nextSibling
|
||||||
|
if (next && next.nodeType === Node.TEXT_NODE) {
|
||||||
|
range.setStart(next, 0)
|
||||||
|
}
|
||||||
|
if (!next || next.nodeType !== Node.TEXT_NODE) {
|
||||||
|
range.setStartAfter(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
range.collapse(true)
|
range.collapse(true)
|
||||||
selection?.removeAllRanges()
|
selection?.removeAllRanges()
|
||||||
selection?.addRange(range)
|
selection?.addRange(range)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { createMemo, Show } from "solid-js"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
export function SessionContextUsage() {
|
||||||
|
const sync = useSync()
|
||||||
|
const params = useParams()
|
||||||
|
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||||
|
|
||||||
|
const cost = createMemo(() => {
|
||||||
|
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(total)
|
||||||
|
})
|
||||||
|
|
||||||
|
const context = createMemo(() => {
|
||||||
|
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
|
||||||
|
if (!last) return
|
||||||
|
const total =
|
||||||
|
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||||
|
const model = sync.data.provider.all.find((x) => x.id === last.providerID)?.models[last.modelID]
|
||||||
|
return {
|
||||||
|
tokens: total.toLocaleString(),
|
||||||
|
percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={context?.()}>
|
||||||
|
{(ctx) => (
|
||||||
|
<Tooltip
|
||||||
|
value={
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<span class="opacity-70 text-right flex-1">Tokens</span>
|
||||||
|
<span class="text-left flex-1">{ctx().tokens}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<span class="opacity-70 text-right flex-1">Usage</span>
|
||||||
|
<span class="text-left flex-1">{ctx().percentage ?? 0}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<span class="opacity-70 text-right flex-1">Cost</span>
|
||||||
|
<span class="text-left flex-1">{cost()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} />
|
||||||
|
{/* <span class="text-12-medium text-text-weak">{`${ctx().percentage ?? 0}%`}</span> */}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createMemo, Show } from "solid-js"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
|
||||||
|
export function SessionLspIndicator() {
|
||||||
|
const sync = useSync()
|
||||||
|
|
||||||
|
const lspStats = createMemo(() => {
|
||||||
|
const lsp = sync.data.lsp ?? []
|
||||||
|
const connected = lsp.filter((s) => s.status === "connected").length
|
||||||
|
const hasError = lsp.some((s) => s.status === "error")
|
||||||
|
const total = lsp.length
|
||||||
|
return { connected, hasError, total }
|
||||||
|
})
|
||||||
|
|
||||||
|
const tooltipContent = createMemo(() => {
|
||||||
|
const lsp = sync.data.lsp ?? []
|
||||||
|
if (lsp.length === 0) return "No LSP servers"
|
||||||
|
return lsp.map((s) => s.name).join(", ")
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={lspStats().total > 0}>
|
||||||
|
<Tooltip placement="top" value={tooltipContent()}>
|
||||||
|
<div class="flex items-center gap-1 px-2 cursor-default select-none">
|
||||||
|
<Icon
|
||||||
|
name="code"
|
||||||
|
size="small"
|
||||||
|
classList={{
|
||||||
|
"text-icon-critical-base": lspStats().hasError,
|
||||||
|
"text-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span class="text-12-regular text-text-weak">{lspStats().connected} LSP</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { createMemo, Show } from "solid-js"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
||||||
|
|
||||||
|
export function SessionMcpIndicator() {
|
||||||
|
const sync = useSync()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const mcpStats = createMemo(() => {
|
||||||
|
const mcp = sync.data.mcp ?? {}
|
||||||
|
const entries = Object.entries(mcp)
|
||||||
|
const enabled = entries.filter(([, status]) => status.status === "connected").length
|
||||||
|
const failed = entries.some(([, status]) => status.status === "failed")
|
||||||
|
const total = entries.length
|
||||||
|
return { enabled, failed, total }
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={mcpStats().total > 0}>
|
||||||
|
<Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}>
|
||||||
|
<Icon
|
||||||
|
name="mcp"
|
||||||
|
size="small"
|
||||||
|
classList={{
|
||||||
|
"text-icon-critical-base": mcpStats().failed,
|
||||||
|
"text-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span class="text-12-regular text-text-weak">{mcpStats().enabled} MCP</span>
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
|
|
||||||
|
export function StatusBar(props: ParentProps) {
|
||||||
|
const platform = usePlatform()
|
||||||
|
const sync = useSync()
|
||||||
|
const globalSync = useGlobalSync()
|
||||||
|
|
||||||
|
const directoryDisplay = createMemo(() => {
|
||||||
|
const directory = sync.data.path.directory || ""
|
||||||
|
const home = globalSync.data.path.home || ""
|
||||||
|
const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory
|
||||||
|
const branch = sync.data.vcs?.branch
|
||||||
|
return branch ? `${short}:${branch}` : short
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="h-8 w-full shrink-0 flex items-center justify-between px-2 border-t border-border-weak-base bg-background-base">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Show when={platform.version}>
|
||||||
|
<span class="text-12-regular text-text-weak">v{platform.version}</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={directoryDisplay()}>
|
||||||
|
<span class="text-12-regular text-text-weak">{directoryDisplay()}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">{props.children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
||||||
|
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { SerializeAddon } from "@/addons/serialize"
|
||||||
|
import { LocalPTY } from "@/context/terminal"
|
||||||
|
import { resolveThemeVariant, useTheme } from "@opencode-ai/ui/theme"
|
||||||
|
|
||||||
|
export interface TerminalProps extends ComponentProps<"div"> {
|
||||||
|
pty: LocalPTY
|
||||||
|
onSubmit?: () => void
|
||||||
|
onCleanup?: (pty: LocalPTY) => void
|
||||||
|
onConnectError?: (error: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type TerminalColors = {
|
||||||
|
background: string
|
||||||
|
foreground: string
|
||||||
|
cursor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
|
||||||
|
light: {
|
||||||
|
background: "#fcfcfc",
|
||||||
|
foreground: "#211e1e",
|
||||||
|
cursor: "#211e1e",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
background: "#191515",
|
||||||
|
foreground: "#d4d4d4",
|
||||||
|
cursor: "#d4d4d4",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Terminal = (props: TerminalProps) => {
|
||||||
|
const sdk = useSDK()
|
||||||
|
const theme = useTheme()
|
||||||
|
let container!: HTMLDivElement
|
||||||
|
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
|
||||||
|
let ws: WebSocket
|
||||||
|
let term: Term
|
||||||
|
let ghostty: Ghostty
|
||||||
|
let serializeAddon: SerializeAddon
|
||||||
|
let fitAddon: FitAddon
|
||||||
|
let handleResize: () => void
|
||||||
|
|
||||||
|
const getTerminalColors = (): TerminalColors => {
|
||||||
|
const mode = theme.mode()
|
||||||
|
const fallback = DEFAULT_TERMINAL_COLORS[mode]
|
||||||
|
const currentTheme = theme.themes()[theme.themeId()]
|
||||||
|
if (!currentTheme) return fallback
|
||||||
|
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
|
||||||
|
if (!variant?.seeds) return fallback
|
||||||
|
const resolved = resolveThemeVariant(variant, mode === "dark")
|
||||||
|
const text = resolved["text-base"] ?? fallback.foreground
|
||||||
|
const background = resolved["background-stronger"] ?? fallback.background
|
||||||
|
return {
|
||||||
|
background,
|
||||||
|
foreground: text,
|
||||||
|
cursor: text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const colors = getTerminalColors()
|
||||||
|
setTerminalColors(colors)
|
||||||
|
if (!term) return
|
||||||
|
const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption
|
||||||
|
if (!setOption) return
|
||||||
|
setOption("theme", colors)
|
||||||
|
})
|
||||||
|
|
||||||
|
const focusTerminal = () => term?.focus()
|
||||||
|
const copySelection = () => {
|
||||||
|
if (!term || !term.hasSelection()) return false
|
||||||
|
const selection = term.getSelection()
|
||||||
|
if (!selection) return false
|
||||||
|
const clipboard = navigator.clipboard
|
||||||
|
if (clipboard?.writeText) {
|
||||||
|
clipboard.writeText(selection).catch(() => {})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!document.body) return false
|
||||||
|
const textarea = document.createElement("textarea")
|
||||||
|
textarea.value = selection
|
||||||
|
textarea.setAttribute("readonly", "")
|
||||||
|
textarea.style.position = "fixed"
|
||||||
|
textarea.style.opacity = "0"
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
const copied = document.execCommand("copy")
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
return copied
|
||||||
|
}
|
||||||
|
const handlePointerDown = () => {
|
||||||
|
const activeElement = document.activeElement
|
||||||
|
if (activeElement instanceof HTMLElement && activeElement !== container) {
|
||||||
|
activeElement.blur()
|
||||||
|
}
|
||||||
|
focusTerminal()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
ghostty = await Ghostty.load()
|
||||||
|
|
||||||
|
ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
|
||||||
|
term = new Term({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "IBM Plex Mono, monospace",
|
||||||
|
allowTransparency: true,
|
||||||
|
theme: terminalColors(),
|
||||||
|
scrollback: 10_000,
|
||||||
|
ghostty,
|
||||||
|
})
|
||||||
|
term.attachCustomKeyEventHandler((event) => {
|
||||||
|
const key = event.key.toLowerCase()
|
||||||
|
if (key === "c") {
|
||||||
|
const macCopy = event.metaKey && !event.ctrlKey && !event.altKey
|
||||||
|
const linuxCopy = event.ctrlKey && event.shiftKey && !event.metaKey
|
||||||
|
if ((macCopy || linuxCopy) && copySelection()) {
|
||||||
|
event.preventDefault()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// allow for ctrl-` to toggle terminal in parent
|
||||||
|
if (event.ctrlKey && key === "`") {
|
||||||
|
event.preventDefault()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
fitAddon = new FitAddon()
|
||||||
|
serializeAddon = new SerializeAddon()
|
||||||
|
term.loadAddon(serializeAddon)
|
||||||
|
term.loadAddon(fitAddon)
|
||||||
|
|
||||||
|
term.open(container)
|
||||||
|
container.addEventListener("pointerdown", handlePointerDown)
|
||||||
|
focusTerminal()
|
||||||
|
|
||||||
|
if (local.pty.buffer) {
|
||||||
|
if (local.pty.rows && local.pty.cols) {
|
||||||
|
term.resize(local.pty.cols, local.pty.rows)
|
||||||
|
}
|
||||||
|
term.reset()
|
||||||
|
term.write(local.pty.buffer)
|
||||||
|
if (local.pty.scrollY) {
|
||||||
|
term.scrollToLine(local.pty.scrollY)
|
||||||
|
}
|
||||||
|
fitAddon.fit()
|
||||||
|
}
|
||||||
|
|
||||||
|
fitAddon.observeResize()
|
||||||
|
handleResize = () => fitAddon.fit()
|
||||||
|
window.addEventListener("resize", handleResize)
|
||||||
|
term.onResize(async (size) => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
await sdk.client.pty
|
||||||
|
.update({
|
||||||
|
ptyID: local.pty.id,
|
||||||
|
size: {
|
||||||
|
cols: size.cols,
|
||||||
|
rows: size.rows,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
term.onData((data) => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
term.onKey((key) => {
|
||||||
|
if (key.key == "Enter") {
|
||||||
|
props.onSubmit?.()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// term.onScroll((ydisp) => {
|
||||||
|
// console.log("Scroll position:", ydisp)
|
||||||
|
// })
|
||||||
|
ws.addEventListener("open", () => {
|
||||||
|
console.log("WebSocket connected")
|
||||||
|
sdk.client.pty
|
||||||
|
.update({
|
||||||
|
ptyID: local.pty.id,
|
||||||
|
size: {
|
||||||
|
cols: term.cols,
|
||||||
|
rows: term.rows,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
})
|
||||||
|
ws.addEventListener("message", (event) => {
|
||||||
|
term.write(event.data)
|
||||||
|
})
|
||||||
|
ws.addEventListener("error", (error) => {
|
||||||
|
console.error("WebSocket error:", error)
|
||||||
|
props.onConnectError?.(error)
|
||||||
|
})
|
||||||
|
ws.addEventListener("close", () => {
|
||||||
|
console.log("WebSocket disconnected")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (handleResize) {
|
||||||
|
window.removeEventListener("resize", handleResize)
|
||||||
|
}
|
||||||
|
container.removeEventListener("pointerdown", handlePointerDown)
|
||||||
|
if (serializeAddon && props.onCleanup) {
|
||||||
|
const buffer = serializeAddon.serialize()
|
||||||
|
props.onCleanup({
|
||||||
|
...local.pty,
|
||||||
|
buffer,
|
||||||
|
rows: term.rows,
|
||||||
|
cols: term.cols,
|
||||||
|
scrollY: term.getViewportY(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ws?.close()
|
||||||
|
term?.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={container}
|
||||||
|
data-component="terminal"
|
||||||
|
data-prevent-autofocus
|
||||||
|
style={{ "background-color": terminalColors().background }}
|
||||||
|
classList={{
|
||||||
|
...(local.classList ?? {}),
|
||||||
|
"size-full px-6 py-3 font-mono": true,
|
||||||
|
[local.class ?? ""]: !!local.class,
|
||||||
|
}}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export interface CommandOption {
|
|||||||
suggested?: boolean
|
suggested?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onSelect?: (source?: "palette" | "keybind" | "slash") => void
|
onSelect?: (source?: "palette" | "keybind" | "slash") => void
|
||||||
|
onHighlight?: () => (() => void) | void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseKeybind(config: string): Keybind[] {
|
export function parseKeybind(config: string): Keybind[] {
|
||||||
@@ -115,23 +116,40 @@ export function formatKeybind(config: string): string {
|
|||||||
|
|
||||||
function DialogCommand(props: { options: CommandOption[] }) {
|
function DialogCommand(props: { options: CommandOption[] }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
let cleanup: (() => void) | void
|
||||||
|
let committed = false
|
||||||
|
|
||||||
|
const handleMove = (option: CommandOption | undefined) => {
|
||||||
|
cleanup?.()
|
||||||
|
cleanup = option?.onHighlight?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelect = (option: CommandOption | undefined) => {
|
||||||
|
if (option) {
|
||||||
|
committed = true
|
||||||
|
cleanup = undefined
|
||||||
|
dialog.close()
|
||||||
|
option.onSelect?.("palette")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (!committed) {
|
||||||
|
cleanup?.()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title="Commands">
|
<Dialog title="Commands">
|
||||||
<List
|
<List
|
||||||
class="px-2.5"
|
|
||||||
search={{ placeholder: "Search commands", autofocus: true }}
|
search={{ placeholder: "Search commands", autofocus: true }}
|
||||||
emptyMessage="No commands found"
|
emptyMessage="No commands found"
|
||||||
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
|
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
|
||||||
key={(x) => x?.id}
|
key={(x) => x?.id}
|
||||||
filterKeys={["title", "description", "category"]}
|
filterKeys={["title", "description", "category"]}
|
||||||
groupBy={(x) => x.category ?? ""}
|
groupBy={(x) => x.category ?? ""}
|
||||||
onSelect={(option) => {
|
onMove={handleMove}
|
||||||
if (option) {
|
onSelect={handleSelect}
|
||||||
dialog.close()
|
|
||||||
option.onSelect?.("palette")
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{(option) => (
|
{(option) => (
|
||||||
<div class="w-full flex items-center justify-between gap-4">
|
<div class="w-full flex items-center justify-between gap-4">
|
||||||
@@ -226,6 +244,11 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
keybind(id: string) {
|
||||||
|
const option = options().find((x) => x.id === id || x.id === "suggested." + id)
|
||||||
|
if (!option?.keybind) return ""
|
||||||
|
return formatKeybind(option.keybind)
|
||||||
|
},
|
||||||
show: showPalette,
|
show: showPalette,
|
||||||
keybinds(enabled: boolean) {
|
keybinds(enabled: boolean) {
|
||||||
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
||||||
+113
-13
@@ -5,8 +5,6 @@ import {
|
|||||||
type Part,
|
type Part,
|
||||||
type Config,
|
type Config,
|
||||||
type Path,
|
type Path,
|
||||||
type File,
|
|
||||||
type FileNode,
|
|
||||||
type Project,
|
type Project,
|
||||||
type FileDiff,
|
type FileDiff,
|
||||||
type Todo,
|
type Todo,
|
||||||
@@ -14,6 +12,10 @@ import {
|
|||||||
type ProviderListResponse,
|
type ProviderListResponse,
|
||||||
type ProviderAuthResponse,
|
type ProviderAuthResponse,
|
||||||
type Command,
|
type Command,
|
||||||
|
type McpStatus,
|
||||||
|
type LspStatus,
|
||||||
|
type VcsInfo,
|
||||||
|
type Permission,
|
||||||
createOpencodeClient,
|
createOpencodeClient,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
@@ -43,6 +45,14 @@ type State = {
|
|||||||
todo: {
|
todo: {
|
||||||
[sessionID: string]: Todo[]
|
[sessionID: string]: Todo[]
|
||||||
}
|
}
|
||||||
|
permission: {
|
||||||
|
[sessionID: string]: Permission[]
|
||||||
|
}
|
||||||
|
mcp: {
|
||||||
|
[name: string]: McpStatus
|
||||||
|
}
|
||||||
|
lsp: LspStatus[]
|
||||||
|
vcs: VcsInfo | undefined
|
||||||
limit: number
|
limit: number
|
||||||
message: {
|
message: {
|
||||||
[sessionID: string]: Message[]
|
[sessionID: string]: Message[]
|
||||||
@@ -50,8 +60,6 @@ type State = {
|
|||||||
part: {
|
part: {
|
||||||
[messageID: string]: Part[]
|
[messageID: string]: Part[]
|
||||||
}
|
}
|
||||||
node: FileNode[]
|
|
||||||
changes: File[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createGlobalSync() {
|
function createGlobalSync() {
|
||||||
@@ -89,11 +97,13 @@ function createGlobalSync() {
|
|||||||
session_status: {},
|
session_status: {},
|
||||||
session_diff: {},
|
session_diff: {},
|
||||||
todo: {},
|
todo: {},
|
||||||
|
permission: {},
|
||||||
|
mcp: {},
|
||||||
|
lsp: [],
|
||||||
|
vcs: undefined,
|
||||||
limit: 5,
|
limit: 5,
|
||||||
message: {},
|
message: {},
|
||||||
part: {},
|
part: {},
|
||||||
node: [],
|
|
||||||
changes: [],
|
|
||||||
})
|
})
|
||||||
children[directory] = createStore(globalStore.children[directory])
|
children[directory] = createStore(globalStore.children[directory])
|
||||||
bootstrapInstance(directory)
|
bootstrapInstance(directory)
|
||||||
@@ -136,15 +146,37 @@ function createGlobalSync() {
|
|||||||
})
|
})
|
||||||
const load = {
|
const load = {
|
||||||
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
|
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
|
||||||
provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)),
|
provider: () =>
|
||||||
|
sdk.provider.list().then((x) => {
|
||||||
|
const data = x.data!
|
||||||
|
setStore("provider", {
|
||||||
|
...data,
|
||||||
|
all: data.all.map((provider) => ({
|
||||||
|
...provider,
|
||||||
|
models: Object.fromEntries(
|
||||||
|
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}),
|
||||||
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
|
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
|
||||||
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||||
command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])),
|
command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])),
|
||||||
session: () => loadSessions(directory),
|
session: () => loadSessions(directory),
|
||||||
status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
|
status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
|
||||||
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||||
changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
|
mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})),
|
||||||
node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
|
lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])),
|
||||||
|
vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)),
|
||||||
|
permission: () =>
|
||||||
|
sdk.permission.list().then((x) => {
|
||||||
|
const grouped: Record<string, typeof x.data> = {}
|
||||||
|
for (const perm of x.data ?? []) {
|
||||||
|
grouped[perm.sessionID] = grouped[perm.sessionID] ?? []
|
||||||
|
grouped[perm.sessionID]!.push(perm)
|
||||||
|
}
|
||||||
|
setStore("permission", grouped)
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
|
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
|
||||||
.then(() => setStore("ready", true))
|
.then(() => setStore("ready", true))
|
||||||
@@ -211,13 +243,13 @@ function createGlobalSync() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.diff":
|
case "session.diff":
|
||||||
setStore("session_diff", event.properties.sessionID, event.properties.diff)
|
setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" }))
|
||||||
break
|
break
|
||||||
case "todo.updated":
|
case "todo.updated":
|
||||||
setStore("todo", event.properties.sessionID, event.properties.todos)
|
setStore("todo", event.properties.sessionID, reconcile(event.properties.todos))
|
||||||
break
|
break
|
||||||
case "session.status": {
|
case "session.status": {
|
||||||
setStore("session_status", event.properties.sessionID, event.properties.status)
|
setStore("session_status", event.properties.sessionID, reconcile(event.properties.status))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "message.updated": {
|
case "message.updated": {
|
||||||
@@ -291,10 +323,69 @@ function createGlobalSync() {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case "vcs.branch.updated": {
|
||||||
|
setStore("vcs", { branch: event.properties.branch })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "permission.updated": {
|
||||||
|
const permissions = store.permission[event.properties.sessionID]
|
||||||
|
if (!permissions) {
|
||||||
|
setStore("permission", event.properties.sessionID, [event.properties])
|
||||||
|
} else {
|
||||||
|
const result = Binary.search(permissions, event.properties.id, (p) => p.id)
|
||||||
|
setStore(
|
||||||
|
"permission",
|
||||||
|
event.properties.sessionID,
|
||||||
|
produce((draft) => {
|
||||||
|
if (result.found) {
|
||||||
|
draft[result.index] = event.properties
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draft.push(event.properties)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "permission.replied": {
|
||||||
|
const permissions = store.permission[event.properties.sessionID]
|
||||||
|
if (!permissions) break
|
||||||
|
const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
|
||||||
|
if (!result.found) break
|
||||||
|
setStore(
|
||||||
|
"permission",
|
||||||
|
event.properties.sessionID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "lsp.updated": {
|
||||||
|
const sdk = createOpencodeClient({
|
||||||
|
baseUrl: globalSDK.url,
|
||||||
|
directory,
|
||||||
|
throwOnError: true,
|
||||||
|
})
|
||||||
|
sdk.lsp.status().then((x) => setStore("lsp", x.data ?? []))
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
|
const health = await globalSDK.client.global
|
||||||
|
.health()
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (!health?.healthy) {
|
||||||
|
setGlobalStore(
|
||||||
|
"error",
|
||||||
|
new Error(`Could not connect to server. Is there a server running at \`${globalSDK.url}\`?`),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
retry(() =>
|
retry(() =>
|
||||||
globalSDK.client.path.get().then((x) => {
|
globalSDK.client.path.get().then((x) => {
|
||||||
@@ -311,7 +402,16 @@ function createGlobalSync() {
|
|||||||
),
|
),
|
||||||
retry(() =>
|
retry(() =>
|
||||||
globalSDK.client.provider.list().then((x) => {
|
globalSDK.client.provider.list().then((x) => {
|
||||||
setGlobalStore("provider", x.data ?? {})
|
const data = x.data!
|
||||||
|
setGlobalStore("provider", {
|
||||||
|
...data,
|
||||||
|
all: data.all.map((provider) => ({
|
||||||
|
...provider,
|
||||||
|
models: Object.fromEntries(
|
||||||
|
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
retry(() =>
|
retry(() =>
|
||||||
@@ -46,6 +46,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
opened: false,
|
opened: false,
|
||||||
height: 280,
|
height: 280,
|
||||||
},
|
},
|
||||||
|
review: {
|
||||||
|
opened: true,
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
width: 600,
|
width: 600,
|
||||||
},
|
},
|
||||||
@@ -108,10 +111,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
|
setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
|
||||||
},
|
},
|
||||||
expand(directory: string) {
|
expand(directory: string) {
|
||||||
setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: true } : x)))
|
const index = store.projects.findIndex((x) => x.worktree === directory)
|
||||||
|
if (index !== -1) setStore("projects", index, "expanded", true)
|
||||||
},
|
},
|
||||||
collapse(directory: string) {
|
collapse(directory: string) {
|
||||||
setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: false } : x)))
|
const index = store.projects.findIndex((x) => x.worktree === directory)
|
||||||
|
if (index !== -1) setStore("projects", index, "expanded", false)
|
||||||
},
|
},
|
||||||
move(directory: string, toIndex: number) {
|
move(directory: string, toIndex: number) {
|
||||||
setStore("projects", (projects) => {
|
setStore("projects", (projects) => {
|
||||||
@@ -156,6 +161,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
setStore("terminal", "height", height)
|
setStore("terminal", "height", height)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
review: {
|
||||||
|
opened: createMemo(() => store.review?.opened ?? true),
|
||||||
|
open() {
|
||||||
|
setStore("review", "opened", true)
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
setStore("review", "opened", false)
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
setStore("review", "opened", (x) => !x)
|
||||||
|
},
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
width: createMemo(() => store.session?.width ?? 600),
|
width: createMemo(() => store.session?.width ?? 600),
|
||||||
resize(width: number) {
|
resize(width: number) {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { batch, createEffect, createMemo } from "solid-js"
|
import { batch, createMemo } from "solid-js"
|
||||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||||
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
|
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
@@ -9,6 +9,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
|||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
|
||||||
export type LocalFile = FileNode &
|
export type LocalFile = FileNode &
|
||||||
Partial<{
|
Partial<{
|
||||||
@@ -61,24 +62,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatically update model when agent changes
|
|
||||||
createEffect(() => {
|
|
||||||
const value = agent.current()
|
|
||||||
if (value.model) {
|
|
||||||
if (isModelValid(value.model))
|
|
||||||
model.set({
|
|
||||||
providerID: value.model.providerID,
|
|
||||||
modelID: value.model.modelID,
|
|
||||||
})
|
|
||||||
// else
|
|
||||||
// toast.show({
|
|
||||||
// type: "warning",
|
|
||||||
// message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
|
|
||||||
// duration: 3000,
|
|
||||||
// })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const agent = (() => {
|
const agent = (() => {
|
||||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
@@ -276,11 +259,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
node: Record<string, LocalFile>
|
node: Record<string, LocalFile>
|
||||||
}>({
|
}>({
|
||||||
node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
|
node: {}, // Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
|
||||||
})
|
})
|
||||||
|
|
||||||
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
// const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||||
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
// const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
||||||
|
|
||||||
// createEffect((prev: FileStatus[]) => {
|
// createEffect((prev: FileStatus[]) => {
|
||||||
// const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
|
// const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
|
||||||
@@ -308,16 +291,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
// return sync.data.changes
|
// return sync.data.changes
|
||||||
// }, sync.data.changes)
|
// }, sync.data.changes)
|
||||||
|
|
||||||
const changed = (path: string) => {
|
// const changed = (path: string) => {
|
||||||
const node = store.node[path]
|
// const node = store.node[path]
|
||||||
if (node?.status) return true
|
// if (node?.status) return true
|
||||||
const set = changeset()
|
// const set = changeset()
|
||||||
if (set.has(path)) return true
|
// if (set.has(path)) return true
|
||||||
for (const p of set) {
|
// for (const p of set) {
|
||||||
if (p.startsWith(path ? path + "/" : "")) return true
|
// if (p.startsWith(path ? path + "/" : "")) return true
|
||||||
}
|
// }
|
||||||
return false
|
// return false
|
||||||
}
|
// }
|
||||||
|
|
||||||
// const resetNode = (path: string) => {
|
// const resetNode = (path: string) => {
|
||||||
// setStore("node", path, {
|
// setStore("node", path, {
|
||||||
@@ -336,17 +319,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const load = async (path: string) => {
|
const load = async (path: string) => {
|
||||||
const relativePath = relative(path)
|
const relativePath = relative(path)
|
||||||
await sdk.client.file.read({ path: relativePath }).then((x) => {
|
await sdk.client.file
|
||||||
if (!store.node[relativePath]) return
|
.read({ path: relativePath })
|
||||||
setStore(
|
.then((x) => {
|
||||||
"node",
|
if (!store.node[relativePath]) return
|
||||||
relativePath,
|
setStore(
|
||||||
produce((draft) => {
|
"node",
|
||||||
draft.loaded = true
|
relativePath,
|
||||||
draft.content = x.data
|
produce((draft) => {
|
||||||
}),
|
draft.loaded = true
|
||||||
)
|
draft.content = x.data
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Failed to load file",
|
||||||
|
description: e.message,
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetch = async (path: string) => {
|
const fetch = async (path: string) => {
|
||||||
@@ -385,17 +377,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const list = async (path: string) => {
|
const list = async (path: string) => {
|
||||||
return sdk.client.file.list({ path: path + "/" }).then((x) => {
|
return sdk.client.file
|
||||||
setStore(
|
.list({ path: path + "/" })
|
||||||
"node",
|
.then((x) => {
|
||||||
produce((draft) => {
|
setStore(
|
||||||
x.data!.forEach((node) => {
|
"node",
|
||||||
if (node.path in draft) return
|
produce((draft) => {
|
||||||
draft[node.path] = node
|
x.data!.forEach((node) => {
|
||||||
})
|
if (node.path in draft) return
|
||||||
}),
|
draft[node.path] = node
|
||||||
)
|
})
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!)
|
const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!)
|
||||||
@@ -466,8 +461,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
setChangeIndex(path: string, index: number | undefined) {
|
setChangeIndex(path: string, index: number | undefined) {
|
||||||
setStore("node", path, "selectedChange", index)
|
setStore("node", path, "selectedChange", index)
|
||||||
},
|
},
|
||||||
changes,
|
// changes,
|
||||||
changed,
|
// changed,
|
||||||
children(path: string) {
|
children(path: string) {
|
||||||
return Object.values(store.node).filter(
|
return Object.values(store.node).filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
@@ -5,6 +5,9 @@ export type Platform = {
|
|||||||
/** Platform discriminator */
|
/** Platform discriminator */
|
||||||
platform: "web" | "tauri"
|
platform: "web" | "tauri"
|
||||||
|
|
||||||
|
/** App version */
|
||||||
|
version?: string
|
||||||
|
|
||||||
/** Open a URL in the default browser */
|
/** Open a URL in the default browser */
|
||||||
openLink(url: string): void
|
openLink(url: string): void
|
||||||
|
|
||||||
@@ -36,35 +36,49 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||||||
all: createMemo(() => Object.values(store.all)),
|
all: createMemo(() => Object.values(store.all)),
|
||||||
active: createMemo(() => store.active),
|
active: createMemo(() => store.active),
|
||||||
new() {
|
new() {
|
||||||
sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => {
|
sdk.client.pty
|
||||||
const id = pty.data?.id
|
.create({ title: `Terminal ${store.all.length + 1}` })
|
||||||
if (!id) return
|
.then((pty) => {
|
||||||
setStore("all", [
|
const id = pty.data?.id
|
||||||
...store.all,
|
if (!id) return
|
||||||
{
|
setStore("all", [
|
||||||
id,
|
...store.all,
|
||||||
title: pty.data?.title ?? "Terminal",
|
{
|
||||||
},
|
id,
|
||||||
])
|
title: pty.data?.title ?? "Terminal",
|
||||||
setStore("active", id)
|
},
|
||||||
})
|
])
|
||||||
|
setStore("active", id)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to create terminal", e)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||||
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
|
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
|
||||||
sdk.client.pty.update({
|
sdk.client.pty
|
||||||
ptyID: pty.id,
|
.update({
|
||||||
title: pty.title,
|
ptyID: pty.id,
|
||||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
title: pty.title,
|
||||||
})
|
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to update terminal", e)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
async clone(id: string) {
|
async clone(id: string) {
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
const pty = store.all[index]
|
const pty = store.all[index]
|
||||||
if (!pty) return
|
if (!pty) return
|
||||||
const clone = await sdk.client.pty.create({
|
const clone = await sdk.client.pty
|
||||||
title: pty.title,
|
.create({
|
||||||
})
|
title: pty.title,
|
||||||
if (!clone.data) return
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to clone terminal", e)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (!clone?.data) return
|
||||||
setStore("all", index, {
|
setStore("all", index, {
|
||||||
...pty,
|
...pty,
|
||||||
...clone.data,
|
...clone.data,
|
||||||
@@ -88,7 +102,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||||||
setStore("active", previous?.id)
|
setStore("active", previous?.id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await sdk.client.pty.remove({ ptyID: id })
|
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
|
||||||
|
console.error("Failed to close terminal", e)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
move(id: string, to: number) {
|
move(id: string, to: number) {
|
||||||
const index = store.all.findIndex((f) => f.id === id)
|
const index = store.all.findIndex((f) => f.id === id)
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { render } from "solid-js/web"
|
import { render } from "solid-js/web"
|
||||||
import { App } from "@/app"
|
import { App } from "@/app"
|
||||||
import { Platform, PlatformProvider } from "@/context/platform"
|
import { Platform, PlatformProvider } from "@/context/platform"
|
||||||
|
import pkg from "../package.json"
|
||||||
|
|
||||||
const root = document.getElementById("root")
|
const root = document.getElementById("root")
|
||||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||||
@@ -12,6 +13,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
|||||||
|
|
||||||
const platform: Platform = {
|
const platform: Platform = {
|
||||||
platform: "web",
|
platform: "web",
|
||||||
|
version: pkg.version,
|
||||||
openLink(url: string) {
|
openLink(url: string) {
|
||||||
window.open(url, "_blank")
|
window.open(url, "_blank")
|
||||||
},
|
},
|
||||||
+9
-2
@@ -1,6 +1,6 @@
|
|||||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { SDKProvider } from "@/context/sdk"
|
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||||
import { SyncProvider, useSync } from "@/context/sync"
|
import { SyncProvider, useSync } from "@/context/sync"
|
||||||
import { LocalProvider } from "@/context/local"
|
import { LocalProvider } from "@/context/local"
|
||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
@@ -18,8 +18,15 @@ export default function Layout(props: ParentProps) {
|
|||||||
<SyncProvider>
|
<SyncProvider>
|
||||||
{iife(() => {
|
{iife(() => {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const sdk = useSDK()
|
||||||
return (
|
return (
|
||||||
<DataProvider data={sync.data} directory={directory()}>
|
<DataProvider
|
||||||
|
data={sync.data}
|
||||||
|
directory={directory()}
|
||||||
|
onPermissionRespond={(input) => {
|
||||||
|
sdk.client.permission.respond(input)
|
||||||
|
}}
|
||||||
|
>
|
||||||
<LocalProvider>{props.children}</LocalProvider>
|
<LocalProvider>{props.children}</LocalProvider>
|
||||||
</DataProvider>
|
</DataProvider>
|
||||||
)
|
)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { Logo } from "@opencode-ai/ui/logo"
|
import { Logo } from "@opencode-ai/ui/logo"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Component } from "solid-js"
|
import { Component, Show } from "solid-js"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
|
||||||
@@ -62,27 +62,49 @@ function formatInitError(error: InitError): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatErrorChain(error: unknown, depth = 0): string {
|
function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): string {
|
||||||
if (!error) return "Unknown error"
|
if (!error) return "Unknown error"
|
||||||
|
|
||||||
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
|
|
||||||
|
|
||||||
if (isInitError(error)) {
|
if (isInitError(error)) {
|
||||||
return indent + formatInitError(error)
|
const message = formatInitError(error)
|
||||||
|
if (depth > 0 && parentMessage === message) return ""
|
||||||
|
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
|
||||||
|
return indent + message
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
const parts = [indent + `${error.name}: ${error.message}`]
|
const isDuplicate = depth > 0 && parentMessage === error.message
|
||||||
if (error.stack) {
|
const parts: string[] = []
|
||||||
parts.push(error.stack)
|
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
|
||||||
|
|
||||||
|
if (!isDuplicate) {
|
||||||
|
// Stack already includes error name and message, so prefer it
|
||||||
|
parts.push(indent + (error.stack ?? `${error.name}: ${error.message}`))
|
||||||
|
} else if (error.stack) {
|
||||||
|
// Duplicate message - only show the stack trace lines (skip message)
|
||||||
|
const trace = error.stack.split("\n").slice(1).join("\n").trim()
|
||||||
|
if (trace) {
|
||||||
|
parts.push(trace)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.cause) {
|
if (error.cause) {
|
||||||
parts.push(formatErrorChain(error.cause, depth + 1))
|
const causeResult = formatErrorChain(error.cause, depth + 1, error.message)
|
||||||
|
if (causeResult) {
|
||||||
|
parts.push(causeResult)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.join("\n\n")
|
return parts.join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof error === "string") return indent + error
|
if (typeof error === "string") {
|
||||||
|
if (depth > 0 && parentMessage === error) return ""
|
||||||
|
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
|
||||||
|
return indent + error
|
||||||
|
}
|
||||||
|
|
||||||
|
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
|
||||||
return indent + JSON.stringify(error, null, 2)
|
return indent + JSON.stringify(error, null, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,16 +138,21 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
|||||||
<Button size="large" onClick={platform.restart}>
|
<Button size="large" onClick={platform.restart}>
|
||||||
Restart
|
Restart
|
||||||
</Button>
|
</Button>
|
||||||
<div class="flex items-center justify-center gap-1">
|
<div class="flex flex-col items-center gap-2">
|
||||||
Please report this error to the OpenCode team
|
<div class="flex items-center justify-center gap-1">
|
||||||
<button
|
Please report this error to the OpenCode team
|
||||||
type="button"
|
<button
|
||||||
class="flex items-center text-text-interactive-base gap-1"
|
type="button"
|
||||||
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
class="flex items-center text-text-interactive-base gap-1"
|
||||||
>
|
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||||
<div>on Discord</div>
|
>
|
||||||
<Icon name="discord" class="text-text-interactive-base" />
|
<div>on Discord</div>
|
||||||
</button>
|
<Icon name="discord" class="text-text-interactive-base" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Show when={platform.version}>
|
||||||
|
<p class="text-xs text-text-weak">Version: {platform.version}</p>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,4 +1,17 @@
|
|||||||
import { createEffect, createMemo, For, Match, onMount, ParentProps, Show, Switch, type JSX } from "solid-js"
|
import {
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createSignal,
|
||||||
|
For,
|
||||||
|
Match,
|
||||||
|
onCleanup,
|
||||||
|
onMount,
|
||||||
|
ParentProps,
|
||||||
|
Show,
|
||||||
|
Switch,
|
||||||
|
untrack,
|
||||||
|
type JSX,
|
||||||
|
} from "solid-js"
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
|
import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
|
||||||
@@ -28,23 +41,44 @@ import {
|
|||||||
} from "@thisbeyond/solid-dnd"
|
} from "@thisbeyond/solid-dnd"
|
||||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { showToast, Toast } from "@opencode-ai/ui/toast"
|
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { Header } from "@/components/header"
|
import { Header } from "@/components/header"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||||
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand, type CommandOption } from "@/context/command"
|
||||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||||
|
|
||||||
export default function Layout(props: ParentProps) {
|
export default function Layout(props: ParentProps) {
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
lastSession: {} as { [directory: string]: string },
|
lastSession: {} as { [directory: string]: string },
|
||||||
activeDraggable: undefined as string | undefined,
|
activeDraggable: undefined as string | undefined,
|
||||||
|
mobileSidebarOpen: false,
|
||||||
|
mobileProjectsExpanded: {} as Record<string, boolean>,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mobileSidebar = {
|
||||||
|
open: () => store.mobileSidebarOpen,
|
||||||
|
show: () => setStore("mobileSidebarOpen", true),
|
||||||
|
hide: () => setStore("mobileSidebarOpen", false),
|
||||||
|
toggle: () => setStore("mobileSidebarOpen", (x) => !x),
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobileProjects = {
|
||||||
|
expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true,
|
||||||
|
expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true),
|
||||||
|
collapse: (directory: string) => setStore("mobileProjectsExpanded", directory, false),
|
||||||
|
}
|
||||||
|
|
||||||
let scrollContainerRef: HTMLDivElement | undefined
|
let scrollContainerRef: HTMLDivElement | undefined
|
||||||
|
const xlQuery = window.matchMedia("(min-width: 1280px)")
|
||||||
|
const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
|
||||||
|
const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches)
|
||||||
|
xlQuery.addEventListener("change", handleViewportChange)
|
||||||
|
onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
|
||||||
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
@@ -56,6 +90,41 @@ export default function Layout(props: ParentProps) {
|
|||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
|
const theme = useTheme()
|
||||||
|
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
|
||||||
|
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
|
||||||
|
const colorSchemeLabel: Record<ColorScheme, string> = {
|
||||||
|
system: "System",
|
||||||
|
light: "Light",
|
||||||
|
dark: "Dark",
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleTheme(direction = 1) {
|
||||||
|
const ids = availableThemeEntries().map(([id]) => id)
|
||||||
|
if (ids.length === 0) return
|
||||||
|
const currentIndex = ids.indexOf(theme.themeId())
|
||||||
|
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
|
||||||
|
const nextThemeId = ids[nextIndex]
|
||||||
|
theme.setTheme(nextThemeId)
|
||||||
|
const nextTheme = theme.themes()[nextThemeId]
|
||||||
|
showToast({
|
||||||
|
title: "Theme switched",
|
||||||
|
description: nextTheme?.name ?? nextThemeId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleColorScheme(direction = 1) {
|
||||||
|
const current = theme.colorScheme()
|
||||||
|
const currentIndex = colorSchemeOrder.indexOf(current)
|
||||||
|
const nextIndex =
|
||||||
|
currentIndex === -1 ? 0 : (currentIndex + direction + colorSchemeOrder.length) % colorSchemeOrder.length
|
||||||
|
const next = colorSchemeOrder[nextIndex]
|
||||||
|
theme.setColorScheme(next)
|
||||||
|
showToast({
|
||||||
|
title: "Color scheme",
|
||||||
|
description: colorSchemeLabel[next],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (platform.checkUpdate && platform.update && platform.restart) {
|
if (platform.checkUpdate && platform.update && platform.restart) {
|
||||||
@@ -84,42 +153,96 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function flattenSessions(sessions: Session[]): Session[] {
|
onMount(() => {
|
||||||
const childrenMap = new Map<string, Session[]>()
|
const seenSessions = new Set<string>()
|
||||||
for (const session of sessions) {
|
const toastBySession = new Map<string, number>()
|
||||||
if (session.parentID) {
|
const unsub = globalSDK.event.listen((e) => {
|
||||||
const children = childrenMap.get(session.parentID) ?? []
|
if (e.details?.type !== "permission.updated") return
|
||||||
children.push(session)
|
const directory = e.name
|
||||||
childrenMap.set(session.parentID, children)
|
const permission = e.details.properties
|
||||||
|
const sessionKey = `${directory}:${permission.sessionID}`
|
||||||
|
if (seenSessions.has(sessionKey)) return
|
||||||
|
seenSessions.add(sessionKey)
|
||||||
|
const currentDir = params.dir ? base64Decode(params.dir) : undefined
|
||||||
|
const currentSession = params.id
|
||||||
|
if (directory === currentDir && permission.sessionID === currentSession) return
|
||||||
|
const [store] = globalSync.child(directory)
|
||||||
|
const session = store.session.find((s) => s.id === permission.sessionID)
|
||||||
|
if (directory === currentDir && session?.parentID === currentSession) return
|
||||||
|
const sessionTitle = session?.title ?? "New session"
|
||||||
|
const projectName = getFilename(directory)
|
||||||
|
const toastId = showToast({
|
||||||
|
persistent: true,
|
||||||
|
icon: "checklist",
|
||||||
|
title: "Permission required",
|
||||||
|
description: `${sessionTitle} in ${projectName} needs permission`,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: "Go to session",
|
||||||
|
onClick: () => {
|
||||||
|
navigate(`/${base64Encode(directory)}/session/${permission.sessionID}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dismiss",
|
||||||
|
onClick: "dismiss",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
toastBySession.set(sessionKey, toastId)
|
||||||
|
})
|
||||||
|
onCleanup(unsub)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const currentDir = params.dir ? base64Decode(params.dir) : undefined
|
||||||
|
const currentSession = params.id
|
||||||
|
if (!currentDir || !currentSession) return
|
||||||
|
const sessionKey = `${currentDir}:${currentSession}`
|
||||||
|
const toastId = toastBySession.get(sessionKey)
|
||||||
|
if (toastId !== undefined) {
|
||||||
|
toaster.dismiss(toastId)
|
||||||
|
toastBySession.delete(sessionKey)
|
||||||
|
seenSessions.delete(sessionKey)
|
||||||
}
|
}
|
||||||
}
|
const [store] = globalSync.child(currentDir)
|
||||||
const result: Session[] = []
|
const childSessions = store.session.filter((s) => s.parentID === currentSession)
|
||||||
function visit(session: Session) {
|
for (const child of childSessions) {
|
||||||
result.push(session)
|
const childKey = `${currentDir}:${child.id}`
|
||||||
for (const child of childrenMap.get(session.id) ?? []) {
|
const childToastId = toastBySession.get(childKey)
|
||||||
visit(child)
|
if (childToastId !== undefined) {
|
||||||
|
toaster.dismiss(childToastId)
|
||||||
|
toastBySession.delete(childKey)
|
||||||
|
seenSessions.delete(childKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
for (const session of sessions) {
|
})
|
||||||
if (!session.parentID) visit(session)
|
|
||||||
}
|
function sortSessions(a: Session, b: Session) {
|
||||||
return result
|
const now = Date.now()
|
||||||
|
const oneMinuteAgo = now - 60 * 1000
|
||||||
|
const aUpdated = a.time.updated ?? a.time.created
|
||||||
|
const bUpdated = b.time.updated ?? b.time.created
|
||||||
|
const aRecent = aUpdated > oneMinuteAgo
|
||||||
|
const bRecent = bUpdated > oneMinuteAgo
|
||||||
|
if (aRecent && bRecent) return a.id.localeCompare(b.id)
|
||||||
|
if (aRecent && !bRecent) return -1
|
||||||
|
if (!aRecent && bRecent) return 1
|
||||||
|
return bUpdated - aUpdated
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToSession(sessionId: string) {
|
function scrollToSession(sessionId: string) {
|
||||||
if (!scrollContainerRef) return
|
if (!scrollContainerRef) return
|
||||||
const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
|
const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
|
||||||
if (element) {
|
if (element) {
|
||||||
element.scrollIntoView({ block: "center", behavior: "smooth" })
|
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectSessions(directory: string) {
|
function projectSessions(directory: string) {
|
||||||
if (!directory) return []
|
if (!directory) return []
|
||||||
const sessions = globalSync
|
const sessions = globalSync.child(directory)[0].session.toSorted(sortSessions)
|
||||||
.child(directory)[0]
|
return (sessions ?? []).filter((s) => !s.parentID)
|
||||||
.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
|
||||||
return flattenSessions(sessions ?? [])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSessions = createMemo(() => {
|
const currentSessions = createMemo(() => {
|
||||||
@@ -199,57 +322,102 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
command.register(() => [
|
command.register(() => {
|
||||||
{
|
const commands: CommandOption[] = [
|
||||||
id: "sidebar.toggle",
|
{
|
||||||
title: "Toggle sidebar",
|
id: "sidebar.toggle",
|
||||||
category: "View",
|
title: "Toggle sidebar",
|
||||||
keybind: "mod+b",
|
category: "View",
|
||||||
onSelect: () => layout.sidebar.toggle(),
|
keybind: "mod+b",
|
||||||
},
|
onSelect: () => layout.sidebar.toggle(),
|
||||||
...(platform.openDirectoryPickerDialog
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: "project.open",
|
|
||||||
title: "Open project",
|
|
||||||
category: "Project",
|
|
||||||
keybind: "mod+o",
|
|
||||||
onSelect: () => chooseProject(),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
|
||||||
id: "provider.connect",
|
|
||||||
title: "Connect provider",
|
|
||||||
category: "Provider",
|
|
||||||
onSelect: () => connectProvider(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "session.previous",
|
|
||||||
title: "Previous session",
|
|
||||||
category: "Session",
|
|
||||||
keybind: "alt+arrowup",
|
|
||||||
onSelect: () => navigateSessionByOffset(-1),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "session.next",
|
|
||||||
title: "Next session",
|
|
||||||
category: "Session",
|
|
||||||
keybind: "alt+arrowdown",
|
|
||||||
onSelect: () => navigateSessionByOffset(1),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "session.archive",
|
|
||||||
title: "Archive session",
|
|
||||||
category: "Session",
|
|
||||||
keybind: "mod+shift+backspace",
|
|
||||||
disabled: !params.dir || !params.id,
|
|
||||||
onSelect: () => {
|
|
||||||
const session = currentSessions().find((s) => s.id === params.id)
|
|
||||||
if (session) archiveSession(session)
|
|
||||||
},
|
},
|
||||||
},
|
...(platform.openDirectoryPickerDialog
|
||||||
])
|
? [
|
||||||
|
{
|
||||||
|
id: "project.open",
|
||||||
|
title: "Open project",
|
||||||
|
category: "Project",
|
||||||
|
keybind: "mod+o",
|
||||||
|
onSelect: () => chooseProject(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
id: "provider.connect",
|
||||||
|
title: "Connect provider",
|
||||||
|
category: "Provider",
|
||||||
|
onSelect: () => connectProvider(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "session.previous",
|
||||||
|
title: "Previous session",
|
||||||
|
category: "Session",
|
||||||
|
keybind: "alt+arrowup",
|
||||||
|
onSelect: () => navigateSessionByOffset(-1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "session.next",
|
||||||
|
title: "Next session",
|
||||||
|
category: "Session",
|
||||||
|
keybind: "alt+arrowdown",
|
||||||
|
onSelect: () => navigateSessionByOffset(1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "session.archive",
|
||||||
|
title: "Archive session",
|
||||||
|
category: "Session",
|
||||||
|
keybind: "mod+shift+backspace",
|
||||||
|
disabled: !params.dir || !params.id,
|
||||||
|
onSelect: () => {
|
||||||
|
const session = currentSessions().find((s) => s.id === params.id)
|
||||||
|
if (session) archiveSession(session)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "theme.cycle",
|
||||||
|
title: "Cycle theme",
|
||||||
|
category: "Theme",
|
||||||
|
keybind: "mod+shift+t",
|
||||||
|
onSelect: () => cycleTheme(1),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [id, definition] of availableThemeEntries()) {
|
||||||
|
commands.push({
|
||||||
|
id: `theme.set.${id}`,
|
||||||
|
title: `Use theme: ${definition.name ?? id}`,
|
||||||
|
category: "Theme",
|
||||||
|
onSelect: () => theme.commitPreview(),
|
||||||
|
onHighlight: () => {
|
||||||
|
theme.previewTheme(id)
|
||||||
|
return () => theme.cancelPreview()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
commands.push({
|
||||||
|
id: "theme.scheme.cycle",
|
||||||
|
title: "Cycle color scheme",
|
||||||
|
category: "Theme",
|
||||||
|
keybind: "mod+shift+s",
|
||||||
|
onSelect: () => cycleColorScheme(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const scheme of colorSchemeOrder) {
|
||||||
|
commands.push({
|
||||||
|
id: `theme.scheme.${scheme}`,
|
||||||
|
title: `Use color scheme: ${colorSchemeLabel[scheme]}`,
|
||||||
|
category: "Theme",
|
||||||
|
onSelect: () => theme.commitPreview(),
|
||||||
|
onHighlight: () => {
|
||||||
|
theme.previewColorScheme(scheme)
|
||||||
|
return () => theme.cancelPreview()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands
|
||||||
|
})
|
||||||
|
|
||||||
function connectProvider() {
|
function connectProvider() {
|
||||||
dialog.show(() => <DialogSelectProvider />)
|
dialog.show(() => <DialogSelectProvider />)
|
||||||
@@ -259,11 +427,13 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (!directory) return
|
if (!directory) return
|
||||||
const lastSession = store.lastSession[directory]
|
const lastSession = store.lastSession[directory]
|
||||||
navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
|
navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
|
||||||
|
mobileSidebar.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateToSession(session: Session | undefined) {
|
function navigateToSession(session: Session | undefined) {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
navigate(`/${params.dir}/session/${session?.id}`)
|
navigate(`/${params.dir}/session/${session?.id}`)
|
||||||
|
mobileSidebar.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openProject(directory: string, navigate = true) {
|
function openProject(directory: string, navigate = true) {
|
||||||
@@ -297,13 +467,20 @@ export default function Layout(props: ParentProps) {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!params.dir || !params.id) return
|
if (!params.dir || !params.id) return
|
||||||
const directory = base64Decode(params.dir)
|
const directory = base64Decode(params.dir)
|
||||||
setStore("lastSession", directory, params.id)
|
const id = params.id
|
||||||
notification.session.markViewed(params.id)
|
setStore("lastSession", directory, id)
|
||||||
|
notification.session.markViewed(id)
|
||||||
|
untrack(() => layout.projects.expand(directory))
|
||||||
|
requestAnimationFrame(() => scrollToSession(id))
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
|
if (isLargeViewport()) {
|
||||||
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
|
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
|
||||||
|
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
|
||||||
|
} else {
|
||||||
|
document.documentElement.style.setProperty("--dialog-left-margin", "0px")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function getDraggableId(event: unknown): string | undefined {
|
function getDraggableId(event: unknown): string | undefined {
|
||||||
@@ -417,17 +594,26 @@ export default function Layout(props: ParentProps) {
|
|||||||
session: Session
|
session: Session
|
||||||
slug: string
|
slug: string
|
||||||
project: LocalProject
|
project: LocalProject
|
||||||
depth?: number
|
mobile?: boolean
|
||||||
childrenMap: Map<string, Session[]>
|
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const depth = props.depth ?? 0
|
|
||||||
const children = createMemo(() => props.childrenMap.get(props.session.id) ?? [])
|
|
||||||
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
|
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
|
||||||
const notifications = createMemo(() => notification.session.unseen(props.session.id))
|
const notifications = createMemo(() => notification.session.unseen(props.session.id))
|
||||||
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
||||||
|
const hasPermissions = createMemo(() => {
|
||||||
|
const store = globalSync.child(props.project.worktree)[0]
|
||||||
|
const permissions = store.permission?.[props.session.id] ?? []
|
||||||
|
if (permissions.length > 0) return true
|
||||||
|
const childSessions = store.session.filter((s) => s.parentID === props.session.id)
|
||||||
|
for (const child of childSessions) {
|
||||||
|
const childPermissions = store.permission?.[child.id] ?? []
|
||||||
|
if (childPermissions.length > 0) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
const isWorking = createMemo(() => {
|
const isWorking = createMemo(() => {
|
||||||
if (props.session.id === params.id) return false
|
if (props.session.id === params.id) return false
|
||||||
|
if (hasPermissions()) return false
|
||||||
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
|
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
|
||||||
return status?.type === "busy" || status?.type === "retry"
|
return status?.type === "busy" || status?.type === "retry"
|
||||||
})
|
})
|
||||||
@@ -437,15 +623,20 @@ export default function Layout(props: ParentProps) {
|
|||||||
data-session-id={props.session.id}
|
data-session-id={props.session.id}
|
||||||
class="group/session relative w-full pr-2 py-1 rounded-md cursor-default transition-colors
|
class="group/session relative w-full pr-2 py-1 rounded-md cursor-default transition-colors
|
||||||
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
|
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
|
||||||
style={{ "padding-left": `${16 + depth * 12}px` }}
|
style={{ "padding-left": "16px" }}
|
||||||
>
|
>
|
||||||
<Tooltip placement="right" value={props.session.title} gutter={10}>
|
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
|
||||||
<A
|
<A
|
||||||
href={`${props.slug}/session/${props.session.id}`}
|
href={`${props.slug}/session/${props.session.id}`}
|
||||||
class="flex flex-col min-w-0 text-left w-full focus:outline-none"
|
class="flex flex-col min-w-0 text-left w-full focus:outline-none"
|
||||||
>
|
>
|
||||||
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
|
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
|
||||||
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
|
<span
|
||||||
|
classList={{
|
||||||
|
"text-14-regular text-text-strong overflow-hidden text-ellipsis truncate": true,
|
||||||
|
"animate-pulse": isWorking(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
{props.session.title}
|
{props.session.title}
|
||||||
</span>
|
</span>
|
||||||
<div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
|
<div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
|
||||||
@@ -453,6 +644,9 @@ export default function Layout(props: ParentProps) {
|
|||||||
<Match when={isWorking()}>
|
<Match when={isWorking()}>
|
||||||
<Spinner class="size-2.5 mr-0.5" />
|
<Spinner class="size-2.5 mr-0.5" />
|
||||||
</Match>
|
</Match>
|
||||||
|
<Match when={hasPermissions()}>
|
||||||
|
<div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" />
|
||||||
|
</Match>
|
||||||
<Match when={hasError()}>
|
<Match when={hasError()}>
|
||||||
<div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
|
<div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
|
||||||
</Match>
|
</Match>
|
||||||
@@ -486,66 +680,54 @@ export default function Layout(props: ParentProps) {
|
|||||||
</A>
|
</A>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
|
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
|
||||||
<Tooltip placement="right" value="Archive session">
|
<Tooltip
|
||||||
|
placement={props.mobile ? "bottom" : "right"}
|
||||||
|
value={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>Archive session</span>
|
||||||
|
<span class="text-icon-base text-12-medium">{command.keybind("session.archive")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
|
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<For each={children()}>
|
|
||||||
{(child) => (
|
|
||||||
<SessionItem
|
|
||||||
session={child}
|
|
||||||
slug={props.slug}
|
|
||||||
project={props.project}
|
|
||||||
depth={depth + 1}
|
|
||||||
childrenMap={props.childrenMap}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const SortableProject = (props: { project: LocalProject }): JSX.Element => {
|
const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
|
||||||
const sortable = createSortable(props.project.worktree)
|
const sortable = createSortable(props.project.worktree)
|
||||||
|
const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
|
||||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
const slug = createMemo(() => base64Encode(props.project.worktree))
|
||||||
const name = createMemo(() => getFilename(props.project.worktree))
|
const name = createMemo(() => getFilename(props.project.worktree))
|
||||||
const [store, setProjectStore] = globalSync.child(props.project.worktree)
|
const [store, setProjectStore] = globalSync.child(props.project.worktree)
|
||||||
const sessions = createMemo(() =>
|
const sessions = createMemo(() => store.session.toSorted(sortSessions))
|
||||||
store.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)),
|
|
||||||
)
|
|
||||||
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
|
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
|
||||||
const childSessionsByParent = createMemo(() => {
|
|
||||||
const map = new Map<string, Session[]>()
|
|
||||||
for (const session of sessions()) {
|
|
||||||
if (session.parentID) {
|
|
||||||
const children = map.get(session.parentID) ?? []
|
|
||||||
children.push(session)
|
|
||||||
map.set(session.parentID, children)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
|
const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
|
||||||
const loadMoreSessions = async () => {
|
const loadMoreSessions = async () => {
|
||||||
setProjectStore("limit", (limit) => limit + 5)
|
setProjectStore("limit", (limit) => limit + 5)
|
||||||
await globalSync.project.loadSessions(props.project.worktree)
|
await globalSync.project.loadSessions(props.project.worktree)
|
||||||
}
|
}
|
||||||
|
const isExpanded = createMemo(() =>
|
||||||
|
props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
|
||||||
|
)
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
if (open) layout.projects.expand(props.project.worktree)
|
if (props.mobile) {
|
||||||
else layout.projects.collapse(props.project.worktree)
|
if (open) mobileProjects.expand(props.project.worktree)
|
||||||
|
else mobileProjects.collapse(props.project.worktree)
|
||||||
|
} else {
|
||||||
|
if (open) layout.projects.expand(props.project.worktree)
|
||||||
|
else layout.projects.collapse(props.project.worktree)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={layout.sidebar.opened()}>
|
<Match when={showExpanded()}>
|
||||||
<Collapsible
|
<Collapsible variant="ghost" open={isExpanded()} class="gap-2 shrink-0" onOpenChange={handleOpenChange}>
|
||||||
variant="ghost"
|
|
||||||
open={props.project.expanded}
|
|
||||||
class="gap-2 shrink-0"
|
|
||||||
onOpenChange={handleOpenChange}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
as={"div"}
|
as={"div"}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -556,7 +738,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
project={props.project}
|
project={props.project}
|
||||||
class="group-hover/session:hidden"
|
class="group-hover/session:hidden"
|
||||||
expandable
|
expandable
|
||||||
notify={!props.project.expanded}
|
notify={!isExpanded()}
|
||||||
/>
|
/>
|
||||||
<span class="truncate text-14-medium text-text-strong">{name()}</span>
|
<span class="truncate text-14-medium text-text-strong">{name()}</span>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
@@ -566,12 +748,20 @@ export default function Layout(props: ParentProps) {
|
|||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content>
|
<DropdownMenu.Content>
|
||||||
<DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
|
<DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
|
||||||
<DropdownMenu.ItemLabel>Close Project</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.Portal>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Tooltip placement="top" value="New session">
|
<Tooltip
|
||||||
|
placement="top"
|
||||||
|
value={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>New session</span>
|
||||||
|
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
|
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -580,12 +770,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
<nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
|
<nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
|
||||||
<For each={rootSessions()}>
|
<For each={rootSessions()}>
|
||||||
{(session) => (
|
{(session) => (
|
||||||
<SessionItem
|
<SessionItem session={session} slug={slug()} project={props.project} mobile={props.mobile} />
|
||||||
session={session}
|
|
||||||
slug={slug()}
|
|
||||||
project={props.project}
|
|
||||||
childrenMap={childSessionsByParent()}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<Show when={rootSessions().length === 0}>
|
<Show when={rootSessions().length === 0}>
|
||||||
@@ -595,7 +780,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
>
|
>
|
||||||
<div class="flex items-center self-stretch w-full">
|
<div class="flex items-center self-stretch w-full">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<Tooltip placement="right" value="New session">
|
<Tooltip placement={props.mobile ? "bottom" : "right"} value="New session">
|
||||||
<A
|
<A
|
||||||
href={`${slug()}/session`}
|
href={`${slug()}/session`}
|
||||||
class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
|
class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
|
||||||
@@ -650,31 +835,23 @@ export default function Layout(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
|
||||||
<div class="relative flex-1 min-h-0 flex flex-col">
|
const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
|
||||||
<Header navigateToProject={navigateToProject} navigateToSession={navigateToSession} />
|
return (
|
||||||
<div class="flex-1 min-h-0 flex">
|
<>
|
||||||
<div
|
<div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
|
||||||
classList={{
|
<Show when={!sidebarProps.mobile}>
|
||||||
"relative @container w-12 pb-5 shrink-0 bg-background-base": true,
|
<Tooltip
|
||||||
"flex flex-col gap-5.5 items-start self-stretch justify-between": true,
|
class="shrink-0"
|
||||||
"border-r border-border-weak-base contain-strict": true,
|
placement="right"
|
||||||
}}
|
value={
|
||||||
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
|
<div class="flex items-center gap-2">
|
||||||
>
|
<span>Toggle sidebar</span>
|
||||||
<Show when={layout.sidebar.opened()}>
|
<span class="text-icon-base text-12-medium">{command.keybind("sidebar.toggle")}</span>
|
||||||
<ResizeHandle
|
</div>
|
||||||
direction="horizontal"
|
}
|
||||||
size={layout.sidebar.width()}
|
inactive={expanded()}
|
||||||
min={150}
|
>
|
||||||
max={window.innerWidth * 0.3}
|
|
||||||
collapseThreshold={80}
|
|
||||||
onResize={layout.sidebar.resize}
|
|
||||||
onCollapse={layout.sidebar.close}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
|
|
||||||
<Tooltip class="shrink-0" placement="right" value="Toggle sidebar" inactive={layout.sidebar.opened()}>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="large"
|
size="large"
|
||||||
@@ -705,101 +882,162 @@ export default function Layout(props: ParentProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DragDropProvider
|
</Show>
|
||||||
onDragStart={handleDragStart}
|
<DragDropProvider
|
||||||
onDragEnd={handleDragEnd}
|
onDragStart={handleDragStart}
|
||||||
onDragOver={handleDragOver}
|
onDragEnd={handleDragEnd}
|
||||||
collisionDetector={closestCenter}
|
onDragOver={handleDragOver}
|
||||||
|
collisionDetector={closestCenter}
|
||||||
|
>
|
||||||
|
<DragDropSensors />
|
||||||
|
<ConstrainDragXAxis />
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
if (!sidebarProps.mobile) scrollContainerRef = el
|
||||||
|
}}
|
||||||
|
class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
|
||||||
>
|
>
|
||||||
<DragDropSensors />
|
<SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
|
||||||
<ConstrainDragXAxis />
|
<For each={layout.projects.list()}>
|
||||||
<div
|
{(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
|
||||||
ref={scrollContainerRef}
|
</For>
|
||||||
class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
|
</SortableProvider>
|
||||||
>
|
</div>
|
||||||
<SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
|
<DragOverlay>
|
||||||
<For each={layout.projects.list()}>{(project) => <SortableProject project={project} />}</For>
|
<ProjectDragOverlay />
|
||||||
</SortableProvider>
|
</DragOverlay>
|
||||||
</div>
|
</DragDropProvider>
|
||||||
<DragOverlay>
|
</div>
|
||||||
<ProjectDragOverlay />
|
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
|
||||||
</DragOverlay>
|
<Switch>
|
||||||
</DragDropProvider>
|
<Match when={!providers.paid().length && expanded()}>
|
||||||
</div>
|
<div class="rounded-md bg-background-stronger shadow-xs-border-base">
|
||||||
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
|
<div class="p-3 flex flex-col gap-2">
|
||||||
<Switch>
|
<div class="text-12-medium text-text-strong">Getting started</div>
|
||||||
<Match when={!providers.paid().length && layout.sidebar.opened()}>
|
<div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
|
||||||
<div class="rounded-md bg-background-stronger shadow-xs-border-base">
|
<div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
|
||||||
<div class="p-3 flex flex-col gap-2">
|
|
||||||
<div class="text-12-medium text-text-strong">Getting started</div>
|
|
||||||
<div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
|
|
||||||
<div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
|
|
||||||
</div>
|
|
||||||
<Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
|
|
||||||
<Button
|
|
||||||
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
|
|
||||||
size="large"
|
|
||||||
icon="plus"
|
|
||||||
onClick={connectProvider}
|
|
||||||
>
|
|
||||||
<Show when={layout.sidebar.opened()}>Connect provider</Show>
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
<Tooltip placement="right" value="Connect provider" inactive={expanded()}>
|
||||||
<Match when={true}>
|
|
||||||
<Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
|
|
||||||
<Button
|
<Button
|
||||||
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
|
||||||
variant="ghost"
|
|
||||||
size="large"
|
size="large"
|
||||||
icon="plus"
|
icon="plus"
|
||||||
onClick={connectProvider}
|
onClick={connectProvider}
|
||||||
>
|
>
|
||||||
<Show when={layout.sidebar.opened()}>Connect provider</Show>
|
Connect provider
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Match>
|
</div>
|
||||||
</Switch>
|
</Match>
|
||||||
<Show when={platform.openDirectoryPickerDialog}>
|
<Match when={true}>
|
||||||
<Tooltip placement="right" value="Open project" inactive={layout.sidebar.opened()}>
|
<Tooltip placement="right" value="Connect provider" inactive={expanded()}>
|
||||||
<Button
|
<Button
|
||||||
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="large"
|
size="large"
|
||||||
icon="folder-add-left"
|
icon="plus"
|
||||||
onClick={chooseProject}
|
onClick={connectProvider}
|
||||||
>
|
>
|
||||||
<Show when={layout.sidebar.opened()}>Open project</Show>
|
<Show when={expanded()}>Connect provider</Show>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Show>
|
</Match>
|
||||||
{/* <Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}> */}
|
</Switch>
|
||||||
{/* <Button */}
|
<Show when={platform.openDirectoryPickerDialog}>
|
||||||
{/* disabled */}
|
<Tooltip
|
||||||
{/* class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2" */}
|
placement="right"
|
||||||
{/* variant="ghost" */}
|
value={
|
||||||
{/* size="large" */}
|
<div class="flex items-center gap-2">
|
||||||
{/* icon="settings-gear" */}
|
<span>Open project</span>
|
||||||
{/* > */}
|
<Show when={!sidebarProps.mobile}>
|
||||||
{/* <Show when={layout.sidebar.opened()}>Settings</Show> */}
|
<span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
|
||||||
{/* </Button> */}
|
</Show>
|
||||||
{/* </Tooltip> */}
|
</div>
|
||||||
<Tooltip placement="right" value="Share feedback" inactive={layout.sidebar.opened()}>
|
}
|
||||||
|
inactive={expanded()}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
as={"a"}
|
|
||||||
href="https://opencode.ai/desktop-feedback"
|
|
||||||
target="_blank"
|
|
||||||
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="large"
|
size="large"
|
||||||
icon="bubble-5"
|
icon="folder-add-left"
|
||||||
|
onClick={chooseProject}
|
||||||
>
|
>
|
||||||
<Show when={layout.sidebar.opened()}>Share feedback</Show>
|
<Show when={expanded()}>Open project</Show>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</Show>
|
||||||
|
<Tooltip placement="right" value="Share feedback" inactive={expanded()}>
|
||||||
|
<Button
|
||||||
|
as={"a"}
|
||||||
|
href="https://opencode.ai/desktop-feedback"
|
||||||
|
target="_blank"
|
||||||
|
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
||||||
|
variant="ghost"
|
||||||
|
size="large"
|
||||||
|
icon="bubble-5"
|
||||||
|
>
|
||||||
|
<Show when={expanded()}>Share feedback</Show>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="relative flex-1 min-h-0 flex flex-col">
|
||||||
|
<Header
|
||||||
|
navigateToProject={navigateToProject}
|
||||||
|
navigateToSession={navigateToSession}
|
||||||
|
onMobileMenuToggle={mobileSidebar.toggle}
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-h-0 flex">
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"hidden xl:flex": true,
|
||||||
|
"relative @container w-12 pb-5 shrink-0 bg-background-base": true,
|
||||||
|
"flex-col gap-5.5 items-start self-stretch justify-between": true,
|
||||||
|
"border-r border-border-weak-base contain-strict": true,
|
||||||
|
}}
|
||||||
|
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
|
||||||
|
>
|
||||||
|
<Show when={layout.sidebar.opened()}>
|
||||||
|
<ResizeHandle
|
||||||
|
direction="horizontal"
|
||||||
|
size={layout.sidebar.width()}
|
||||||
|
min={150}
|
||||||
|
max={window.innerWidth * 0.3}
|
||||||
|
collapseThreshold={80}
|
||||||
|
onResize={layout.sidebar.resize}
|
||||||
|
onCollapse={layout.sidebar.close}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<SidebarContent />
|
||||||
|
</div>
|
||||||
|
<div class="xl:hidden">
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
|
||||||
|
"opacity-100 pointer-events-auto": mobileSidebar.open(),
|
||||||
|
"opacity-0 pointer-events-none": !mobileSidebar.open(),
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) mobileSidebar.hide()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pt-12 pb-5 transition-transform duration-200 ease-out": true,
|
||||||
|
"translate-x-0": mobileSidebar.open(),
|
||||||
|
"-translate-x-full": !mobileSidebar.open(),
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<SidebarContent mobile />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="size-full overflow-x-hidden flex flex-col items-start contain-strict">{props.children}</main>
|
<main class="size-full overflow-x-hidden flex flex-col items-start contain-strict">{props.children}</main>
|
||||||
</div>
|
</div>
|
||||||
<Toast.Region />
|
<Toast.Region />
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
createRenderEffect,
|
createRenderEffect,
|
||||||
batch,
|
batch,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
|
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import { useLocal, type LocalFile } from "@/context/local"
|
import { useLocal, type LocalFile } from "@/context/local"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
@@ -26,6 +27,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
|||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { useCodeComponent } from "@opencode-ai/ui/context/code"
|
import { useCodeComponent } from "@opencode-ai/ui/context/code"
|
||||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||||
|
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||||
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
|
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
|
||||||
import { SessionReview } from "@opencode-ai/ui/session-review"
|
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +49,7 @@ import { checksum } from "@opencode-ai/util/encode"
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
import { DialogSelectModel } from "@/components/dialog-select-model"
|
||||||
|
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
@@ -54,6 +57,9 @@ import { useSDK } from "@/context/sdk"
|
|||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||||
|
import { StatusBar } from "@/components/status-bar"
|
||||||
|
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
|
||||||
|
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
@@ -70,7 +76,6 @@ export default function Page() {
|
|||||||
|
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
||||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||||
@@ -79,7 +84,6 @@ export default function Page() {
|
|||||||
.filter((m) => m.role === "user")
|
.filter((m) => m.role === "user")
|
||||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
)
|
)
|
||||||
// Visible user messages excludes reverted messages (those >= revertMessageID)
|
|
||||||
const visibleUserMessages = createMemo(() => {
|
const visibleUserMessages = createMemo(() => {
|
||||||
const revert = revertMessageID()
|
const revert = revertMessageID()
|
||||||
if (!revert) return userMessages()
|
if (!revert) return userMessages()
|
||||||
@@ -87,15 +91,24 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
|
const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
|
||||||
|
|
||||||
const [messageStore, setMessageStore] = createStore<{ messageId?: string }>({})
|
const [store, setStore] = createStore({
|
||||||
|
clickTimer: undefined as number | undefined,
|
||||||
|
activeDraggable: undefined as string | undefined,
|
||||||
|
activeTerminalDraggable: undefined as string | undefined,
|
||||||
|
userInteracted: false,
|
||||||
|
stepsExpanded: true,
|
||||||
|
mobileStepsExpanded: {} as Record<string, boolean>,
|
||||||
|
messageId: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
const activeMessage = createMemo(() => {
|
const activeMessage = createMemo(() => {
|
||||||
if (!messageStore.messageId) return lastUserMessage()
|
if (!store.messageId) return lastUserMessage()
|
||||||
// If the stored message is no longer visible (e.g., was reverted), fall back to last visible
|
// If the stored message is no longer visible (e.g., was reverted), fall back to last visible
|
||||||
const found = visibleUserMessages()?.find((m) => m.id === messageStore.messageId)
|
const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
|
||||||
return found ?? lastUserMessage()
|
return found ?? lastUserMessage()
|
||||||
})
|
})
|
||||||
const setActiveMessage = (message: UserMessage | undefined) => {
|
const setActiveMessage = (message: UserMessage | undefined) => {
|
||||||
setMessageStore("messageId", message?.id)
|
setStore("messageId", message?.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateMessageByOffset(offset: number) {
|
function navigateMessageByOffset(offset: number) {
|
||||||
@@ -119,13 +132,6 @@ export default function Page() {
|
|||||||
|
|
||||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
clickTimer: undefined as number | undefined,
|
|
||||||
activeDraggable: undefined as string | undefined,
|
|
||||||
activeTerminalDraggable: undefined as string | undefined,
|
|
||||||
userInteracted: false,
|
|
||||||
stepsExpanded: true,
|
|
||||||
})
|
|
||||||
let inputRef!: HTMLDivElement
|
let inputRef!: HTMLDivElement
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -146,7 +152,7 @@ export default function Page() {
|
|||||||
() => visibleUserMessages().at(-1)?.id,
|
() => visibleUserMessages().at(-1)?.id,
|
||||||
(lastId, prevLastId) => {
|
(lastId, prevLastId) => {
|
||||||
if (lastId && prevLastId && lastId > prevLastId) {
|
if (lastId && prevLastId && lastId > prevLastId) {
|
||||||
setMessageStore("messageId", undefined)
|
setStore("messageId", undefined)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
@@ -219,6 +225,14 @@ export default function Page() {
|
|||||||
slash: "terminal",
|
slash: "terminal",
|
||||||
onSelect: () => layout.terminal.toggle(),
|
onSelect: () => layout.terminal.toggle(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "review.toggle",
|
||||||
|
title: "Toggle review",
|
||||||
|
description: "Show or hide the review panel",
|
||||||
|
category: "View",
|
||||||
|
keybind: "mod+shift+r",
|
||||||
|
onSelect: () => layout.review.toggle(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "terminal.new",
|
id: "terminal.new",
|
||||||
title: "New terminal",
|
title: "New terminal",
|
||||||
@@ -264,6 +278,15 @@ export default function Page() {
|
|||||||
slash: "model",
|
slash: "model",
|
||||||
onSelect: () => dialog.show(() => <DialogSelectModel />),
|
onSelect: () => dialog.show(() => <DialogSelectModel />),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "mcp.toggle",
|
||||||
|
title: "Toggle MCPs",
|
||||||
|
description: "Toggle MCPs",
|
||||||
|
category: "MCP",
|
||||||
|
keybind: "mod+;",
|
||||||
|
slash: "mcp",
|
||||||
|
onSelect: () => dialog.show(() => <DialogSelectMcp />),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "agent.cycle",
|
id: "agent.cycle",
|
||||||
title: "Cycle agent",
|
title: "Cycle agent",
|
||||||
@@ -531,74 +554,163 @@ export default function Page() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const showTabs = createMemo(() => diffs().length > 0 || tabs().all().length > 0)
|
const showTabs = createMemo(() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0))
|
||||||
|
|
||||||
|
const mobileWorking = createMemo(() => status().type !== "idle")
|
||||||
|
const mobileAutoScroll = createAutoScroll({
|
||||||
|
working: mobileWorking,
|
||||||
|
onUserInteracted: () => setStore("userInteracted", true),
|
||||||
|
})
|
||||||
|
|
||||||
|
const MobileTurns = () => (
|
||||||
|
<div
|
||||||
|
ref={mobileAutoScroll.scrollRef}
|
||||||
|
onScroll={mobileAutoScroll.handleScroll}
|
||||||
|
onClick={mobileAutoScroll.handleInteraction}
|
||||||
|
class="relative mt-2 min-w-0 w-full h-full overflow-y-auto no-scrollbar pb-12"
|
||||||
|
>
|
||||||
|
<div ref={mobileAutoScroll.contentRef} class="flex flex-col gap-45 items-start justify-start mt-4">
|
||||||
|
<For each={visibleUserMessages()}>
|
||||||
|
{(message) => (
|
||||||
|
<SessionTurn
|
||||||
|
sessionID={params.id!}
|
||||||
|
messageID={message.id}
|
||||||
|
stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
|
||||||
|
onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)}
|
||||||
|
onUserInteracted={() => setStore("userInteracted", true)}
|
||||||
|
classes={{
|
||||||
|
root: "min-w-0 w-full relative",
|
||||||
|
content:
|
||||||
|
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
|
||||||
|
container: "px-4",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const NewSessionView = () => (
|
||||||
|
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
|
||||||
|
<div class="text-20-medium text-text-weaker">New session</div>
|
||||||
|
<div class="flex justify-center items-center gap-3">
|
||||||
|
<Icon name="folder" size="small" />
|
||||||
|
<div class="text-12-medium text-text-weak">
|
||||||
|
{getDirectory(sync.data.path.directory)}
|
||||||
|
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={sync.project}>
|
||||||
|
{(project) => (
|
||||||
|
<div class="flex justify-center items-center gap-3">
|
||||||
|
<Icon name="pencil-line" size="small" />
|
||||||
|
<div class="text-12-medium text-text-weak">
|
||||||
|
Last modified
|
||||||
|
<span class="text-text-strong">
|
||||||
|
{DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const DesktopSessionContent = () => (
|
||||||
|
<Switch>
|
||||||
|
<Match when={params.id}>
|
||||||
|
<div class="flex items-start justify-start h-full min-h-0">
|
||||||
|
<SessionMessageRail
|
||||||
|
messages={visibleUserMessages()}
|
||||||
|
current={activeMessage()}
|
||||||
|
onMessageSelect={setActiveMessage}
|
||||||
|
wide={!showTabs()}
|
||||||
|
/>
|
||||||
|
<Show when={activeMessage()}>
|
||||||
|
<SessionTurn
|
||||||
|
sessionID={params.id!}
|
||||||
|
messageID={activeMessage()!.id}
|
||||||
|
stepsExpanded={store.stepsExpanded}
|
||||||
|
onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
|
||||||
|
onUserInteracted={() => setStore("userInteracted", true)}
|
||||||
|
classes={{
|
||||||
|
root: "pb-20 flex-1 min-w-0",
|
||||||
|
content: "pb-20",
|
||||||
|
container:
|
||||||
|
"w-full " +
|
||||||
|
(!showTabs() ? "max-w-200 mx-auto px-6" : visibleUserMessages().length > 1 ? "pr-6 pl-18" : "px-6"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<NewSessionView />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative bg-background-base size-full overflow-x-hidden flex flex-col">
|
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
||||||
<div class="min-h-0 grow w-full flex">
|
<div class="md:hidden flex-1 min-h-0 flex flex-col bg-background-stronger">
|
||||||
{/* Session pane - always visible */}
|
<Switch>
|
||||||
|
<Match when={!params.id}>
|
||||||
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<NewSessionView />
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={diffs().length > 0}>
|
||||||
|
<Tabs class="flex-1 min-h-0 flex flex-col pb-28">
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
|
||||||
|
Session
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
|
||||||
|
{diffs().length} Files Changed
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
<Tabs.Content value="session" class="flex-1 !overflow-hidden">
|
||||||
|
<MobileTurns />
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content forceMount value="review" class="flex-1 !overflow-hidden hidden data-[selected]:block">
|
||||||
|
<div class="relative h-full mt-6 overflow-y-auto no-scrollbar">
|
||||||
|
<SessionReview
|
||||||
|
diffs={diffs()}
|
||||||
|
classes={{
|
||||||
|
root: "pb-32",
|
||||||
|
header: "px-4",
|
||||||
|
container: "px-4",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs>
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<MobileTurns />
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
<div class="absolute inset-x-0 bottom-4 flex flex-col justify-center items-center z-50 px-4">
|
||||||
|
<div class="w-full">
|
||||||
|
<PromptInput
|
||||||
|
ref={(el) => {
|
||||||
|
inputRef = el
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hidden md:flex min-h-0 grow w-full">
|
||||||
<div
|
<div
|
||||||
class="@container relative shrink-0 py-3 flex flex-col gap-6 min-h-0 h-full bg-background-stronger"
|
class="@container relative shrink-0 py-3 flex flex-col gap-6 min-h-0 h-full bg-background-stronger"
|
||||||
style={{ width: showTabs() ? `${layout.session.width()}px` : "100%" }}
|
style={{ width: showTabs() ? `${layout.session.width()}px` : "100%" }}
|
||||||
>
|
>
|
||||||
<div class="flex-1 min-h-0 overflow-hidden">
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
<Switch>
|
<DesktopSessionContent />
|
||||||
<Match when={params.id}>
|
|
||||||
<div class="flex items-start justify-start h-full min-h-0">
|
|
||||||
<SessionMessageRail
|
|
||||||
messages={visibleUserMessages()}
|
|
||||||
current={activeMessage()}
|
|
||||||
onMessageSelect={setActiveMessage}
|
|
||||||
wide={!showTabs()}
|
|
||||||
/>
|
|
||||||
<Show when={activeMessage()}>
|
|
||||||
<SessionTurn
|
|
||||||
sessionID={params.id!}
|
|
||||||
messageID={activeMessage()!.id}
|
|
||||||
stepsExpanded={store.stepsExpanded}
|
|
||||||
onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
|
|
||||||
onUserInteracted={() => setStore("userInteracted", true)}
|
|
||||||
classes={{
|
|
||||||
root: "pb-20 flex-1 min-w-0",
|
|
||||||
content: "pb-20",
|
|
||||||
container:
|
|
||||||
"w-full " +
|
|
||||||
(!showTabs()
|
|
||||||
? "max-w-200 mx-auto px-6"
|
|
||||||
: visibleUserMessages().length > 1
|
|
||||||
? "pr-6 pl-18"
|
|
||||||
: "px-6"),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
|
|
||||||
<div class="text-20-medium text-text-weaker">New session</div>
|
|
||||||
<div class="flex justify-center items-center gap-3">
|
|
||||||
<Icon name="folder" size="small" />
|
|
||||||
<div class="text-12-medium text-text-weak">
|
|
||||||
{getDirectory(sync.data.path.directory)}
|
|
||||||
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={sync.project}>
|
|
||||||
{(project) => (
|
|
||||||
<div class="flex justify-center items-center gap-3">
|
|
||||||
<Icon name="pencil-line" size="small" />
|
|
||||||
<div class="text-12-medium text-text-weak">
|
|
||||||
Last modified
|
|
||||||
<span class="text-text-strong">
|
|
||||||
{DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
|
<div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
|
||||||
<div
|
<div
|
||||||
@@ -625,7 +737,6 @@ export default function Page() {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs pane - visible when there are diffs or file tabs */}
|
|
||||||
<Show when={showTabs()}>
|
<Show when={showTabs()}>
|
||||||
<div class="relative flex-1 min-w-0 h-full border-l border-border-weak-base">
|
<div class="relative flex-1 min-w-0 h-full border-l border-border-weak-base">
|
||||||
<DragDropProvider
|
<DragDropProvider
|
||||||
@@ -662,7 +773,15 @@ export default function Page() {
|
|||||||
</For>
|
</For>
|
||||||
</SortableProvider>
|
</SortableProvider>
|
||||||
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
||||||
<Tooltip value="Open file" class="flex items-center">
|
<Tooltip
|
||||||
|
value={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>Open file</span>
|
||||||
|
<span class="text-icon-base text-12-medium">{command.keybind("file.open")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
class="flex items-center"
|
||||||
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="plus-small"
|
icon="plus-small"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -675,7 +794,7 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
<Show when={diffs().length}>
|
<Show when={diffs().length}>
|
||||||
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict">
|
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict">
|
||||||
<div class="relative pt-3 flex-1 min-h-0 overflow-hidden">
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
<SessionReview
|
<SessionReview
|
||||||
classes={{
|
classes={{
|
||||||
root: "pb-40",
|
root: "pb-40",
|
||||||
@@ -746,9 +865,10 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={layout.terminal.opened()}>
|
<Show when={layout.terminal.opened()}>
|
||||||
<div
|
<div
|
||||||
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
|
class="hidden md:flex relative w-full flex-col shrink-0 border-t border-border-weak-base"
|
||||||
style={{ height: `${layout.terminal.height()}px` }}
|
style={{ height: `${layout.terminal.height()}px` }}
|
||||||
>
|
>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
@@ -774,7 +894,15 @@ export default function Page() {
|
|||||||
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
|
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
|
||||||
</SortableProvider>
|
</SortableProvider>
|
||||||
<div class="h-full flex items-center justify-center">
|
<div class="h-full flex items-center justify-center">
|
||||||
<Tooltip value="New Terminal" class="flex items-center">
|
<Tooltip
|
||||||
|
value={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>New terminal</span>
|
||||||
|
<span class="text-icon-base text-12-medium">{command.keybind("terminal.new")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
class="flex items-center"
|
||||||
|
>
|
||||||
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
|
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -806,6 +934,10 @@ export default function Page() {
|
|||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
<StatusBar>
|
||||||
|
<SessionLspIndicator />
|
||||||
|
<SessionMcpIndicator />
|
||||||
|
</StatusBar>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@@ -9,12 +11,16 @@
|
|||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"jsxImportSource": "solid-js",
|
"jsxImportSource": "solid-js",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"outDir": "node_modules/.ts-dist",
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"paths": {
|
||||||
"emitDeclarationOnly": false,
|
"@/*": ["./src/*"]
|
||||||
"outDir": "node_modules/.ts-dist"
|
}
|
||||||
},
|
},
|
||||||
"references": [{ "path": "../desktop" }],
|
"include": ["src", "package.json"],
|
||||||
"include": ["src"]
|
"exclude": ["dist", "ts-dist"]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vite"
|
||||||
|
import desktopPlugin from "./vite"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [desktopPlugin] as any,
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
allowedHosts: true,
|
||||||
|
port: 3000,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
target: "esnext",
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.0.183",
|
"version": "1.0.207",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit",
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export const config = {
|
|||||||
github: {
|
github: {
|
||||||
repoUrl: "https://github.com/sst/opencode",
|
repoUrl: "https://github.com/sst/opencode",
|
||||||
starsFormatted: {
|
starsFormatted: {
|
||||||
compact: "38K",
|
compact: "41K",
|
||||||
full: "38,000",
|
full: "41,000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -22,8 +22,8 @@ export const config = {
|
|||||||
|
|
||||||
// Static stats (used on landing page)
|
// Static stats (used on landing page)
|
||||||
stats: {
|
stats: {
|
||||||
contributors: "400",
|
contributors: "450",
|
||||||
commits: "5,000",
|
commits: "6,000",
|
||||||
monthlyUsers: "400,000",
|
monthlyUsers: "400,000",
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -5,28 +5,36 @@ import { useAuthSession } from "~/context/auth.session"
|
|||||||
|
|
||||||
export async function GET(input: APIEvent) {
|
export async function GET(input: APIEvent) {
|
||||||
const url = new URL(input.request.url)
|
const url = new URL(input.request.url)
|
||||||
const code = url.searchParams.get("code")
|
try {
|
||||||
if (!code) throw new Error("No code found")
|
const code = url.searchParams.get("code")
|
||||||
const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
|
if (!code) throw new Error("No code found")
|
||||||
if (result.err) {
|
const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
|
||||||
throw new Error(result.err.message)
|
if (result.err) throw new Error(result.err.message)
|
||||||
}
|
const decoded = AuthClient.decode(result.tokens.access, {} as any)
|
||||||
const decoded = AuthClient.decode(result.tokens.access, {} as any)
|
if (decoded.err) throw new Error(decoded.err.message)
|
||||||
if (decoded.err) throw new Error(decoded.err.message)
|
const session = await useAuthSession()
|
||||||
const session = await useAuthSession()
|
const id = decoded.subject.properties.accountID
|
||||||
const id = decoded.subject.properties.accountID
|
await session.update((value) => {
|
||||||
await session.update((value) => {
|
return {
|
||||||
return {
|
...value,
|
||||||
...value,
|
account: {
|
||||||
account: {
|
...value.account,
|
||||||
...value.account,
|
[id]: {
|
||||||
[id]: {
|
id,
|
||||||
id,
|
email: decoded.subject.properties.email,
|
||||||
email: decoded.subject.properties.email,
|
},
|
||||||
},
|
},
|
||||||
},
|
current: id,
|
||||||
current: id,
|
}
|
||||||
}
|
})
|
||||||
})
|
return redirect("/auth")
|
||||||
return redirect("/auth")
|
} catch (e: any) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: e.message,
|
||||||
|
cause: Object.fromEntries(url.searchParams.entries()),
|
||||||
|
}),
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { Title } from "@solidjs/meta"
|
||||||
|
import { createAsync, query, useParams } from "@solidjs/router"
|
||||||
|
import { createSignal, For, Show } from "solid-js"
|
||||||
|
import { Database, desc, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
|
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
|
||||||
|
|
||||||
|
interface TaskSource {
|
||||||
|
repo: string
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Judge {
|
||||||
|
score: number
|
||||||
|
rationale: string
|
||||||
|
judge: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScoreDetail {
|
||||||
|
criterion: string
|
||||||
|
weight: number
|
||||||
|
average: number
|
||||||
|
variance?: number
|
||||||
|
judges?: Judge[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunUsage {
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
cost: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Run {
|
||||||
|
task: string
|
||||||
|
model: string
|
||||||
|
agent: string
|
||||||
|
score: {
|
||||||
|
final: number
|
||||||
|
base: number
|
||||||
|
penalty: number
|
||||||
|
}
|
||||||
|
scoreDetails: ScoreDetail[]
|
||||||
|
usage?: RunUsage
|
||||||
|
duration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Prompt {
|
||||||
|
commit: string
|
||||||
|
prompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AverageUsage {
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
cost: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
averageScore: number
|
||||||
|
averageDuration?: number
|
||||||
|
averageUsage?: AverageUsage
|
||||||
|
model?: string
|
||||||
|
agent?: string
|
||||||
|
summary?: string
|
||||||
|
runs?: Run[]
|
||||||
|
task: {
|
||||||
|
id: string
|
||||||
|
source: TaskSource
|
||||||
|
prompts?: Prompt[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BenchmarkResult {
|
||||||
|
averageScore: number
|
||||||
|
tasks: Task[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTaskDetail(benchmarkId: string, taskId: string) {
|
||||||
|
"use server"
|
||||||
|
const rows = await Database.use((tx) =>
|
||||||
|
tx.select().from(BenchmarkTable).where(eq(BenchmarkTable.id, benchmarkId)).limit(1),
|
||||||
|
)
|
||||||
|
if (!rows[0]) return null
|
||||||
|
const parsed = JSON.parse(rows[0].result) as BenchmarkResult
|
||||||
|
const task = parsed.tasks.find((t) => t.task.id === taskId)
|
||||||
|
return task ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryTaskDetail = query(getTaskDetail, "benchmark.task.detail")
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const seconds = Math.floor(ms / 1000)
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const remainingSeconds = seconds % 60
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes}m ${remainingSeconds}s`
|
||||||
|
}
|
||||||
|
return `${remainingSeconds}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BenchDetail() {
|
||||||
|
const params = useParams()
|
||||||
|
const [benchmarkId, taskId] = (params.id ?? "").split(":")
|
||||||
|
const task = createAsync(() => queryTaskDetail(benchmarkId, taskId))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main data-page="bench-detail">
|
||||||
|
<Title>Benchmark - {taskId}</Title>
|
||||||
|
<div style={{ padding: "1rem" }}>
|
||||||
|
<Show when={task()} fallback={<p>Task not found</p>}>
|
||||||
|
<div style={{ "margin-bottom": "1rem" }}>
|
||||||
|
<div>
|
||||||
|
<strong>Agent: </strong>
|
||||||
|
{task()?.agent ?? "N/A"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Model: </strong>
|
||||||
|
{task()?.model ?? "N/A"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Task: </strong>
|
||||||
|
{task()!.task.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ "margin-bottom": "1rem" }}>
|
||||||
|
<div>
|
||||||
|
<strong>Repo: </strong>
|
||||||
|
<a
|
||||||
|
href={`https://github.com/${task()!.task.source.repo}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: "#0066cc" }}
|
||||||
|
>
|
||||||
|
{task()!.task.source.repo}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>From: </strong>
|
||||||
|
<a
|
||||||
|
href={`https://github.com/${task()!.task.source.repo}/commit/${task()!.task.source.from}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: "#0066cc" }}
|
||||||
|
>
|
||||||
|
{task()!.task.source.from.slice(0, 7)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>To: </strong>
|
||||||
|
<a
|
||||||
|
href={`https://github.com/${task()!.task.source.repo}/commit/${task()!.task.source.to}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: "#0066cc" }}
|
||||||
|
>
|
||||||
|
{task()!.task.source.to.slice(0, 7)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={task()?.task.prompts && task()!.task.prompts!.length > 0}>
|
||||||
|
<div style={{ "margin-bottom": "1rem" }}>
|
||||||
|
<strong>Prompt:</strong>
|
||||||
|
<For each={task()!.task.prompts}>
|
||||||
|
{(p) => (
|
||||||
|
<div style={{ "margin-top": "0.5rem" }}>
|
||||||
|
<div style={{ "font-size": "0.875rem", color: "#666" }}>Commit: {p.commit.slice(0, 7)}</div>
|
||||||
|
<p style={{ "margin-top": "0.25rem", "white-space": "pre-wrap" }}>{p.prompt}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<hr style={{ margin: "1rem 0", border: "none", "border-top": "1px solid #ccc" }} />
|
||||||
|
|
||||||
|
<div style={{ "margin-bottom": "1rem" }}>
|
||||||
|
<div>
|
||||||
|
<strong>Average Duration: </strong>
|
||||||
|
{task()?.averageDuration ? formatDuration(task()!.averageDuration!) : "N/A"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Average Score: </strong>
|
||||||
|
{task()?.averageScore?.toFixed(3) ?? "N/A"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Average Cost: </strong>
|
||||||
|
{task()?.averageUsage?.cost ? `$${task()!.averageUsage!.cost.toFixed(4)}` : "N/A"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={task()?.summary}>
|
||||||
|
<div style={{ "margin-bottom": "1rem" }}>
|
||||||
|
<strong>Summary:</strong>
|
||||||
|
<p style={{ "margin-top": "0.5rem", "white-space": "pre-wrap" }}>{task()!.summary}</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={task()?.runs && task()!.runs!.length > 0}>
|
||||||
|
<div style={{ "margin-bottom": "1rem" }}>
|
||||||
|
<strong>Runs:</strong>
|
||||||
|
<table style={{ "margin-top": "0.5rem", "border-collapse": "collapse", width: "100%" }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Run</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
padding: "0.5rem",
|
||||||
|
"text-align": "left",
|
||||||
|
"white-space": "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Score (Base - Penalty)
|
||||||
|
</th>
|
||||||
|
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Cost</th>
|
||||||
|
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Duration</th>
|
||||||
|
<For each={task()!.runs![0]?.scoreDetails}>
|
||||||
|
{(detail) => (
|
||||||
|
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>
|
||||||
|
{detail.criterion} ({detail.weight})
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={task()!.runs}>
|
||||||
|
{(run, index) => (
|
||||||
|
<tr>
|
||||||
|
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>{index() + 1}</td>
|
||||||
|
<td style={{ border: "1px solid #ccc", padding: "0.5rem", "white-space": "nowrap" }}>
|
||||||
|
{run.score.final.toFixed(3)} ({run.score.base.toFixed(3)} - {run.score.penalty.toFixed(3)})
|
||||||
|
</td>
|
||||||
|
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
|
||||||
|
{run.usage?.cost ? `$${run.usage.cost.toFixed(4)}` : "N/A"}
|
||||||
|
</td>
|
||||||
|
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
|
||||||
|
{run.duration ? formatDuration(run.duration) : "N/A"}
|
||||||
|
</td>
|
||||||
|
<For each={run.scoreDetails}>
|
||||||
|
{(detail) => (
|
||||||
|
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
|
||||||
|
<For each={detail.judges}>
|
||||||
|
{(judge) => (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: judge.score === 1 ? "green" : judge.score === 0 ? "red" : "inherit",
|
||||||
|
"margin-right": "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{judge.score === 1 ? "✓" : judge.score === 0 ? "✗" : judge.score}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<For each={task()!.runs}>
|
||||||
|
{(run, index) => (
|
||||||
|
<div style={{ "margin-top": "1rem" }}>
|
||||||
|
<h3 style={{ margin: "0 0 0.5rem 0" }}>Run {index() + 1}</h3>
|
||||||
|
<div>
|
||||||
|
<strong>Score: </strong>
|
||||||
|
{run.score.final.toFixed(3)} (Base: {run.score.base.toFixed(3)} - Penalty:{" "}
|
||||||
|
{run.score.penalty.toFixed(3)})
|
||||||
|
</div>
|
||||||
|
<For each={run.scoreDetails}>
|
||||||
|
{(detail) => (
|
||||||
|
<div style={{ "margin-top": "1rem", "padding-left": "1rem", "border-left": "2px solid #ccc" }}>
|
||||||
|
<div>
|
||||||
|
{detail.criterion} (weight: {detail.weight}){" "}
|
||||||
|
<For each={detail.judges}>
|
||||||
|
{(judge) => (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: judge.score === 1 ? "green" : judge.score === 0 ? "red" : "inherit",
|
||||||
|
"margin-right": "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{judge.score === 1 ? "✓" : judge.score === 0 ? "✗" : judge.score}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<Show when={detail.judges && detail.judges.length > 0}>
|
||||||
|
<For each={detail.judges}>
|
||||||
|
{(judge) => {
|
||||||
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
|
return (
|
||||||
|
<div style={{ "margin-top": "0.5rem", "padding-left": "1rem" }}>
|
||||||
|
<div
|
||||||
|
style={{ "font-size": "0.875rem", cursor: "pointer" }}
|
||||||
|
onClick={() => setExpanded(!expanded())}
|
||||||
|
>
|
||||||
|
<span style={{ "margin-right": "0.5rem" }}>{expanded() ? "▼" : "▶"}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: judge.score === 1 ? "green" : judge.score === 0 ? "red" : "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{judge.score === 1 ? "✓" : judge.score === 0 ? "✗" : judge.score}
|
||||||
|
</span>{" "}
|
||||||
|
{judge.judge}
|
||||||
|
</div>
|
||||||
|
<Show when={expanded()}>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "0.25rem 0 0 0",
|
||||||
|
"white-space": "pre-wrap",
|
||||||
|
"font-size": "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{judge.rationale}
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const [jsonExpanded, setJsonExpanded] = createSignal(false)
|
||||||
|
return (
|
||||||
|
<div style={{ "margin-top": "1rem" }}>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "0.75rem 1.5rem",
|
||||||
|
"font-size": "1rem",
|
||||||
|
background: "#f0f0f0",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
"border-radius": "4px",
|
||||||
|
}}
|
||||||
|
onClick={() => setJsonExpanded(!jsonExpanded())}
|
||||||
|
>
|
||||||
|
<span style={{ "margin-right": "0.5rem" }}>{jsonExpanded() ? "▼" : "▶"}</span>
|
||||||
|
Raw JSON
|
||||||
|
</button>
|
||||||
|
<Show when={jsonExpanded()}>
|
||||||
|
<pre>{JSON.stringify(task(), null, 2)}</pre>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Title } from "@solidjs/meta"
|
||||||
|
import { A, createAsync, query } from "@solidjs/router"
|
||||||
|
import { createMemo, For, Show } from "solid-js"
|
||||||
|
import { Database, desc } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
|
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
|
||||||
|
|
||||||
|
interface BenchmarkResult {
|
||||||
|
averageScore: number
|
||||||
|
tasks: { averageScore: number; task: { id: string } }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBenchmarks() {
|
||||||
|
"use server"
|
||||||
|
const rows = await Database.use((tx) =>
|
||||||
|
tx.select().from(BenchmarkTable).orderBy(desc(BenchmarkTable.timeCreated)).limit(100),
|
||||||
|
)
|
||||||
|
return rows.map((row) => {
|
||||||
|
const parsed = JSON.parse(row.result) as BenchmarkResult
|
||||||
|
const taskScores: Record<string, number> = {}
|
||||||
|
for (const t of parsed.tasks) {
|
||||||
|
taskScores[t.task.id] = t.averageScore
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
agent: row.agent,
|
||||||
|
model: row.model,
|
||||||
|
averageScore: parsed.averageScore,
|
||||||
|
taskScores,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryBenchmarks = query(getBenchmarks, "benchmarks.list")
|
||||||
|
|
||||||
|
export default function Bench() {
|
||||||
|
const benchmarks = createAsync(() => queryBenchmarks())
|
||||||
|
|
||||||
|
const taskIds = createMemo(() => {
|
||||||
|
const ids = new Set<string>()
|
||||||
|
for (const row of benchmarks() ?? []) {
|
||||||
|
for (const id of Object.keys(row.taskScores)) {
|
||||||
|
ids.add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...ids].sort()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main data-page="bench" style={{ padding: "2rem" }}>
|
||||||
|
<Title>Benchmark</Title>
|
||||||
|
<h1 style={{ "margin-bottom": "1.5rem" }}>Benchmarks</h1>
|
||||||
|
<table style={{ "border-collapse": "collapse", width: "100%" }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ "text-align": "left", padding: "0.75rem" }}>Agent</th>
|
||||||
|
<th style={{ "text-align": "left", padding: "0.75rem" }}>Model</th>
|
||||||
|
<th style={{ "text-align": "left", padding: "0.75rem" }}>Score</th>
|
||||||
|
<For each={taskIds()}>{(id) => <th style={{ "text-align": "left", padding: "0.75rem" }}>{id}</th>}</For>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={benchmarks()}>
|
||||||
|
{(row) => (
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "0.75rem" }}>{row.agent}</td>
|
||||||
|
<td style={{ padding: "0.75rem" }}>{row.model}</td>
|
||||||
|
<td style={{ padding: "0.75rem" }}>{row.averageScore.toFixed(3)}</td>
|
||||||
|
<For each={taskIds()}>
|
||||||
|
{(id) => (
|
||||||
|
<td style={{ padding: "0.75rem" }}>
|
||||||
|
<Show when={row.taskScores[id] !== undefined} fallback="">
|
||||||
|
<A href={`/bench/${row.id}:${id}`} style={{ color: "#0066cc" }}>
|
||||||
|
{row.taskScores[id]?.toFixed(3)}
|
||||||
|
</A>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { APIEvent } from "@solidjs/start/server"
|
||||||
|
import { Database } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
|
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
|
||||||
|
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||||
|
|
||||||
|
interface SubmissionBody {
|
||||||
|
model: string
|
||||||
|
agent: string
|
||||||
|
result: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(event: APIEvent) {
|
||||||
|
const body = (await event.request.json()) as SubmissionBody
|
||||||
|
|
||||||
|
if (!body.model || !body.agent || !body.result) {
|
||||||
|
return Response.json({ error: "All fields are required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await Database.use((tx) =>
|
||||||
|
tx.insert(BenchmarkTable).values({
|
||||||
|
id: Identifier.create("benchmark"),
|
||||||
|
model: body.model,
|
||||||
|
agent: body.agent,
|
||||||
|
result: body.result,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response.json({ success: true }, { status: 200 })
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ const assetNames: Record<string, string> = {
|
|||||||
"darwin-x64-dmg": "opencode-desktop-darwin-x64.dmg",
|
"darwin-x64-dmg": "opencode-desktop-darwin-x64.dmg",
|
||||||
"windows-x64-nsis": "opencode-desktop-windows-x64.exe",
|
"windows-x64-nsis": "opencode-desktop-windows-x64.exe",
|
||||||
"linux-x64-deb": "opencode-desktop-linux-amd64.deb",
|
"linux-x64-deb": "opencode-desktop-linux-amd64.deb",
|
||||||
|
"linux-x64-appimage": "opencode-desktop-linux-amd64.AppImage",
|
||||||
"linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm",
|
"linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm",
|
||||||
} satisfies Record<DownloadPlatform, string>
|
} satisfies Record<DownloadPlatform, string>
|
||||||
|
|
||||||
|
|||||||
@@ -244,6 +244,22 @@ export default function Download() {
|
|||||||
Download
|
Download
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div data-component="download-row">
|
||||||
|
<div data-component="download-info">
|
||||||
|
<span data-slot="icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M4.34591 22.7088C5.61167 22.86 7.03384 23.6799 8.22401 23.8247C9.42058 23.9758 9.79086 23.0098 9.79086 23.0098C9.79086 23.0098 11.1374 22.7088 12.553 22.6741C13.97 22.6344 15.3113 22.9688 15.3113 22.9688C15.3113 22.9688 15.5714 23.5646 16.057 23.8247C16.5426 24.0898 17.588 24.1257 18.258 23.4198C18.9293 22.7088 20.7204 21.8132 21.7261 21.2533C22.7382 20.6922 22.5525 19.8364 21.917 19.5763C21.2816 19.3163 20.7614 18.9063 20.8011 18.1196C20.8357 17.3394 20.24 16.8193 20.24 16.8193C20.24 16.8193 20.7614 15.1025 20.2759 13.6805C19.7903 12.2648 18.1889 9.98819 16.9577 8.27657C15.7266 6.55985 16.7719 4.5779 15.651 2.04503C14.5299 -0.491656 11.623 -0.341713 10.0562 0.739505C8.4893 1.8208 8.96968 4.50225 9.04526 5.77447C9.12084 7.04022 9.07985 7.94598 8.93509 8.27146C8.79033 8.60198 7.77951 9.80243 7.1082 10.8081C6.43818 11.819 5.95254 13.906 5.46187 14.7669C4.98142 15.6228 5.31711 16.403 5.31711 16.403C5.31711 16.403 4.98149 16.5182 4.71628 17.0795C4.45616 17.6342 3.93601 17.8993 2.99948 18.0801C2.06934 18.2709 2.06934 18.8705 2.29357 19.5419C2.51902 20.2119 2.29357 20.5873 2.03346 21.4431C1.77342 22.2988 3.07506 22.5588 4.34591 22.7088ZM17.5034 18.805C18.1683 19.0958 19.124 18.691 19.4149 18.4001C19.7045 18.1106 19.9094 17.6801 19.9094 17.6801C19.9094 17.6801 20.2002 17.8249 20.1707 18.2848C20.14 18.7512 20.3706 19.4161 20.8062 19.6467C21.2418 19.876 21.9067 20.1963 21.5621 20.5166C21.211 20.8369 19.2688 21.6183 18.6885 22.2282C18.1132 22.8341 17.3573 23.33 16.8974 23.1839C16.4324 23.0391 16.0262 22.4037 16.2261 21.4736C16.4324 20.5473 16.6066 19.5313 16.5771 18.951C16.5464 18.3707 16.4324 17.5892 16.5771 17.4738C16.7219 17.3598 16.9525 17.4148 16.9525 17.4148C16.9525 17.4148 16.8371 18.5156 17.5034 18.805ZM13.1885 3.12632C13.829 3.12632 14.3454 3.76175 14.3454 4.54324C14.3454 5.09798 14.0853 5.57844 13.7048 5.80906C13.6087 5.76937 13.5087 5.72449 13.3986 5.67832C13.6292 5.56434 13.7893 5.27352 13.7893 4.93783C13.7893 4.49844 13.519 4.13714 13.1794 4.13714C12.8489 4.13714 12.5734 4.49836 12.5734 4.93783C12.5734 5.09806 12.6132 5.25813 12.6785 5.38369C12.4786 5.30293 12.298 5.23383 12.1532 5.17874C12.0776 4.98781 12.0328 4.77257 12.0328 4.54331C12.0328 3.76183 12.5478 3.12632 13.1885 3.12632ZM11.6024 5.56823C11.9176 5.62331 12.7835 5.9987 13.1039 6.11398C13.4242 6.22415 13.7791 6.4291 13.7445 6.63413C13.7048 6.84548 13.5395 6.84548 13.1039 7.1107C12.6735 7.37082 11.7331 7.95116 11.432 7.99085C11.1322 8.03055 10.9618 7.86141 10.6415 7.65516C10.3211 7.44503 9.72039 6.95436 9.87147 6.69432C9.87147 6.69432 10.3416 6.33432 10.5467 6.14986C10.7517 5.95893 11.2821 5.50925 11.6024 5.56823ZM10.2213 3.35185C10.726 3.35185 11.1373 3.95268 11.1373 4.69318C11.1373 4.82773 11.1219 4.95322 11.0976 5.07878C10.972 5.11847 10.8466 5.18385 10.726 5.28891C10.6671 5.33889 10.612 5.38369 10.5621 5.43367C10.6415 5.28381 10.6722 5.06857 10.6363 4.84305C10.5672 4.44335 10.2968 4.14743 10.0316 4.18712C9.76511 4.232 9.60625 4.5984 9.67033 5.00327C9.74081 5.41325 10.0059 5.7091 10.2763 5.6643C10.2917 5.6592 10.3058 5.65409 10.3211 5.64891C10.1918 5.77447 10.0713 5.88464 9.94576 5.97432C9.58065 5.80388 9.31033 5.29402 9.31033 4.69318C9.31041 3.94758 9.71521 3.35185 10.2213 3.35185ZM7.40915 13.045C7.9293 12.2251 8.26492 10.4328 8.78507 9.83702C9.31041 9.24259 9.71521 7.97554 9.53075 7.41569C9.53075 7.41569 10.6517 8.75702 11.432 8.53668C12.2135 8.31116 13.97 7.00571 14.23 7.22994C14.4901 7.45539 16.727 12.375 16.9525 13.9419C17.178 15.5074 16.8026 16.7041 16.8026 16.7041C16.8026 16.7041 15.9468 16.4785 15.8366 16.9987C15.7264 17.524 15.7264 19.4265 15.7264 19.4265C15.7264 19.4265 14.5695 21.0279 12.7784 21.2931C10.9874 21.5532 10.0905 21.3636 10.0905 21.3636L9.08481 20.2118C9.08481 20.2118 9.86637 20.0965 9.75612 19.3112C9.64595 18.531 7.36801 17.4496 6.95803 16.4785C6.5482 15.5073 6.8826 13.8662 7.40915 13.045ZM2.9802 18.9204C3.06988 18.5361 4.23056 18.5361 4.67643 18.2657C5.12229 17.9954 5.21189 17.219 5.57197 17.0141C5.92679 16.804 6.58279 17.5496 6.85311 17.9697C7.11833 18.3797 8.13433 20.1721 8.54942 20.6179C8.96961 21.0676 9.35528 21.6633 9.23483 22.1988C9.12084 22.7343 8.48923 23.1251 8.48923 23.1251C7.92427 23.2993 6.34843 22.619 5.63231 22.3192C4.9162 22.0182 3.09433 21.9284 2.8599 21.6633C2.61906 21.393 2.97517 20.7972 3.06995 20.2322C3.15445 19.6609 2.8893 19.306 2.9802 18.9204Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span>Linux (.AppImage)</span>
|
||||||
|
</div>
|
||||||
|
<a href={getDownloadHref("linux-x64-appimage")} data-component="action-button">
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
export type DownloadPlatform = `darwin-${"x64" | "aarch64"}-dmg` | "windows-x64-nsis" | `linux-x64-${"deb" | "rpm"}`
|
export type DownloadPlatform =
|
||||||
|
| `darwin-${"x64" | "aarch64"}-dmg`
|
||||||
|
| "windows-x64-nsis"
|
||||||
|
| `linux-x64-${"deb" | "rpm" | "appimage"}`
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user