mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 03:06:10 +00:00
Compare commits
416
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1803aaf857 | ||
|
|
00ab94c44f | ||
|
|
fe09a2e9b7 | ||
|
|
59202356fe | ||
|
|
637282f3b9 | ||
|
|
d1550cb599 | ||
|
|
ed6c117184 | ||
|
|
6ce62bd84a | ||
|
|
a4a948316b | ||
|
|
aca3b7813d | ||
|
|
0eaa75ec0f | ||
|
|
6b3c4f5839 | ||
|
|
8e76adb08f | ||
|
|
0bb24a46c1 | ||
|
|
d3f4695af3 | ||
|
|
5b2715d24f | ||
|
|
20e37e7122 | ||
|
|
b87bb4486f | ||
|
|
feaec7a6be | ||
|
|
1c67004999 | ||
|
+18 |
43ecf3ff1b | ||
|
|
9028c2d8f8 | ||
|
|
be18f22842 | ||
|
|
6216dbae15 | ||
|
|
96a9731947 | ||
|
|
768a69bbbd | ||
|
|
b7b941c354 | ||
|
|
cf5e0adf02 | ||
|
|
41d0a9f010 | ||
|
|
accaa792d8 | ||
|
|
76d7a11585 | ||
|
|
c867e66ab5 | ||
|
|
278c510549 | ||
|
|
0d6ccd2a50 | ||
|
|
39cceeb143 | ||
|
|
a6449cb45c | ||
|
|
6a85f0d3db | ||
|
|
fbb0fdf88a | ||
|
|
2a4b298108 | ||
|
|
efeff63749 | ||
|
|
d54038b9d2 | ||
|
|
8f04a09d1a | ||
|
|
761f37370f | ||
|
|
b452368b3b | ||
|
|
3785eddfa0 | ||
|
|
4a006b1210 | ||
|
|
2db7ccb453 | ||
|
|
629b304c48 | ||
|
|
1004fbb2f5 | ||
|
+17 |
a7746379d5 | ||
|
|
a72992e00f | ||
|
|
99156c10e6 | ||
|
|
f6f0a5c10f | ||
|
|
e2eed76101 | ||
|
|
9ff7ef3fb0 | ||
|
|
8fe78c8f8d | ||
|
|
bd0dffd781 | ||
|
|
0ac8f91ed6 | ||
|
|
4c80c23e11 | ||
|
|
33c0cc2bb6 | ||
|
|
cc686ab8f6 | ||
|
|
6524dfc818 | ||
|
|
c91604d519 | ||
|
|
a2718177be | ||
|
|
6fb526741b | ||
|
|
85fdb3bde6 | ||
|
|
b642f9c7bc | ||
|
|
855a9569b5 | ||
|
|
aa0e626b12 | ||
|
|
389c866cac | ||
|
|
7feefb697f | ||
|
|
d4155f2906 | ||
|
|
67f2393f83 | ||
|
|
cccd013801 | ||
|
|
be7a684791 | ||
|
|
984cab7938 | ||
|
|
7eea97184a | ||
|
|
0590819090 | ||
|
+22 |
687dbba6a3 | ||
|
|
2347f4d7b4 | ||
|
|
7698a5e6ac | ||
|
|
ec8eea7cbe | ||
|
|
b44a981eef | ||
|
|
79415f625a | ||
|
|
19f42f7102 | ||
|
|
212c9f99ee | ||
|
|
99e52303e6 | ||
|
|
88925ec6e6 | ||
|
|
cedf365674 | ||
|
|
5b39972947 | ||
|
|
09553d46e0 | ||
|
|
e1d72fa338 | ||
|
|
b3cf749a7f | ||
|
|
dd842f9862 | ||
|
|
3dd29094f4 | ||
|
|
05e8d5be73 | ||
|
|
8b634e4a58 | ||
|
|
c6156f171c | ||
|
|
4d2b06f8cf | ||
|
|
bf15c97e4b | ||
|
|
3cbcd8d727 | ||
|
|
cc29f86cdc | ||
|
|
86e3828da6 | ||
|
|
8d00a51766 | ||
|
|
ed4f833813 | ||
|
|
e96c24ce2e | ||
|
|
ed6ad272ec | ||
|
|
d27746e5b3 | ||
|
|
eb661e6e6d | ||
|
|
3379e44abb | ||
|
|
e1abf59c59 | ||
|
|
4f976bcf1a | ||
|
|
e25a724bc2 | ||
|
|
521ea87192 | ||
|
|
3100701488 | ||
|
|
9c54291ae8 | ||
|
|
52ad916ba4 | ||
|
|
5db320b02d | ||
|
|
910e37f6d8 | ||
|
|
81f6e06681 | ||
|
|
da68e2865e | ||
|
|
f86e1cc1ff | ||
|
|
947bbf9490 | ||
|
|
3e57b43a4a | ||
|
|
cb18a42a47 | ||
|
|
1fb2837fd3 | ||
|
|
66a8d830aa | ||
|
|
d0733f83bf | ||
|
|
b2ce195bda | ||
|
|
6c065ca6a5 | ||
|
|
dd1c007877 | ||
|
|
8deb0e5780 | ||
|
|
f488089179 | ||
|
|
e3a39b214f | ||
|
|
b6a2912bb1 | ||
|
|
93b7ca9b4d | ||
|
|
fcb1d4b418 | ||
|
|
04b673432c | ||
|
|
bd947658bb | ||
|
|
bb6ee0a5cc | ||
|
|
fd4497e6b9 | ||
|
|
59790380de | ||
|
|
7668eb728d | ||
|
|
79301fbed5 | ||
|
|
c0ea1a43f9 | ||
|
|
96717c1a8c | ||
|
|
e312f0d775 | ||
|
|
bfdbf43ef8 | ||
|
|
984430c97d | ||
|
|
bf01264661 | ||
|
|
b046bbe4e3 | ||
|
|
8dc9ac07a2 | ||
|
|
ba24037e64 | ||
|
|
df471872de | ||
|
|
8942f97998 | ||
|
|
754dd57177 | ||
|
|
f044def957 | ||
|
|
9c2842b9b0 | ||
|
+17 |
9e0d3976e1 | ||
|
|
f87998f37f | ||
|
|
e57d9ca390 | ||
|
|
d603eed5a1 | ||
|
|
0e1c0414c2 | ||
|
|
dc99c600b6 | ||
|
|
6ffe61b9fe | ||
|
|
36d17c30a7 | ||
|
|
4d100de194 | ||
|
|
c13f06c30c | ||
|
|
91f1815732 | ||
|
|
1a52e1118e | ||
|
|
f2f62438b8 | ||
|
|
2830176972 | ||
|
|
06dcf3f221 | ||
|
|
f9442e7107 | ||
|
|
ff499c3603 | ||
|
|
4cee5bd824 | ||
|
|
f406fed924 | ||
|
|
5b2d90a441 | ||
|
|
04e8801f5b | ||
|
|
7f008df79d | ||
|
|
175d200e53 | ||
|
|
41d11a8a89 | ||
|
|
0bba8c780a | ||
|
|
32cf36de9d | ||
|
|
fb75ea2cf6 | ||
|
|
147da5d278 | ||
|
|
eed23d8ee9 | ||
|
|
e6e951b252 | ||
|
|
4e019ba5d9 | ||
|
|
50a762e7b9 | ||
|
|
780c99bc2e | ||
|
|
5bcf8d5a0b | ||
|
|
652e8ff3fb | ||
|
|
7b88de47c3 | ||
|
|
33cb536879 | ||
|
|
6ffecf9345 | ||
|
|
08741f6b93 | ||
|
|
baacb2e776 | ||
|
|
a75978815a | ||
|
|
c3d26c4912 | ||
|
|
a9b7bd9e2f | ||
|
|
f9d1d3b259 | ||
|
|
f950497173 | ||
|
|
391aa38281 | ||
|
|
29e8502bb0 | ||
|
|
44b182fe23 | ||
|
|
905123b9c0 | ||
|
|
bb3b6a2f65 | ||
|
|
b8efb33cde | ||
|
|
d65ecd4a90 | ||
|
|
57fb3e5cc5 | ||
|
|
ba07481b59 | ||
|
|
b99759c7de | ||
|
|
c590e27639 | ||
|
|
8f4b62eb49 | ||
|
|
945d1c8cb2 | ||
|
|
610e618bc5 | ||
|
|
9daa4d85a4 | ||
|
|
62af66a74f | ||
|
|
5b44e5bf41 | ||
|
|
a15afbe8f2 | ||
|
|
8e0856c43b | ||
|
|
f66c829231 | ||
|
|
3baaabede8 | ||
|
|
afe3ebbc35 | ||
|
|
e2faeb84e5 | ||
|
|
35ed09ff37 | ||
|
|
9751615651 | ||
|
|
c9b24ef027 | ||
|
|
b04d8d53e6 | ||
|
|
7bd3f8ac83 | ||
|
|
6ae2fa5196 | ||
|
|
650d774372 | ||
|
|
64e4f6f91b | ||
|
|
d097cc8065 | ||
|
|
438654768c | ||
|
|
37b26e495b | ||
|
|
5d14c7a185 | ||
|
|
e66cbf36e9 | ||
|
|
50977dd4fe | ||
|
|
a644e0e7a0 | ||
|
|
ca861fdf43 | ||
|
|
34a08cbdb8 | ||
|
|
bf3ae45439 | ||
|
|
394e0b9045 | ||
|
|
4790a2772c | ||
|
|
2ff19171dc | ||
|
|
1401901529 | ||
|
|
1b88ff8d53 | ||
|
|
08293e74a0 | ||
|
|
24d26365e6 | ||
|
|
c22793d5f6 | ||
|
|
c6a52a39b5 | ||
|
|
aad8d90dd1 | ||
|
|
6f47459c68 | ||
|
|
dd768e30e2 | ||
|
|
de476aa51b | ||
|
|
bd8d858bf7 | ||
|
|
5fc4e18b82 | ||
|
|
12d9f4b29b | ||
|
|
a2769b5ade | ||
|
|
1fa605ad5e | ||
|
|
698ef25f33 | ||
|
|
88dc960af8 | ||
|
|
5657cec4f2 | ||
|
|
8d790a9ed5 | ||
|
|
b62d24e40b | ||
|
|
a1b6c84135 | ||
|
|
85fde8c11d | ||
|
|
c167bde6a0 | ||
|
|
d9bf30fc22 | ||
|
|
3ce5e9800d | ||
|
|
af5eabcb26 | ||
|
|
0087dd56d9 | ||
|
|
ef2140d121 | ||
|
|
1de3c6e4a6 | ||
|
|
b6c3a8ea7b | ||
|
|
efcf2c3f5d | ||
|
|
e65477ab1d | ||
|
|
cd0b274856 | ||
|
|
57e9e9771d | ||
|
|
1aae92c42a | ||
|
|
6555df912a | ||
|
|
7ebd344fa2 | ||
|
|
460cdc5aec | ||
|
|
b0ca5520a1 | ||
|
|
bc2e270f82 | ||
|
|
33705e632a | ||
|
|
cff2345c12 | ||
|
|
0405518180 | ||
|
|
c9604c86ec | ||
|
|
967c44552e | ||
|
|
ec95694e2f | ||
|
|
c176baee82 | ||
|
|
016d296f7c | ||
|
|
dbaa53329c | ||
|
|
7843f8fb38 | ||
|
|
4045041554 | ||
|
|
7ec7413fdb | ||
|
|
f016392368 | ||
|
|
140224b0fc | ||
|
|
cfd35c9354 | ||
|
|
674d08f9be | ||
|
|
02f012f5d1 | ||
|
|
bea6e1499d | ||
|
|
50a1fe49bc | ||
|
|
6380919fda | ||
|
|
d972aa9d83 | ||
|
|
d544ab4d91 | ||
|
|
ce228bfd7c | ||
|
|
eabf85aea2 | ||
|
|
2f4a688790 | ||
|
|
4a90ffedfb | ||
|
|
95cf5039be | ||
|
|
041215996c | ||
|
|
d0b920e187 | ||
|
+22 |
8c94e9005f | ||
|
|
932a40cfd9 | ||
|
|
716f6658db | ||
|
|
9f1a8f2149 | ||
|
|
b3650e2316 | ||
|
|
fb884bb91e | ||
|
|
98c09884bf | ||
|
|
5e47501b8b | ||
|
|
d51ba6ed94 | ||
|
|
a6983b65fc | ||
|
|
1a06198954 | ||
|
|
72ec09cf74 | ||
|
|
e2bca216a2 | ||
|
|
a10733dbf4 | ||
|
|
b5823d1077 | ||
|
|
e1fafa2e54 | ||
|
|
ae49b21376 | ||
|
|
5710064ae1 | ||
|
|
4617b03ca9 | ||
|
|
6a91a682e4 | ||
|
|
f8626865b9 | ||
|
|
2324a63fc6 | ||
|
|
1ce607c230 | ||
|
|
c26f6f95f7 | ||
|
|
d77b87ea65 | ||
|
|
6ca6566bd3 | ||
|
|
8dd993d25a | ||
|
|
24ab17e718 | ||
|
|
0a60662d71 | ||
|
|
194b0615e0 | ||
|
|
a5c51e11d0 | ||
|
|
8f1db7d06d | ||
|
|
b1ca070b3b | ||
|
|
12887e572e | ||
|
|
75715e2115 | ||
|
|
a1250cd690 | ||
|
|
23adaaaeab | ||
|
|
524ee8fc03 | ||
|
|
0f9719a7b5 | ||
|
|
1b83c08b8a | ||
|
|
02cb350880 | ||
|
|
4ce830a919 | ||
|
|
461a1c3ab4 | ||
|
|
6846542115 | ||
|
|
f928b5be07 | ||
|
|
ecfa918760 | ||
|
|
f80624cf17 | ||
|
|
fe59174c23 | ||
|
|
2fe057324f | ||
|
|
19a5b5a05d | ||
|
|
ff4cab03c1 | ||
|
|
b2d46ecd7e | ||
|
|
360d85a521 | ||
|
|
c65a7d50c1 | ||
|
|
fa73546a86 | ||
|
|
935ac2db91 | ||
|
|
01edae4a7f | ||
|
|
381d67572e | ||
|
|
e8ac44430b | ||
|
|
5ae93092aa | ||
|
|
595c6bd4a7 | ||
|
|
7073e8797f | ||
|
|
f7034a35a8 | ||
|
|
53b93b6991 | ||
|
|
6067019434 | ||
|
|
42a3cf9645 | ||
|
|
04c6bed240 | ||
|
|
94e3a29d2f | ||
|
+8 |
41283933ff | ||
|
|
11bf8d8a42 | ||
|
|
c22973ab9f | ||
|
|
612009e0f1 | ||
|
|
a0a6c9545e | ||
|
|
bceb6d0a9e | ||
|
|
49e7dc191f | ||
|
|
f4720be08e | ||
|
|
d1d7ebc2c6 | ||
|
|
cd942d0669 | ||
|
|
4741e3ee6b | ||
|
|
4673bfbaa0 | ||
|
|
b458dd8c63 | ||
|
|
7edd6c3a1d | ||
|
|
f43f066741 | ||
|
|
b6553d14e1 | ||
|
|
ab0042a666 | ||
|
|
5df049d081 | ||
|
|
573ab9c24b | ||
|
|
6a16c41e8f | ||
|
|
a491cbee64 | ||
|
|
658cbe9caf | ||
|
|
62b2bc39df | ||
|
|
beb2c52c3f | ||
|
|
655adbf46e | ||
|
|
ac2a78391f | ||
|
|
9a9bdaba95 | ||
|
|
8b682c42b6 | ||
|
|
ad4f1c1018 | ||
|
|
a0afb63ed0 | ||
|
|
df9ecb8f6a | ||
|
|
e6f660fecf |
@@ -1,4 +1,5 @@
|
||||
adamdotdevin
|
||||
arvsrn
|
||||
Brendonovich
|
||||
fwang
|
||||
Hona
|
||||
@@ -7,11 +8,14 @@ jayair
|
||||
jlongster
|
||||
kitlangton
|
||||
kommander
|
||||
ludvigrask
|
||||
MrMushrooooom
|
||||
nexxeln
|
||||
R44VC0RP
|
||||
rekram1-node
|
||||
thdxr
|
||||
simonklee
|
||||
Slickstef11
|
||||
usrnk1
|
||||
vimtor
|
||||
starptech
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
branches:
|
||||
- ci
|
||||
- dev
|
||||
- v2
|
||||
- beta
|
||||
- fix/npm-native-binary-install
|
||||
- snapshot-*
|
||||
@@ -31,6 +32,9 @@ permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -122,7 +126,7 @@ jobs:
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -221,7 +225,7 @@ jobs:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -447,6 +451,7 @@ jobs:
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- v2
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -74,13 +75,9 @@ jobs:
|
||||
working-directory: packages/client
|
||||
run: bun run check:generated
|
||||
|
||||
- name: Run HttpApi exerciser gates
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/opencode
|
||||
run: bun run test:httpapi
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
if: github.ref_name != 'v2' && github.head_ref != 'v2'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -2,9 +2,9 @@ name: typecheck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
branches: [dev, v2]
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
branches: [dev, v2]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -13,6 +13,7 @@ tmp
|
||||
dist
|
||||
ts-dist
|
||||
.turbo
|
||||
.typecheck-profiles
|
||||
**/.serena
|
||||
.serena/
|
||||
**/.omo
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
---
|
||||
name: opencode-drive
|
||||
description: Use when an agent needs drive OpenCode via a script or interact with an isolated instance
|
||||
---
|
||||
|
||||
# OpenCode Drive
|
||||
|
||||
Use `opencode-drive` to launch an isolated OpenCode instance and control it via commands or a script.
|
||||
|
||||
There are two modes. Always default to using a script unless specifically directed to be interactive (connect
|
||||
to an existing running instance, or start a new one, and make a few changes to the UI and read it, and iterate
|
||||
on changes).
|
||||
|
||||
Scripts allow you to run a full walkthrough in one run. When the script is done opencode-drive exits,
|
||||
stops all processes, and cleans up all artifacts.
|
||||
|
||||
# Prepare The Environment
|
||||
|
||||
Use `init` when files must be added to the isolated home or project before OpenCode starts. It prints the artifact directory without launching OpenCode. A later `start` with the same name reuses it.
|
||||
|
||||
```bash
|
||||
artifacts=$(opencode-drive init --name demo)
|
||||
cp -R ./fixtures/home/. "$artifacts/"
|
||||
cp -R ./fixtures/project/. "$artifacts/files/"
|
||||
opencode-drive start --name demo --dev ~/projects/opencode
|
||||
```
|
||||
|
||||
The simulated project is under `$artifacts/files`. Running `start` without a prior `init` initializes the artifacts automatically.
|
||||
|
||||
# Scripted usage
|
||||
|
||||
You can write scripts that walk through entire flows, and gives you full access to controlling
|
||||
the backend too. See examples of the script API at the bottom of this file.
|
||||
|
||||
After creating or editing a script, always typecheck it before running. Never skip this step:
|
||||
|
||||
```bash
|
||||
opencode-drive check ./reproduce-stale-exploring-empty.ts
|
||||
```
|
||||
|
||||
Run it by passing `--script` to start:
|
||||
|
||||
```bash
|
||||
opencode-drive start --name auto-stop-reproduction --script ./reproduce-stale-exploring-empty.ts
|
||||
```
|
||||
|
||||
It will output information about the run, including paths to log files which you can read
|
||||
to inspect what happened. If you need to dig into failures that aren't clear, read those log
|
||||
files. If the script is unsuccessful, automatically fix the script and run it again.
|
||||
|
||||
Scripts use one typed definition object. `setup` runs before OpenCode starts,
|
||||
and `fs.writeFile` always writes inside the simulated project.
|
||||
|
||||
You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
|
||||
|
||||
```ts
|
||||
import { defineScript } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
async setup({ fs, config }) {
|
||||
config.autoupdate = false
|
||||
await fs.writeFile("src/example.ts", "export const value = 1\n")
|
||||
},
|
||||
|
||||
async run({ ui, llm }) {
|
||||
await ui.submit("Open src/example.ts")
|
||||
await llm.send(llm.text("The file exports `value`."))
|
||||
await ui.waitFor("The file exports `value`.")
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`setup` receives the current OpenCode config object, which starts from the
|
||||
default drive config unless the prepared instance already has one. When a script
|
||||
needs custom config, mutate this `config` parameter instead of generating and
|
||||
writing a new config object from scratch, so the script keeps the default
|
||||
provider/model settings unless it intentionally changes them.
|
||||
|
||||
Note that the simulated model is a GPT model type, and opencode uses the `patch` tool for working with files Do not use a `edit` or `write` tool to edit files.
|
||||
|
||||
Use `launch: "manual"` when the script needs to launch the server and every TUI
|
||||
itself (this is extremely rare, do not use this unless explicitly asked). In this
|
||||
mode `ui` is typed as `null`; call `server.launch()` exactly
|
||||
once before launching clients. Each `clients.launch(name)` result provides the
|
||||
same UI methods as the automatic client. You can see an example of this API
|
||||
here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
|
||||
|
||||
Use the exported `wait(milliseconds)` utility for an unconditional delay.
|
||||
|
||||
`await llm.send(...)` waits for the next request and resolves after OpenCode
|
||||
acknowledges its complete response. `llm.queue(...)` declares responses in
|
||||
advance. Chunks may be built with `text`, `reasoning`, `toolCall`, `raw`,
|
||||
`finish`, and `disconnect`. A normal response receives `finish("stop")`
|
||||
automatically unless it yields or queues an explicit terminal event.
|
||||
|
||||
`llm.text(text, { delay, chunkSize })` defaults to a 2 ms delay and a
|
||||
15-character target varied by plus or minus 5 per chunk.
|
||||
|
||||
`llm.reasoning` accepts the same options, and `llm.pause(milliseconds)` adds a
|
||||
delay between any two outputs.
|
||||
|
||||
Use `llm.serve` for an ongoing typed response generator:
|
||||
|
||||
```ts
|
||||
llm.serve(async function* (request, index) {
|
||||
yield llm.reasoning(`Handling request ${index + 1}`)
|
||||
yield llm.text(`Received ${request.id}`)
|
||||
yield llm.finish("stop")
|
||||
})
|
||||
```
|
||||
|
||||
The backend connection, response cleanup, cancellation, and recording
|
||||
completion are automatic.
|
||||
|
||||
You can see some example scripts here:
|
||||
|
||||
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
|
||||
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
|
||||
|
||||
## Prune
|
||||
|
||||
- `prune` removes artifact directories. These are always cleaned up after running a script
|
||||
successfully, but leftover on failed runs. Always call this if a script fails.
|
||||
|
||||
```bash
|
||||
opencode-drive prune --name demo
|
||||
|
||||
// --force cleans up all artifcat directories
|
||||
opencode-dirve prune --force
|
||||
```
|
||||
|
||||
# Live interaction usage
|
||||
|
||||
- Always give headless instances a unique `--name`. Visible instances may omit it.
|
||||
- A normal headless `start` detaches automatically and returns after the instance is ready.
|
||||
- Do not add `&`; the long-running owner already runs in the background.
|
||||
- Configure simulated model responses after startup when needed.
|
||||
- Send ordered UI commands with `send`.
|
||||
- Always stop the instance when finished.
|
||||
|
||||
```bash
|
||||
opencode-drive start --name demo
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.type '{"text":"Explain this project"}' \
|
||||
--command.ui.enter
|
||||
|
||||
opencode-drive stop --name demo
|
||||
```
|
||||
|
||||
## Send UI Commands
|
||||
|
||||
- Every `send` opens a connection to the named instance, runs its commands in order, and exits.
|
||||
- Combine typing and Enter in one command when submitting a prompt.
|
||||
- JSON-valued commands require one JSON argument.
|
||||
- Multiple command flags execute from left to right.
|
||||
|
||||
Commands:
|
||||
|
||||
- `--command.ui.type <json>` types into the focused editor. Arguments: `text` string.
|
||||
- `--command.ui.press <json>` presses a key. Arguments: `key` string; optional `modifiers` object with boolean `ctrl`, `shift`, `meta`, `super`, or `hyper`.
|
||||
- `--command.ui.enter` presses Enter. Arguments: none.
|
||||
- `--command.ui.arrow <json>` presses an arrow key. Arguments: `direction` is `up`, `down`, `left`, or `right`.
|
||||
- `--command.ui.focus <json>` focuses an element. Arguments: `target` is the numeric element `num` returned by `ui.state`.
|
||||
- `--command.ui.click <json>` clicks an element. Arguments: numeric `target`, `x`, and `y`; use the element `num` returned by `ui.state` as `target`.
|
||||
- `--command.ui.state` prints focus and interactive element metadata as JSON. Arguments: none.
|
||||
- `--command.ui.matches <json>` prints whether literal, case-sensitive text appears on screen. Arguments: `text` string.
|
||||
|
||||
```bash
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.type '{"text":"Find the relevant code and explain it"}' \
|
||||
--command.ui.enter
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.press '{"key":"p","modifiers":{"ctrl":true}}'
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.arrow '{"direction":"down"}'
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.focus '{"target":12}'
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.click '{"target":12,"x":4,"y":1}'
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.matches '{"text":"OpenCode"}'
|
||||
```
|
||||
|
||||
To read the UI state and see information about interactable elements, use the `ui.state` command:
|
||||
|
||||
```bash
|
||||
opencode-drive send --name demo --command.ui.state
|
||||
```
|
||||
|
||||
## Configure LLM Responses
|
||||
|
||||
- `responses` controls what the LLM responds with
|
||||
- Only use this if you are wanting to reproduce an exact type of response
|
||||
- Defaults are `text,reasoning,diff,tool` with `write,apply_patch`.
|
||||
- Supported types are `text`, `reasoning`, `diff`, and `tool`.
|
||||
- `--tools` limits generated tool calls to names offered by OpenCode.
|
||||
|
||||
```bash
|
||||
opencode-drive responses --name demo \
|
||||
--types text,reasoning,diff,tool \
|
||||
--tools write,apply_patch
|
||||
|
||||
opencode-drive responses --name demo \
|
||||
--types tool \
|
||||
--tools read,glob,grep
|
||||
```
|
||||
|
||||
## Inspect The UI
|
||||
|
||||
- `ui.state` prints focus and interactive element metadata as JSON.
|
||||
- `ui.matches` checks for literal, case-sensitive screen text.
|
||||
- `screenshot` prints the generated image path.
|
||||
|
||||
```bash
|
||||
opencode-drive screenshot --name demo
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- `stop` waits for recording export and owner cleanup before returning.
|
||||
|
||||
```bash
|
||||
opencode-drive stop --name demo
|
||||
```
|
||||
|
||||
# Record The UI
|
||||
|
||||
- Start with `--record` to capture a headless instance from its first rendered frame.
|
||||
- `stop` finishes the recording, exports an MP4, and prints its path.
|
||||
|
||||
```bash
|
||||
opencode-drive start --name demo --record
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.type '{"text":"Show me the current architecture"}' \
|
||||
--command.ui.enter
|
||||
|
||||
opencode-drive stop --name demo
|
||||
```
|
||||
|
||||
# Artifacts dir
|
||||
|
||||
- `dir` prints the artifact directory for the instance.
|
||||
|
||||
```bash
|
||||
opencode-drive dir --name demo
|
||||
```
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: sample-skill
|
||||
description: Use when the user says sample skill, skill demo, or asks how an opencode SKILL.md should be structured; demonstrates a tiny project-local skill with practical assistant workflow guidance.
|
||||
---
|
||||
|
||||
# Sample Skill
|
||||
|
||||
This is a minimal project-local opencode skill. It exists as a reference for how a skill is structured and as a tiny reusable workflow the assistant can load when the user asks for a skill example.
|
||||
|
||||
## When To Use
|
||||
|
||||
- Use when the user asks for a sample skill or skill template.
|
||||
- Use when demonstrating the required `SKILL.md` frontmatter and body format.
|
||||
- Do not use for unrelated coding tasks just because a skill exists.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Confirm the specific outcome the user wants if the request is ambiguous.
|
||||
- Inspect the relevant files before changing anything.
|
||||
- Make the smallest correct change.
|
||||
- Verify the result with a focused read, typecheck, test, or other lightweight check when available.
|
||||
- Summarize the changed files and any required restart or reload step.
|
||||
|
||||
## Example Response Style
|
||||
|
||||
When this skill is relevant, keep responses direct and actionable:
|
||||
|
||||
```text
|
||||
I created a project-local skill at .opencode/skills/sample-skill/SKILL.md.
|
||||
Restart opencode for the new skill to be discovered by future sessions.
|
||||
```
|
||||
@@ -1,6 +1,7 @@
|
||||
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- The default branch in this repo is `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
|
||||
@@ -18,12 +19,15 @@ Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes a
|
||||
|
||||
Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`.
|
||||
|
||||
Never bypass Git hooks. Do not use `--no-verify` or otherwise disable, skip, or circumvent commit or push hooks. If a hook fails, fix the failure or stop and report it to the user.
|
||||
|
||||
## Style Guide
|
||||
|
||||
### General Principles
|
||||
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
|
||||
- Before adding complexity for a speculative or vanishingly unlikely race or security edge case, explain the concrete failure mode, likelihood, and complexity cost to the user and get their buy-in. Do not silently expand scope for theoretical robustness.
|
||||
- Avoid `try`/`catch` where possible
|
||||
- Avoid using the `any` type
|
||||
- Use Bun APIs when possible, like `Bun.file()`
|
||||
@@ -150,12 +154,15 @@ const table = sqliteTable("session", {
|
||||
|
||||
## V2 Session Core
|
||||
|
||||
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
|
||||
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
||||
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_pending` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries, consuming the pending row in the same event transaction; `session_pending` stores only unconsumed work.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Retry of an already-promoted input reconciles against the projected message and the durable admitted event rather than a retained row.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. Most Steps have one Physical Attempt; overflow-triggered compaction recovery may rebuild one Step for a second attempt. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
|
||||
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
||||
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement and committed revert clear it. Unavailable sources retain the last value and block only the initial complete delta.
|
||||
|
||||
+100
-80
@@ -4,40 +4,50 @@ OpenCode sessions preserve durable conversational history while assembling the r
|
||||
|
||||
## Language
|
||||
|
||||
**System Context**:
|
||||
The structured collection of contextual facts presented to the model as initial instructions and chronological updates.
|
||||
_Avoid_: System prompt
|
||||
**Model Context**:
|
||||
The complete model-visible input assembled for one **Step**, including system instructions, **Session History**, tool definitions, and step-local additions. **Instructions** are one component of Model Context, not a synonym for it.
|
||||
_Avoid_: System Context
|
||||
|
||||
**Instructions**:
|
||||
The opaque algebra of independently refreshable typed instruction sources that render the durable instruction baseline and chronological updates shown to the model.
|
||||
_Avoid_: Model Context, System Context
|
||||
|
||||
**Session History**:
|
||||
The projected chronological conversation selected for a provider turn after applying the active compaction and **Context Epoch** cutoffs.
|
||||
The projected chronological conversation selected for a **Step** after applying the active compaction boundary and interleaving derived **Instruction Updates** from the current **Instruction Epoch**.
|
||||
_Avoid_: Session Context
|
||||
|
||||
**Context Source**:
|
||||
One independently observed typed value within the **System Context**, represented by a stable key, JSON codec, infallible loader, pure baseline/update renderers, and an optional removal renderer for dynamic sources.
|
||||
**Instruction Source**:
|
||||
One independently read typed value within **Instructions**, represented by a stable namespaced key, canonical JSON codec, pure first/changed renderers, and an optional removal renderer.
|
||||
_Avoid_: Prompt fragment
|
||||
|
||||
**System Context Registry**:
|
||||
The Location-scoped registry of ordered, scoped producers that contribute to the current **System Context**.
|
||||
**InstructionEntry**:
|
||||
One API-managed, durable, per-Session instruction value. Its slash-free client key maps to the `api/<key>` **Instruction Source** key. Entries deliberately render to the model as mechanism-neutral `<context>` blocks: the model sees session context, not how it was attached.
|
||||
|
||||
**Mid-Conversation System Message**:
|
||||
A durable chronological instruction that tells the model the newly effective state of a changed **Context Source**.
|
||||
_Avoid_: System update, system notification, raw text diff
|
||||
**InstructionDiscovery**:
|
||||
The Location-scoped service that observes ambient global and upward-project `AGENTS.md` files as one ordered aggregate **Instruction Source**.
|
||||
|
||||
**Context Epoch**:
|
||||
The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline.
|
||||
**Instruction State**:
|
||||
The Session-owned projection cache of one instruction log fold: epoch start, values at that start, current values, and the last folded sequence. It is rebuilt from durable events and never authors model-visible facts.
|
||||
|
||||
**Baseline System Context**:
|
||||
The full **System Context** rendered at the start of a **Context Epoch**.
|
||||
**Instruction Update**:
|
||||
A durable `session.instructions.updated` value delta admitted at a **Safe Step Boundary**. Its model-visible System text is rendered from stored values at request assembly and is never persisted verbatim.
|
||||
_Avoid_: Correction, stored prose, raw text diff
|
||||
|
||||
**Initial Instructions**:
|
||||
The deterministic instruction text rendered from values at the current **Instruction Epoch** start and sent as provider-cache prefix state until completed compaction moves the epoch or Session movement or committed revert resets it.
|
||||
_Avoid_: Live system prompt
|
||||
|
||||
**Context Snapshot**:
|
||||
The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a provider turn.
|
||||
**Instruction Epoch**:
|
||||
The span between completed compactions. Its start is the last `session.compaction.ended` sequence, or the initial complete instruction delta when no prior epoch exists.
|
||||
|
||||
**Unavailable Context**:
|
||||
An expected temporary inability to observe a **Context Source** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded.
|
||||
**Instruction Values**:
|
||||
The key-to-hash map produced by folding instruction deltas in durable sequence order. Hash bodies live once in the content-addressed instruction blob store.
|
||||
|
||||
**Safe Provider-Turn Boundary**:
|
||||
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
|
||||
**Unavailable Instruction Source**:
|
||||
An expected temporary inability to read an **Instruction Source** value; the runtime retains its prior effective value and emits no update, while an unavailable source blocks the initial complete delta.
|
||||
|
||||
**Safe Step Boundary**:
|
||||
The point during Step preparation, after prior tool settlement and before durable input promotion, where instruction changes may be admitted chronologically.
|
||||
|
||||
**Admitted Prompt**:
|
||||
A durable user input accepted into the Session inbox but not yet included in **Session History**.
|
||||
@@ -45,11 +55,24 @@ A durable user input accepted into the Session inbox but not yet included in **S
|
||||
**Prompt Promotion**:
|
||||
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
|
||||
|
||||
**Provider Turn**:
|
||||
One request to a model provider and the response projected from that request.
|
||||
**Step**:
|
||||
One logical LLM call spanning pre-flight instruction synchronization, input promotion, request build, and compaction check; the provider stream; and tool settlement.
|
||||
_Avoid_: provider turn, turn (unqualified)
|
||||
|
||||
**Physical Attempt**:
|
||||
One actual provider request on the wire in service of a **Step**; most Steps have one Physical Attempt, while overflow-triggered compaction recovery may give one Step two.
|
||||
|
||||
**Assistant Turn**:
|
||||
A reserved name for the not-yet-modeled unit containing all **Steps** from prompt promotion until the assistant yields the floor; do not reify it until something durable needs it.
|
||||
|
||||
**Settlement**:
|
||||
The terminal transition for a unit of work: Step and tool settlement are durable, while drain and execution settlement are coordinator-observed.
|
||||
|
||||
**Execution**:
|
||||
One session-scoped coordinator busy period from first wake until idle. An Execution is process-local coordination rather than a durable domain entity.
|
||||
|
||||
**Session Drain**:
|
||||
One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
|
||||
One process-local execution span that promotes eligible input and runs required **Steps** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
|
||||
|
||||
**Model Tool Output**:
|
||||
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
|
||||
@@ -87,52 +110,49 @@ _Avoid_: Response envelope
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
||||
- **Session History** contains projected conversational messages and admitted **Mid-Conversation System Messages**; the active **Baseline System Context** remains separate provider-request state.
|
||||
- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Provider-Turn Boundary**.
|
||||
- A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state.
|
||||
- A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model.
|
||||
- The current **Context Snapshot** advances atomically with the corresponding durable **Mid-Conversation System Message**.
|
||||
- A **Context Snapshot** stores one codec-encoded JSON value and, for removable dynamic sources, a pre-rendered removal message per stable **Context Source** key.
|
||||
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
|
||||
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
|
||||
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
|
||||
- **Instructions** is an opaque carrier composed from zero or more **Instruction Sources**.
|
||||
- **Model Context** is broader than **Instructions**. For each **Step**, the runner assembles the selected agent or provider system text, **Initial Instructions**, **Session History**, available tools, and step-local additions into one model request.
|
||||
- **Session History** persists conversational messages. The runner derives model-facing **Instruction Update** messages from value deltas and interleaves them by durable sequence; **Initial Instructions** remain separate provider-request state.
|
||||
- The runner explicitly loads and combines instruction built-ins, **InstructionDiscovery**, selected-agent skill guidance, reference guidance, MCP guidance, and **InstructionEntry** values. There is no instruction registry.
|
||||
- `Instructions.combine(...)` preserves caller order and rejects duplicate stable namespaced source keys. The runner loads its producers concurrently, then combines them in its fixed declared order.
|
||||
- Each **Instruction Source** read returns one coherent typed value, explicit removal, or temporary unavailability. `Instructions.make(...)` hides the value type so differently typed sources compose uniformly; its canonical codec defines storage and hash equivalence, while pure renderers produce first, changed, and optional removal text.
|
||||
- `Instructions.read(...)` reads every composed source concurrently and exactly once at the boundary. `Instructions.diff(...)` compares encoded-value hashes with current **Instruction Values** and returns one delta plus new blob bodies.
|
||||
- `Instructions.renderInitial(...)` renders values at the **Instruction Epoch** start. `Instructions.renderUpdate(...)` renders one hydrated delta against the values immediately before it.
|
||||
- A changed **Instruction Source** contributes its hash to one **Instruction Update**; explicit removal contributes the `"removed"` sentinel.
|
||||
- An **Instruction Update** persists only its value delta. Rendered text is derived during request assembly and excluded from compaction summaries.
|
||||
- The instruction blob insert, durable delta, and **Instruction State** advance commit atomically.
|
||||
- Changes from multiple **Instruction Sources** admitted at one safe boundary combine into one **Instruction Update**.
|
||||
- Instruction changes are sampled and admitted lazily at a **Safe Step Boundary**, never pushed asynchronously when their source changes.
|
||||
- At a **Safe Step Boundary**, prior tool results are already settled; instruction preparation completes before newly admitted user input promotes.
|
||||
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
|
||||
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
|
||||
- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once.
|
||||
- Steering prompts promote at the next **Safe Step Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's step allowance; multiple prompts promoted at one boundary reset it once.
|
||||
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
|
||||
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity.
|
||||
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
|
||||
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
|
||||
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
|
||||
- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Provider-Turn Boundary**.
|
||||
- **Context Source** keys are stable and namespaced; duplicate keys fail composition. `SystemContext.combine(...)` preserves caller order; the **System Context Registry** evaluates producers concurrently and combines them in stable contribution-key order so rendered context remains deterministic.
|
||||
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
|
||||
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
|
||||
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
|
||||
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
|
||||
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
||||
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
|
||||
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
|
||||
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
|
||||
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
|
||||
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
|
||||
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
|
||||
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
|
||||
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
||||
- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn.
|
||||
- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline.
|
||||
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
|
||||
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
|
||||
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
|
||||
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
|
||||
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
||||
- A **Context Epoch** begins with one immutable **Baseline System Context**.
|
||||
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
|
||||
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
||||
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
|
||||
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
|
||||
- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
|
||||
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, physical attempts, and tool state rather than inventing an enclosing execution identity.
|
||||
- An **Execution** contains one or more **Session Drains**; a **Session Drain** contains one reserved assistant-turn span at a time; that span contains **Steps**; and each **Step** contains one or more **Physical Attempts** plus any tool calls it requires.
|
||||
- A **Step** record covers only the model-visible span from first assistant output through tool settlement; pre-flight leaves no record, and one Step settles at most one record.
|
||||
- The first **Step** admits one complete delta and renders **Initial Instructions** without narrating that delta in history; an unavailable initial source blocks the Step instead of persisting incomplete values.
|
||||
- Instruction preparation precedes durable input promotion on every Step so an unavailable first baseline leaves pending input untouched and later updates enter history before newly promoted input.
|
||||
- Completed compaction moves the **Instruction Epoch** to the exact `session.compaction.ended` sequence and copies current hashes to the epoch's initial values. Earlier updates leave active model history while durable deltas remain.
|
||||
- A newly composed **Instruction Source** absent from current **Instruction Values** emits its first rendering once at the next **Safe Step Boundary**.
|
||||
- **Unavailable Instruction Source** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
||||
- **InstructionDiscovery** observes ambient instructions as one ordered aggregate **Instruction Source**.
|
||||
- Ambient discovery reads global and upward-project `AGENTS.md` files and honors `OPENCODE_DISABLE_PROJECT_CONFIG` for project files.
|
||||
- After a successful internal file or directory read, nearby `AGENTS.md` files toward the Location root are injected once per Session as durable synthetic instruction messages.
|
||||
- **InstructionEntry** stores API-managed per-Session JSON values. Each entry contributes one `api/<key>` **Instruction Source**, so adding, replacing, or removing an entry is reconciled at the next **Safe Step Boundary**.
|
||||
- Location-scoped instruction producers naturally re-resolve when a moved Session next runs in its destination Location.
|
||||
- Moving a Session clears its **Instruction State**, so the destination must admit a complete delta before another prompt can promote. Committed revert does the same; replay derives both resets from their durable events.
|
||||
- Selected-agent available-skill guidance is an **Instruction Source** composed explicitly by the runner. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
||||
- The selected agent and model are sampled when a **Step** starts. Changes admitted after that boundary apply to the next Step and do not restart the current Step.
|
||||
- An agent switch that changes selected-agent guidance produces an **Instruction Update** while preserving the current baseline.
|
||||
- Local tool authorization and pending permission requests retain the effective agent of the **Step** that issued the call; a later agent switch cannot change that call's policy.
|
||||
- Instruction source changes never wake idle Sessions; the next naturally scheduled **Safe Step Boundary** loads and compares current values lazily.
|
||||
- Once admitted, an **Instruction Update** remains durable even if the following **Physical Attempt** fails and is replayed unchanged on retry.
|
||||
- **Instruction Updates** remain durable value history but are not `session_message` rows. Clients display changed keys rather than model-facing prose.
|
||||
- The date **Instruction Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
||||
- **Initial Instructions** are recomputed deterministically from durable values for every request; rendered bytes are not stored.
|
||||
- A model/provider switch preserves current **Instruction Values**, the **Instruction Epoch**, and chronological conversation history; the new selection applies to the next **Step**.
|
||||
- **Native Continuation Metadata** remains in durable history. Step projection includes it only for a successful exact originating provider/model match; failed Steps and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
|
||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
||||
@@ -162,12 +182,12 @@ _Avoid_: Response envelope
|
||||
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
|
||||
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
|
||||
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
|
||||
- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
|
||||
- `sessions.log({ sessionID, after, follow })` is the public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, optionally continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
|
||||
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
|
||||
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
|
||||
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
|
||||
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
|
||||
- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
|
||||
- `sessions.log({ sessionID, after, follow })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
|
||||
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
|
||||
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
|
||||
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
|
||||
@@ -175,27 +195,27 @@ _Avoid_: Response envelope
|
||||
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
|
||||
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
|
||||
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
|
||||
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate.
|
||||
- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata?
|
||||
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected **Session History**; it does not include or represent the complete **Model Context**, whose agent system text, **Initial Instructions**, tools, and step-local additions remain separate.
|
||||
- **Open question**: Should a future, separately named operation expose complete **Model Context**, including the instruction baseline, applied instruction metadata, tools, and step-local additions?
|
||||
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
|
||||
- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
|
||||
- The public operation remains `sessions.prompt(...)`; `SessionPending.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
|
||||
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
|
||||
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
|
||||
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
|
||||
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
|
||||
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
||||
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
|
||||
- An **Instruction Update** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
||||
- When the aggregate discovered instruction set changes, its **Instruction Update** includes the complete current ordered set and supersedes the prior aggregate value; when no discovered instructions remain, the message states that previously loaded instructions no longer apply.
|
||||
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
||||
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
|
||||
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
|
||||
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
|
||||
- A truncated **Model Tool Output** identifies its complete text both in the bounded model-visible preview and as a typed managed output path. Managed output paths do not modify the tool's validated structured result.
|
||||
- A truncated **Model Tool Output** identifies its complete text in the bounded model-visible preview. The Tool Registry also supplies managed paths as internal metadata to tool hooks; Session events do not expose a typed `outputPaths` field.
|
||||
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
|
||||
- Failure to retain a **Managed Tool Output File** does not change a successful tool operation into a failed one. The Session records an explicitly lossy bounded output without a path, while operators receive diagnostics for the storage failure.
|
||||
- Failure to retain a **Managed Tool Output File** fails settlement operationally. The Session never publishes a successful result whose complete output was lost during generic bounding.
|
||||
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
|
||||
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
|
||||
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
|
||||
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
|
||||
- **Managed Tool Output Files** use globally unique names in one shared flat directory. They receive no special filesystem authority; each tool applies its ordinary external-path policy.
|
||||
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
|
||||
|
||||
## Client contract architecture
|
||||
@@ -217,9 +237,9 @@ Before stabilizing the client API:
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
|
||||
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current **System Context**."
|
||||
> **Dev:** "The date changed while the session was active. Should the **Instruction Update** say what the old date was?"
|
||||
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current instructions."
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Sources**, or narrow its semantics.
|
||||
- Legacy `experimental.chat.system.transform` can mutate assembled system text arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, model dynamic uses as explicit **Instruction Sources**, or narrow its semantics.
|
||||
|
||||
+1
-1
@@ -125,4 +125,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
|
||||
|
||||
---
|
||||
|
||||
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
|
||||
+1
-1
@@ -125,4 +125,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
||||
|
||||
---
|
||||
|
||||
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.remotion
|
||||
out/frame-*.png
|
||||
@@ -0,0 +1,483 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "glm52-rise-video",
|
||||
"dependencies": {
|
||||
"@remotion/cli": "^4.0.384",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"remotion": "^4.0.384",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "^5.8.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.24.1", "", { "bin": "./bin/babel-parser.js" }, "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.24.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" } }, "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@mediabunny/aac-encoder": ["@mediabunny/aac-encoder@1.47.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-JNzgdJoHMFFnv5imi1+dmjZMudsJ1zNCUCEkKjBh90cqGhAFg8xu4V2gsyxE2i6oq/YpWx23P+OvoANLSMcDzA=="],
|
||||
|
||||
"@mediabunny/flac-encoder": ["@mediabunny/flac-encoder@1.47.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-VpKmJO0xlYcFCRD6JvJlMbNQ6d/6YMHdO1gFIqWlZABHjSSL6BquNNEgWSCv5vdF8ELDBwIYBVZEslakIB/7GA=="],
|
||||
|
||||
"@mediabunny/mp3-encoder": ["@mediabunny/mp3-encoder@1.47.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-JyzZyGeGRm2HVUQaGJ/VZT9OG+kG00mA8SLP/f3CO7+qWAmBncKc16WYXWHbZEo8Jn/ZGA6De32S5R2bTQbDqA=="],
|
||||
|
||||
"@module-federation/error-codes": ["@module-federation/error-codes@0.22.0", "", {}, "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug=="],
|
||||
|
||||
"@module-federation/runtime": ["@module-federation/runtime@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/runtime-core": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA=="],
|
||||
|
||||
"@module-federation/runtime-core": ["@module-federation/runtime-core@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA=="],
|
||||
|
||||
"@module-federation/runtime-tools": ["@module-federation/runtime-tools@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/webpack-bundler-runtime": "0.22.0" } }, "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA=="],
|
||||
|
||||
"@module-federation/sdk": ["@module-federation/sdk@0.22.0", "", {}, "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g=="],
|
||||
|
||||
"@module-federation/webpack-bundler-runtime": ["@module-federation/webpack-bundler-runtime@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
|
||||
|
||||
"@remotion/bundler": ["@remotion/bundler@4.0.483", "", { "dependencies": { "@remotion/media-parser": "4.0.483", "@remotion/studio": "4.0.483", "@remotion/studio-shared": "4.0.483", "@remotion/timeline-utils": "4.0.483", "@rspack/core": "1.7.11", "@rspack/plugin-react-refresh": "1.6.1", "css-loader": "7.1.4", "esbuild": "0.28.1", "react-refresh": "0.18.0", "remotion": "4.0.483", "style-loader": "4.0.0", "webpack": "5.105.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-5fQRMYgr2sxZPBOThnIVuFwHBq/FhusJ+Ke6xopga8io8ecK/mj9GaRXC3tgfMa+YWuu8ZSXZ0U9EsMZjjP5YQ=="],
|
||||
|
||||
"@remotion/canvas-capture": ["@remotion/canvas-capture@4.0.483", "", { "dependencies": { "mediabunny": "1.47.0", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JABgbfvXwjFp1E61tlDN9gVAH8OoeF4kUUELNwG9kcNOE4ex8+vCYEO6VKIaXZ4l8A1PEAQ5EK//NheTJ80gfw=="],
|
||||
|
||||
"@remotion/cli": ["@remotion/cli@4.0.483", "", { "dependencies": { "@remotion/bundler": "4.0.483", "@remotion/media-utils": "4.0.483", "@remotion/player": "4.0.483", "@remotion/renderer": "4.0.483", "@remotion/studio": "4.0.483", "@remotion/studio-server": "4.0.483", "@remotion/studio-shared": "4.0.483", "dotenv": "17.3.1", "minimist": "1.2.6", "prompts": "2.4.2", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" }, "bin": { "remotion": "remotion-cli.js", "remotionb": "remotionb-cli.js", "remotiond": "remotiond-cli.js" } }, "sha512-z4TQcgTfQbSIV4GQpCvIihDL77M+leQelbxv962PmfSKTT7z3XnNN732PcZ4sei2+Xg+d2SD95K4tI5XBYs5Gw=="],
|
||||
|
||||
"@remotion/compositor-darwin-arm64": ["@remotion/compositor-darwin-arm64@4.0.483", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CkEJXouGEoCs5PX3KOufkXrlT/Kgn0ZF7SEQ4meCYGYMZD+QPVtsmS1MXH8pQ4RHlFO6Tk7g0CIHMT3Y4yzpuA=="],
|
||||
|
||||
"@remotion/compositor-darwin-x64": ["@remotion/compositor-darwin-x64@4.0.483", "", { "os": "darwin", "cpu": "x64" }, "sha512-vDZK5U8FYbHGMjFC2lUgJRBz8KXwbwFSvEQi8FATD0roNg75SoaEJWrqAOIVv8v5eBjPvo18znDc36NLRYn1Eg=="],
|
||||
|
||||
"@remotion/compositor-linux-arm64-gnu": ["@remotion/compositor-linux-arm64-gnu@4.0.483", "", { "os": "linux", "cpu": "arm64" }, "sha512-1LzfYDY/DQxy3MIc9pCbTn1ObcBDcyI6MzODOwe1bTZl8TZxdUUt1M3hDrQ60AB6iHwruBAnzT9dnogiMIdSew=="],
|
||||
|
||||
"@remotion/compositor-linux-arm64-musl": ["@remotion/compositor-linux-arm64-musl@4.0.483", "", { "os": "linux", "cpu": "arm64" }, "sha512-nG1btxg0HXuLzulIqAg9NnxWyewZNrA0iLe82HdniaJjeWLORreGo4cEEJz1SjbDpQuPEY562XGkTO2DLKK5mA=="],
|
||||
|
||||
"@remotion/compositor-linux-x64-gnu": ["@remotion/compositor-linux-x64-gnu@4.0.483", "", { "os": "linux", "cpu": "x64" }, "sha512-CV4h6r2rNYh4P8utUzP6RM2vW2vq43nA3IvVOwRSuh9LwJh/7rwG53Oco4HanY/sstaNuymGpT57wdXwONU5CQ=="],
|
||||
|
||||
"@remotion/compositor-linux-x64-musl": ["@remotion/compositor-linux-x64-musl@4.0.483", "", { "os": "linux", "cpu": "x64" }, "sha512-moKvavbkoz5nteJZA4K6LMgTTlewhwIbtO7DYARZ3LK3ClGUKnYfFcmaPS15hMun02JPTOMIWbrlaszdR75mxA=="],
|
||||
|
||||
"@remotion/compositor-win32-x64-msvc": ["@remotion/compositor-win32-x64-msvc@4.0.483", "", { "os": "win32", "cpu": "x64" }, "sha512-eDpWe6Vy7ySHugwxlgaeohqdBwR1etxiymXzT13cgbPw2+p6d55z1tJ7VPTXyfu0o+UdyRTauWqD4msoxN0wow=="],
|
||||
|
||||
"@remotion/licensing": ["@remotion/licensing@4.0.483", "", {}, "sha512-GRtjykrxMmB5Cjpq1oVqeYIRXI95vid6afX0LTLfp1shD6MR4lTZRoRuopxrrZp1E4XFGXoY64b6dlIudgTyBQ=="],
|
||||
|
||||
"@remotion/media-parser": ["@remotion/media-parser@4.0.483", "", {}, "sha512-iva9Eof7QQX+3hGxH/N8pGi80wgdGuzOr825Zn7IuEe7IWdtRYWlFpCEghmyfhrwgaWa7RY51wg77ycR3c3nRg=="],
|
||||
|
||||
"@remotion/media-utils": ["@remotion/media-utils@4.0.483", "", { "dependencies": { "mediabunny": "1.47.0", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4T4WQh/U95kCBzgmz/EL/HL8Zeox/AKAL5ufsehrZFgwlcvH1EsKNJH/RWaAtNPRLF+pskEOgFzPYaczHeI8hg=="],
|
||||
|
||||
"@remotion/player": ["@remotion/player@4.0.483", "", { "dependencies": { "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-yyIQ9vinUqqS0fQeL52bPCWQIr8qDXCqQ4iys8P7dSZncdu8V29p4W2a0/4eKV8bv5+mjnJQ/U6nl9NxBF1hdA=="],
|
||||
|
||||
"@remotion/renderer": ["@remotion/renderer@4.0.483", "", { "dependencies": { "@remotion/licensing": "4.0.483", "@remotion/streaming": "4.0.483", "execa": "5.1.1", "remotion": "4.0.483", "source-map": "0.8.0-beta.0", "ws": "8.21.0" }, "optionalDependencies": { "@remotion/compositor-darwin-arm64": "4.0.483", "@remotion/compositor-darwin-x64": "4.0.483", "@remotion/compositor-linux-arm64-gnu": "4.0.483", "@remotion/compositor-linux-arm64-musl": "4.0.483", "@remotion/compositor-linux-x64-gnu": "4.0.483", "@remotion/compositor-linux-x64-musl": "4.0.483", "@remotion/compositor-win32-x64-msvc": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-eSKdQqm8rcl+GNyOhFsBgcPYWMcxTJWHL5xaNrd037piCaL3sdJ57OEO/AbfEvvOO14zwUvlsONKjlkLlEVyKg=="],
|
||||
|
||||
"@remotion/streaming": ["@remotion/streaming@4.0.483", "", {}, "sha512-96rqrk+l5AfriHOqmqxMn8rI6UJfFY7r5UMDZoSKqXWzTOwj+0VWHTRl9ilZQMXhYACaMSA/5yrL9jgQEK8Yuw=="],
|
||||
|
||||
"@remotion/studio": ["@remotion/studio@4.0.483", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@remotion/canvas-capture": "4.0.483", "@remotion/media-utils": "4.0.483", "@remotion/player": "4.0.483", "@remotion/renderer": "4.0.483", "@remotion/studio-shared": "4.0.483", "@remotion/timeline-utils": "4.0.483", "@remotion/web-renderer": "4.0.483", "@remotion/zod-types": "4.0.483", "mediabunny": "1.47.0", "memfs": "3.4.3", "open": "8.4.2", "remotion": "4.0.483", "semver": "7.5.3", "zod": "4.3.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-gNa1Lw8NmHl0jwFb5fk7rzA+WFTJQpGDT/49A38t9jpEwoF+h1mKTgY8gA202apzjBHrEECdFE450npnBOUpUw=="],
|
||||
|
||||
"@remotion/studio-server": ["@remotion/studio-server@4.0.483", "", { "dependencies": { "@babel/parser": "7.24.1", "@babel/types": "7.24.0", "@remotion/bundler": "4.0.483", "@remotion/renderer": "4.0.483", "@remotion/studio-shared": "4.0.483", "memfs": "3.4.3", "open": "8.4.2", "prettier": "3.8.1", "recast": "0.23.11", "remotion": "4.0.483", "semver": "7.5.3" } }, "sha512-2FSLbVN5N8bgtQPX+RQRbis557tJJnVTOZuEBZndfC9NOoPdARlSgEnwVer9tBNczUlc6B3KrPxyPSlS3UjL+Q=="],
|
||||
|
||||
"@remotion/studio-shared": ["@remotion/studio-shared@4.0.483", "", { "dependencies": { "remotion": "4.0.483" } }, "sha512-aXxBYUgsWgphHVq7T7bz+ICMGzfii7pcX1IxS5hbLMLJHqlDtk9g+qcxm4kJ2Fjj/Gb5gERKiNzYym8WZTIM2Q=="],
|
||||
|
||||
"@remotion/timeline-utils": ["@remotion/timeline-utils@4.0.483", "", { "dependencies": { "mediabunny": "1.47.0" } }, "sha512-KTTMdpNA5uRLX+iisAITO+V9cJBk17F8EWvuUnAEVmAFOQTbGIyPI9HNw5pZmL7SlzOJdFjAtksTjipeYGkEug=="],
|
||||
|
||||
"@remotion/web-renderer": ["@remotion/web-renderer@4.0.483", "", { "dependencies": { "@mediabunny/aac-encoder": "1.47.0", "@mediabunny/flac-encoder": "1.47.0", "@mediabunny/mp3-encoder": "1.47.0", "@remotion/licensing": "4.0.483", "mediabunny": "1.47.0", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-14iURfVWA/hcF/rJvkid1ZR5nmnVhkTIf071FabCFi6kdViOSVBugSglJ8tUPvWVbWmxlXGDGQD9CSU18IATww=="],
|
||||
|
||||
"@remotion/zod-types": ["@remotion/zod-types@4.0.483", "", { "dependencies": { "remotion": "4.0.483" }, "peerDependencies": { "zod": "4.3.6" } }, "sha512-KIVYIzFpZBDB0wxGGQu5tDBr4XOYRzvrPXjmo6O/FI5Xx7O8hgvJmBs9VDkEwonAOft86sg79hI19F1DTX1p4w=="],
|
||||
|
||||
"@rspack/binding": ["@rspack/binding@1.7.11", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "1.7.11", "@rspack/binding-darwin-x64": "1.7.11", "@rspack/binding-linux-arm64-gnu": "1.7.11", "@rspack/binding-linux-arm64-musl": "1.7.11", "@rspack/binding-linux-x64-gnu": "1.7.11", "@rspack/binding-linux-x64-musl": "1.7.11", "@rspack/binding-wasm32-wasi": "1.7.11", "@rspack/binding-win32-arm64-msvc": "1.7.11", "@rspack/binding-win32-ia32-msvc": "1.7.11", "@rspack/binding-win32-x64-msvc": "1.7.11" } }, "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q=="],
|
||||
|
||||
"@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@1.7.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig=="],
|
||||
|
||||
"@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@1.7.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA=="],
|
||||
|
||||
"@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA=="],
|
||||
|
||||
"@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw=="],
|
||||
|
||||
"@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ=="],
|
||||
|
||||
"@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg=="],
|
||||
|
||||
"@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@1.7.11", "", { "dependencies": { "@napi-rs/wasm-runtime": "1.0.7" }, "cpu": "none" }, "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ=="],
|
||||
|
||||
"@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@1.7.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA=="],
|
||||
|
||||
"@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@1.7.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw=="],
|
||||
|
||||
"@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@1.7.11", "", { "os": "win32", "cpu": "x64" }, "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q=="],
|
||||
|
||||
"@rspack/core": ["@rspack/core@1.7.11", "", { "dependencies": { "@module-federation/runtime-tools": "0.22.0", "@rspack/binding": "1.7.11", "@rspack/lite-tapable": "1.1.0" }, "peerDependencies": { "@swc/helpers": ">=0.5.1" }, "optionalPeers": ["@swc/helpers"] }, "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew=="],
|
||||
|
||||
"@rspack/lite-tapable": ["@rspack/lite-tapable@1.1.0", "", {}, "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw=="],
|
||||
|
||||
"@rspack/plugin-react-refresh": ["@rspack/plugin-react-refresh@1.6.1", "", { "dependencies": { "error-stack-parser": "^2.1.4", "html-entities": "^2.6.0" }, "peerDependencies": { "react-refresh": ">=0.10.0 <1.0.0", "webpack-hot-middleware": "2.x" }, "optionalPeers": ["webpack-hot-middleware"] }, "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.11", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ=="],
|
||||
|
||||
"@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="],
|
||||
|
||||
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
|
||||
|
||||
"@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="],
|
||||
|
||||
"@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="],
|
||||
|
||||
"@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="],
|
||||
|
||||
"@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="],
|
||||
|
||||
"@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="],
|
||||
|
||||
"@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="],
|
||||
|
||||
"@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="],
|
||||
|
||||
"@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="],
|
||||
|
||||
"@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="],
|
||||
|
||||
"@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="],
|
||||
|
||||
"@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="],
|
||||
|
||||
"@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="],
|
||||
|
||||
"@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="],
|
||||
|
||||
"@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="],
|
||||
|
||||
"@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="],
|
||||
|
||||
"@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="],
|
||||
|
||||
"@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],
|
||||
|
||||
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
|
||||
|
||||
"acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="],
|
||||
|
||||
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
|
||||
|
||||
"ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="],
|
||||
|
||||
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.40", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="],
|
||||
|
||||
"chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="],
|
||||
|
||||
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-loader": ["css-loader@7.1.4", "", { "dependencies": { "icss-utils": "^5.1.0", "postcss": "^8.4.40", "postcss-modules-extract-imports": "^3.1.0", "postcss-modules-local-by-default": "^4.0.5", "postcss-modules-scope": "^3.2.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", "semver": "^7.6.3" }, "peerDependencies": { "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", "webpack": "^5.27.0" }, "optionalPeers": ["@rspack/core", "webpack"] }, "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
|
||||
|
||||
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.379", "", {}, "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.24.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw=="],
|
||||
|
||||
"error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],
|
||||
|
||||
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
||||
|
||||
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
||||
|
||||
"fs-monkey": ["fs-monkey@1.0.3", "", {}, "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q=="],
|
||||
|
||||
"get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
|
||||
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="],
|
||||
|
||||
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||
|
||||
"icss-utils": ["icss-utils@5.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA=="],
|
||||
|
||||
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="],
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
|
||||
|
||||
"loader-runner": ["loader-runner@4.3.2", "", {}, "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w=="],
|
||||
|
||||
"lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="],
|
||||
|
||||
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"mediabunny": ["mediabunny@1.47.0", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-XQMZAcaKPkJ7hQ/Q2fvBdl3ZazQl2WVxDysUbJWh4PuAnLoerdsQBdPTDWdUdK6hh26LQ8Ue94MLLnmpWvlUYg=="],
|
||||
|
||||
"memfs": ["memfs@3.4.3", "", { "dependencies": { "fs-monkey": "1.0.3" } }, "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg=="],
|
||||
|
||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"minimist": ["minimist@1.2.6", "", {}, "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="],
|
||||
|
||||
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="],
|
||||
|
||||
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
|
||||
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
||||
|
||||
"postcss-modules-extract-imports": ["postcss-modules-extract-imports@3.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q=="],
|
||||
|
||||
"postcss-modules-local-by-default": ["postcss-modules-local-by-default@4.2.0", "", { "dependencies": { "icss-utils": "^5.0.0", "postcss-selector-parser": "^7.0.0", "postcss-value-parser": "^4.1.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw=="],
|
||||
|
||||
"postcss-modules-scope": ["postcss-modules-scope@3.2.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA=="],
|
||||
|
||||
"postcss-modules-values": ["postcss-modules-values@4.0.0", "", { "dependencies": { "icss-utils": "^5.0.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ=="],
|
||||
|
||||
"postcss-selector-parser": ["postcss-selector-parser@7.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg=="],
|
||||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
||||
|
||||
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
||||
|
||||
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
|
||||
|
||||
"remotion": ["remotion@4.0.483", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-lf4xq4Twn75TQeTavFLrTE4zdck7EKBSx07ZrPv4om40Sist+3G0kq7NNzSjMeJ6G2Wx6vI+0oICD/vv4sGWtg=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
|
||||
|
||||
"semver": ["semver@7.5.3", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
|
||||
"source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="],
|
||||
|
||||
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
|
||||
"style-loader": ["style-loader@4.0.0", "", { "peerDependencies": { "webpack": "^5.27.0" } }, "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA=="],
|
||||
|
||||
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"terser": ["terser@5.48.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="],
|
||||
|
||||
"terser-webpack-plugin": ["terser-webpack-plugin@5.6.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"to-fast-properties": ["to-fast-properties@2.0.0", "", {}, "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog=="],
|
||||
|
||||
"tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"watchpack": ["watchpack@2.5.2", "", { "dependencies": { "graceful-fs": "^4.1.2" } }, "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
|
||||
|
||||
"webpack": ["webpack@5.105.0", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.19.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.5.1", "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw=="],
|
||||
|
||||
"webpack-sources": ["webpack-sources@3.5.0", "", {}, "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"css-loader/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "glm52-rise-video",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"render": "remotion render src/index.tsx GLM52Rise out/glm-52-broke-out.mp4 --codec h264 --pixel-format yuv420p",
|
||||
"render:sheep": "remotion render src/index.tsx NZSheep out/nz-sheep.mp4 --codec h264 --pixel-format yuv420p",
|
||||
"render:novel": "remotion render src/index.tsx NovelTokens out/novel-1984.mp4 --codec h264 --pixel-format yuv420p",
|
||||
"render:flash": "remotion render src/index.tsx FlashShare out/flash-share.mp4 --codec h264 --pixel-format yuv420p",
|
||||
"render:minimax": "remotion render src/index.tsx MiniMaxClimb out/minimax-climb.mp4 --codec h264 --pixel-format yuv420p",
|
||||
"still:june": "remotion still src/index.tsx JuneTotals out/june-totals.png --frame=0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotion/cli": "^4.0.384",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"remotion": "^4.0.384"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
@@ -0,0 +1,156 @@
|
||||
// Verified against the production opencode-stats PlanetScale DB.
|
||||
// Scope: tier='Go' (OpenCode Go), dataset='zen', client='all', source='all', grain='day'.
|
||||
// metric = total_tokens. Daily token volume per model; GLM-5.2 (zhipu) launched Jun 17.
|
||||
// Segments per day (tokens): glm-5.2, deepseek-v4-flash, deepseek-v4-pro, minimax-m3, all others.
|
||||
|
||||
export type Day = {
|
||||
date: string
|
||||
total: number
|
||||
glm: number
|
||||
dsf: number
|
||||
dsp: number
|
||||
mm: number
|
||||
others: number
|
||||
}
|
||||
|
||||
export const days: Day[] = [
|
||||
{
|
||||
date: "Jun 12",
|
||||
total: 2_283_799_449_383,
|
||||
glm: 0,
|
||||
dsf: 1_176_701_653_509,
|
||||
dsp: 569_527_034_307,
|
||||
mm: 159_016_250_684,
|
||||
others: 378_554_510_883,
|
||||
},
|
||||
{
|
||||
date: "Jun 13",
|
||||
total: 2_008_462_388_420,
|
||||
glm: 0,
|
||||
dsf: 995_338_131_997,
|
||||
dsp: 445_817_536_548,
|
||||
mm: 211_743_241_967,
|
||||
others: 355_563_477_908,
|
||||
},
|
||||
{
|
||||
date: "Jun 14",
|
||||
total: 2_007_785_405_251,
|
||||
glm: 0,
|
||||
dsf: 983_954_176_228,
|
||||
dsp: 428_151_999_341,
|
||||
mm: 262_476_527_930,
|
||||
others: 333_202_701_752,
|
||||
},
|
||||
{
|
||||
date: "Jun 15",
|
||||
total: 2_694_736_103_062,
|
||||
glm: 0,
|
||||
dsf: 1_255_893_953_859,
|
||||
dsp: 632_223_338_376,
|
||||
mm: 352_507_442_991,
|
||||
others: 454_111_367_836,
|
||||
},
|
||||
{
|
||||
date: "Jun 16",
|
||||
total: 2_838_153_758_908,
|
||||
glm: 0,
|
||||
dsf: 1_336_625_283_800,
|
||||
dsp: 676_480_415_730,
|
||||
mm: 305_268_829_013,
|
||||
others: 519_779_230_365,
|
||||
},
|
||||
{
|
||||
date: "Jun 17",
|
||||
total: 2_778_964_711_109,
|
||||
glm: 70_095_977_043,
|
||||
dsf: 1_339_831_523_555,
|
||||
dsp: 660_414_395_220,
|
||||
mm: 251_302_096_157,
|
||||
others: 457_320_719_134,
|
||||
},
|
||||
{
|
||||
date: "Jun 18",
|
||||
total: 2_806_992_430_656,
|
||||
glm: 201_130_231_172,
|
||||
dsf: 1_295_599_996_869,
|
||||
dsp: 595_665_008_776,
|
||||
mm: 322_205_104_324,
|
||||
others: 392_392_089_515,
|
||||
},
|
||||
{
|
||||
date: "Jun 19",
|
||||
total: 2_419_611_630_232,
|
||||
glm: 199_086_413_910,
|
||||
dsf: 1_115_750_468_802,
|
||||
dsp: 475_965_869_304,
|
||||
mm: 303_586_698_735,
|
||||
others: 325_222_179_481,
|
||||
},
|
||||
{
|
||||
date: "Jun 20",
|
||||
total: 2_188_278_916_865,
|
||||
glm: 193_931_516_396,
|
||||
dsf: 1_050_194_681_012,
|
||||
dsp: 395_303_435_278,
|
||||
mm: 281_998_000_337,
|
||||
others: 266_851_283_842,
|
||||
},
|
||||
{
|
||||
date: "Jun 21",
|
||||
total: 2_042_309_961_344,
|
||||
glm: 181_894_043_118,
|
||||
dsf: 985_164_570_580,
|
||||
dsp: 368_194_079_542,
|
||||
mm: 259_812_551_324,
|
||||
others: 247_244_716_780,
|
||||
},
|
||||
{
|
||||
date: "Jun 22",
|
||||
total: 2_893_934_325_663,
|
||||
glm: 301_759_048_475,
|
||||
dsf: 1_298_124_282_989,
|
||||
dsp: 581_012_596_194,
|
||||
mm: 371_581_117_839,
|
||||
others: 341_457_280_166,
|
||||
},
|
||||
{
|
||||
date: "Jun 23",
|
||||
total: 3_109_009_321_480,
|
||||
glm: 282_277_235_158,
|
||||
dsf: 1_423_571_678_821,
|
||||
dsp: 627_374_654_587,
|
||||
mm: 429_416_300_508,
|
||||
others: 346_369_452_406,
|
||||
},
|
||||
{
|
||||
date: "Jun 24",
|
||||
total: 2_939_149_971_595,
|
||||
glm: 256_497_442_533,
|
||||
dsf: 1_373_583_023_234,
|
||||
dsp: 601_270_997_775,
|
||||
mm: 391_586_493_231,
|
||||
others: 316_212_014_822,
|
||||
},
|
||||
{
|
||||
date: "Jun 25",
|
||||
total: 3_029_641_552_948,
|
||||
glm: 256_279_657_734,
|
||||
dsf: 1_481_084_002_776,
|
||||
dsp: 602_077_167_287,
|
||||
mm: 375_985_302_874,
|
||||
others: 314_215_422_277,
|
||||
},
|
||||
]
|
||||
|
||||
export const launchIndex = 5 // Jun 17, first day of GLM-5.2 usage
|
||||
// GLM-5.2 weekly token volume, Jun 19-25 (sum of glm): 1,671,725,357,324 = 1.67T
|
||||
export const glmWeekTokensT = 1.672
|
||||
|
||||
// stacked segments, bottom -> top. GLM-5.2 is the hero (blue); the rest are the field it cut into.
|
||||
export const segments = [
|
||||
{ key: "glm", label: "GLM-5.2", color: "#3b5cf6", hero: true },
|
||||
{ key: "dsf", label: "deepseek-v4-flash", color: "#9ca3ad" },
|
||||
{ key: "dsp", label: "deepseek-v4-pro", color: "#b3b9c1" },
|
||||
{ key: "mm", label: "minimax-m3", color: "#c8cdd3" },
|
||||
{ key: "others", label: "other models", color: "#dde0e4" },
|
||||
] as const
|
||||
@@ -0,0 +1,185 @@
|
||||
import React from "react"
|
||||
import { AbsoluteFill, Easing, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"
|
||||
|
||||
// stats.opencode.ai design tokens (light theme)
|
||||
const c = {
|
||||
bg: "#ffffff",
|
||||
ink: "#161616",
|
||||
muted: "#5c5c5c",
|
||||
faint: "#808080",
|
||||
line: "#e6e6e6",
|
||||
dot: "#e4e4e4",
|
||||
gray: "#aab0b8",
|
||||
accent: "#3b5cf6",
|
||||
accentHi: "#5b78ff",
|
||||
}
|
||||
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
|
||||
|
||||
const DOT_MASK =
|
||||
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")"
|
||||
|
||||
// verified: OpenCode Go, week of Jun 22-28, 2026 (2026-W26). share = % of 19.64T total Go tokens.
|
||||
const bars = [
|
||||
{ label: "deepseek-v4-flash", share: 48.3, hero: true },
|
||||
{ label: "deepseek-v4-pro", share: 19.4 },
|
||||
{ label: "minimax-m3", share: 13.0 },
|
||||
{ label: "glm-5.2", share: 8.3 },
|
||||
{ label: "mimo-v2.5", share: 4.3 },
|
||||
{ label: "kimi-k2.7-code", share: 2.6 },
|
||||
{ label: "other models", share: 4.1 },
|
||||
]
|
||||
|
||||
function DataWordmark({ height = 30 }: { height?: number }) {
|
||||
return (
|
||||
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.ink }}>
|
||||
<path opacity="0.2" d="M12 16H4V8H12V16Z" fill="currentColor" />
|
||||
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
|
||||
<path
|
||||
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
|
||||
<path
|
||||
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function FlashShare() {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
const grow = (i: number) =>
|
||||
Math.min(
|
||||
1,
|
||||
Math.max(0, spring({ frame: frame - 18 - i * 7, fps, config: { damping: 18, stiffness: 120, mass: 0.6 } })),
|
||||
)
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: c.bg, color: c.ink, fontFamily: MONO, padding: 72, boxSizing: "border-box" }}>
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{/* header */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<DataWordmark height={30} />
|
||||
<div style={{ fontSize: 20, fontWeight: 500, color: c.faint, letterSpacing: 1 }}>JUN 22–28, 2026</div>
|
||||
</div>
|
||||
|
||||
{/* headline (static) */}
|
||||
<div style={{ marginTop: 50 }}>
|
||||
<div style={{ fontSize: 23, fontWeight: 600, color: c.muted, letterSpacing: 2 }}>
|
||||
OPENCODE GO · SHARE OF TOKENS
|
||||
</div>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 24, marginTop: 14 }}
|
||||
>
|
||||
<div style={{ fontSize: 62, fontWeight: 600, letterSpacing: -2, lineHeight: 1 }}>DeepSeek V4 Flash</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 88,
|
||||
fontWeight: 600,
|
||||
letterSpacing: -2,
|
||||
lineHeight: 1,
|
||||
color: c.accent,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
48%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* bar chart */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", gap: 16 }}>
|
||||
{bars.map((b, i) => {
|
||||
const g = grow(i)
|
||||
const pct = b.share * g
|
||||
return (
|
||||
<div key={b.label} style={{ display: "flex", alignItems: "center", gap: 18 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 268,
|
||||
fontSize: 23,
|
||||
fontWeight: b.hero ? 600 : 500,
|
||||
color: b.hero ? c.ink : c.muted,
|
||||
textAlign: "right",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{b.label}
|
||||
</div>
|
||||
<div style={{ position: "relative", flex: 1, height: 48 }}>
|
||||
{/* dotted 100% track — height is a multiple of the 12px tile, anchored bottom, so dots never clip */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: c.dot,
|
||||
WebkitMaskImage: DOT_MASK,
|
||||
maskImage: DOT_MASK,
|
||||
WebkitMaskSize: "12px 12px",
|
||||
maskSize: "12px 12px",
|
||||
WebkitMaskRepeat: "repeat",
|
||||
maskRepeat: "repeat",
|
||||
WebkitMaskPosition: "left bottom",
|
||||
maskPosition: "left bottom",
|
||||
}}
|
||||
/>
|
||||
{/* fill */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: `${pct}%`,
|
||||
background: b.hero ? c.accent : c.gray,
|
||||
borderRight: b.hero ? `2px solid ${c.accentHi}` : "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: 84,
|
||||
fontSize: 24,
|
||||
fontWeight: b.hero ? 600 : 500,
|
||||
color: b.hero ? c.accent : c.muted,
|
||||
textAlign: "right",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{Math.round(pct)}%
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* footer */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginTop: 24,
|
||||
paddingTop: 22,
|
||||
borderTop: `1px solid ${c.line}`,
|
||||
fontSize: 20,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: 9, color: c.muted }}>
|
||||
<span style={{ width: 13, height: 13, background: c.accent, display: "inline-block" }} />
|
||||
DeepSeek V4 Flash · 9.48T tokens · 83.6M requests
|
||||
</div>
|
||||
<div style={{ color: c.ink }}>opencode.ai/data</div>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Composition, registerRoot } from "remotion"
|
||||
import { GLM52Rise } from "./video"
|
||||
import { NZSheep } from "./sheep"
|
||||
import { NovelTokens } from "./novel"
|
||||
import { FlashShare } from "./flash"
|
||||
import { MiniMaxClimb } from "./minimax"
|
||||
import { JuneTotals } from "./june"
|
||||
|
||||
function Root() {
|
||||
return (
|
||||
<>
|
||||
<Composition id="GLM52Rise" component={GLM52Rise} durationInFrames={240} fps={30} width={1080} height={1080} />
|
||||
<Composition id="NZSheep" component={NZSheep} durationInFrames={150} fps={30} width={1080} height={1080} />
|
||||
<Composition
|
||||
id="NovelTokens"
|
||||
component={NovelTokens}
|
||||
durationInFrames={150}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1080}
|
||||
/>
|
||||
<Composition id="FlashShare" component={FlashShare} durationInFrames={165} fps={30} width={1080} height={1080} />
|
||||
<Composition
|
||||
id="MiniMaxClimb"
|
||||
component={MiniMaxClimb}
|
||||
durationInFrames={165}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1080}
|
||||
/>
|
||||
<Composition id="JuneTotals" component={JuneTotals} durationInFrames={1} fps={30} width={1080} height={1080} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
registerRoot(Root)
|
||||
@@ -0,0 +1,144 @@
|
||||
import React from "react"
|
||||
import { AbsoluteFill } from "remotion"
|
||||
|
||||
const c = {
|
||||
bg: "#ffffff",
|
||||
ink: "#161616",
|
||||
muted: "#5c5c5c",
|
||||
faint: "#808080",
|
||||
line: "#e6e6e6",
|
||||
dot: "#dcdcdc",
|
||||
accent: "#3b5cf6",
|
||||
}
|
||||
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
|
||||
|
||||
const DOT_MASK =
|
||||
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")"
|
||||
|
||||
// verified: OpenCode Go (tier=Go, dataset=zen), June 1-30, 2026.
|
||||
// 72.78T tokens · 651.4M requests · 11.42M sessions -> rounded headline figures.
|
||||
|
||||
function DataWordmark({ height = 30 }: { height?: number }) {
|
||||
return (
|
||||
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.ink }}>
|
||||
<path opacity="0.2" d="M12 16H4V8H12V16Z" fill="currentColor" />
|
||||
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
|
||||
<path
|
||||
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
|
||||
<path
|
||||
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function DotBand() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 12,
|
||||
background: c.dot,
|
||||
WebkitMaskImage: DOT_MASK,
|
||||
maskImage: DOT_MASK,
|
||||
WebkitMaskSize: "12px 12px",
|
||||
maskSize: "12px 12px",
|
||||
WebkitMaskRepeat: "repeat",
|
||||
maskRepeat: "repeat",
|
||||
WebkitMaskPosition: "left top",
|
||||
maskPosition: "left top",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ value, label }: { value: string; label: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 92,
|
||||
fontWeight: 600,
|
||||
letterSpacing: -3,
|
||||
lineHeight: 1,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
marginLeft: -8, // align the glyph's visual left edge to the column
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
<div style={{ marginTop: 16, fontSize: 24, fontWeight: 500, color: c.muted, letterSpacing: 1 }}>{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function JuneTotals() {
|
||||
return (
|
||||
<AbsoluteFill style={{ background: c.bg, color: c.ink, fontFamily: MONO, padding: 72, boxSizing: "border-box" }}>
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{/* header */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 22 }}>
|
||||
<DataWordmark height={30} />
|
||||
<div style={{ fontSize: 20, fontWeight: 500, color: c.faint, letterSpacing: 1 }}>MONTHLY RECAP</div>
|
||||
</div>
|
||||
<DotBand />
|
||||
|
||||
{/* hero */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
||||
<div style={{ fontSize: 25, fontWeight: 600, color: c.muted, letterSpacing: 3 }}>OPENCODE GO · JUNE 2026</div>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 300,
|
||||
fontWeight: 600,
|
||||
letterSpacing: -10,
|
||||
lineHeight: 0.82,
|
||||
color: c.accent,
|
||||
marginLeft: -22,
|
||||
}}
|
||||
>
|
||||
73T
|
||||
</div>
|
||||
<div style={{ fontSize: 50, fontWeight: 600, color: c.ink, marginTop: 10 }}>tokens processed</div>
|
||||
</div>
|
||||
|
||||
{/* supporting metrics */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
marginTop: 60,
|
||||
paddingTop: 40,
|
||||
borderTop: `1px solid ${c.line}`,
|
||||
}}
|
||||
>
|
||||
<Metric value="650M" label="requests" />
|
||||
<Metric value="11M" label="sessions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* footer */}
|
||||
<DotBand />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
marginTop: 22,
|
||||
fontSize: 20,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<div style={{ color: c.ink }}>opencode.ai/data</div>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import React from "react"
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"
|
||||
|
||||
const c = {
|
||||
bg: "#ffffff",
|
||||
ink: "#161616",
|
||||
muted: "#5c5c5c",
|
||||
faint: "#808080",
|
||||
line: "#e6e6e6",
|
||||
dot: "#e4e4e4",
|
||||
accent: "#3b5cf6",
|
||||
accentHi: "#5b78ff",
|
||||
accentDim: "#aebcf3",
|
||||
}
|
||||
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
|
||||
|
||||
const DOT_MASK =
|
||||
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")"
|
||||
|
||||
// verified: minimax-m3, OpenCode Go (tier=Go, dataset=zen), weekly total_tokens.
|
||||
// W26 2.559T is +23.2% / +482.3B vs W25 2.077T.
|
||||
const weeks = [
|
||||
{ label: "May 25", t: 0.008 },
|
||||
{ label: "Jun 1", t: 0.429 },
|
||||
{ label: "Jun 8", t: 1.192 },
|
||||
{ label: "Jun 15", t: 2.077 },
|
||||
{ label: "Jun 22", t: 2.559, latest: true },
|
||||
]
|
||||
const AXIS_MAX = 3.0
|
||||
|
||||
function DataWordmark({ height = 30 }: { height?: number }) {
|
||||
return (
|
||||
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.ink }}>
|
||||
<path opacity="0.2" d="M12 16H4V8H12V16Z" fill="currentColor" />
|
||||
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
|
||||
<path
|
||||
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
|
||||
<path
|
||||
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const CHART_H = 420 // multiple of 12 so the dotted tracks never clip
|
||||
|
||||
export function MiniMaxClimb() {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
const grow = (i: number) =>
|
||||
Math.min(
|
||||
1,
|
||||
Math.max(0, spring({ frame: frame - 22 - i * 9, fps, config: { damping: 18, stiffness: 110, mass: 0.6 } })),
|
||||
)
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: c.bg, color: c.ink, fontFamily: MONO, padding: 72, boxSizing: "border-box" }}>
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{/* header */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<DataWordmark height={30} />
|
||||
<div style={{ fontSize: 20, fontWeight: 500, color: c.faint, letterSpacing: 1 }}>JUN 22–28, 2026</div>
|
||||
</div>
|
||||
|
||||
{/* headline (static) */}
|
||||
<div style={{ marginTop: 50 }}>
|
||||
<div style={{ fontSize: 23, fontWeight: 600, color: c.muted, letterSpacing: 2 }}>
|
||||
OPENCODE GO · WEEKLY TOKENS
|
||||
</div>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 24, marginTop: 14 }}
|
||||
>
|
||||
<div style={{ fontSize: 62, fontWeight: 600, letterSpacing: -2, lineHeight: 1 }}>MiniMax M3</div>
|
||||
<div style={{ textAlign: "right", flexShrink: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 84,
|
||||
fontWeight: 600,
|
||||
letterSpacing: -2,
|
||||
lineHeight: 1,
|
||||
color: c.accent,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
+23.2%
|
||||
</div>
|
||||
<div style={{ fontSize: 19, fontWeight: 500, color: c.muted, letterSpacing: 1, marginTop: 6 }}>
|
||||
WEEK OVER WEEK
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* column chart */}
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "flex-end", marginTop: 30 }}>
|
||||
<div style={{ width: "100%", display: "flex", alignItems: "flex-end", gap: 30 }}>
|
||||
{weeks.map((w, i) => {
|
||||
const g = grow(i)
|
||||
const h = Math.round((w.t / AXIS_MAX) * CHART_H * g)
|
||||
return (
|
||||
<div key={w.label} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||
{/* value + bar */}
|
||||
<div style={{ position: "relative", width: "100%", height: CHART_H }}>
|
||||
{/* dotted track */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: c.dot,
|
||||
WebkitMaskImage: DOT_MASK,
|
||||
maskImage: DOT_MASK,
|
||||
WebkitMaskSize: "12px 12px",
|
||||
maskSize: "12px 12px",
|
||||
WebkitMaskRepeat: "repeat",
|
||||
maskRepeat: "repeat",
|
||||
WebkitMaskPosition: "left bottom",
|
||||
maskPosition: "left bottom",
|
||||
}}
|
||||
/>
|
||||
{/* fill */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: h,
|
||||
background: w.latest ? c.accent : c.accentDim,
|
||||
borderTop: w.latest ? `3px solid ${c.accentHi}` : "none",
|
||||
}}
|
||||
/>
|
||||
{/* value label */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: h + 10,
|
||||
textAlign: "center",
|
||||
fontSize: 26,
|
||||
fontWeight: w.latest ? 600 : 500,
|
||||
color: w.latest ? c.accent : c.muted,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
opacity: interpolate(g, [0.5, 1], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{w.t.toFixed(2)}T
|
||||
</div>
|
||||
</div>
|
||||
{/* week label */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
fontSize: 18,
|
||||
fontWeight: 500,
|
||||
color: w.latest ? c.ink : c.faint,
|
||||
}}
|
||||
>
|
||||
{w.label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* footer */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginTop: 22,
|
||||
paddingTop: 22,
|
||||
borderTop: `1px solid ${c.line}`,
|
||||
fontSize: 20,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: 9, color: c.muted }}>
|
||||
<span style={{ width: 13, height: 13, background: c.accent, display: "inline-block" }} />
|
||||
2.56T tokens last week · +482.3B added
|
||||
</div>
|
||||
<div style={{ color: c.ink }}>opencode.ai/data</div>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import React from "react"
|
||||
import { AbsoluteFill, Easing, Img, interpolate, staticFile, useCurrentFrame } from "remotion"
|
||||
|
||||
const c = {
|
||||
white: "#ffffff",
|
||||
dim: "rgba(255,255,255,0.74)",
|
||||
}
|
||||
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
|
||||
|
||||
// verified: OpenCode Go, week of Jun 22-28, 2026 (2026-W26)
|
||||
// 19,642,742,937,105 tokens / 173,651,197 requests = 113,116 tokens/request
|
||||
const AVG = 113116
|
||||
const K = Math.round(AVG / 1000) // 113
|
||||
|
||||
const nf = new Intl.NumberFormat("en-US")
|
||||
|
||||
function DataWordmark({ height = 30 }: { height?: number }) {
|
||||
return (
|
||||
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.white }}>
|
||||
<path opacity="0.35" d="M12 16H4V8H12V16Z" fill="currentColor" />
|
||||
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
|
||||
<path
|
||||
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
|
||||
<path
|
||||
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function NovelTokens() {
|
||||
const frame = useCurrentFrame()
|
||||
|
||||
const k = Math.round(
|
||||
K *
|
||||
interpolate(frame, [18, 92], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.out(Easing.cubic),
|
||||
}),
|
||||
)
|
||||
|
||||
const zoom = interpolate(frame, [0, 150], [1.06, 1.12], {
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
})
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: "#0c0c0c", overflow: "hidden" }}>
|
||||
<Img
|
||||
src={staticFile("book.jpg")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
objectPosition: "center 34%",
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: "center 30%",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* legibility scrims */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background:
|
||||
"linear-gradient(to bottom, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 20%, rgba(0,0,0,0.1) 44%, rgba(0,0,0,0.86) 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
boxSizing: "border-box",
|
||||
padding: 64,
|
||||
color: c.white,
|
||||
fontFamily: MONO,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
{/* header */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<DataWordmark height={30} />
|
||||
<div style={{ fontSize: 20, fontWeight: 500, color: c.dim, letterSpacing: 1 }}>JUN 22–28, 2026</div>
|
||||
</div>
|
||||
|
||||
{/* bottom block */}
|
||||
<div>
|
||||
<div style={{ fontSize: 23, fontWeight: 600, color: c.dim, letterSpacing: 2, marginBottom: 8 }}>
|
||||
OPENCODE GO · LAST WEEK
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 168,
|
||||
fontWeight: 600,
|
||||
lineHeight: 0.92,
|
||||
letterSpacing: -5,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{k}K
|
||||
</div>
|
||||
<div style={{ fontSize: 50, fontWeight: 600, letterSpacing: -1, marginTop: 4 }}>tokens per request</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginTop: 30,
|
||||
fontSize: 20,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<div style={{ color: c.dim }}>{nf.format(AVG)} tokens / request · last week</div>
|
||||
<div style={{ color: c.white }}>opencode.ai/data</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import React from "react"
|
||||
import { AbsoluteFill, Easing, Img, interpolate, staticFile, useCurrentFrame } from "remotion"
|
||||
|
||||
const c = {
|
||||
white: "#ffffff",
|
||||
dim: "rgba(255,255,255,0.72)",
|
||||
faint: "rgba(255,255,255,0.55)",
|
||||
}
|
||||
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
|
||||
|
||||
// verified: NZ OpenCode Go, week of Jun 22-28, 2026 (2026-W26)
|
||||
const TOKENS = 40_915_594_381 // 40.9B
|
||||
const SHEEP = 23_600_000 // 23.6M
|
||||
const PER_SHEEP = Math.round(TOKENS / SHEEP) // 1,734
|
||||
const nf = new Intl.NumberFormat("en-US")
|
||||
|
||||
// the correct opencode "DATA" wordmark (white, over photo)
|
||||
function DataWordmark({ height = 30 }: { height?: number }) {
|
||||
return (
|
||||
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.white }}>
|
||||
<path opacity="0.35" d="M12 16H4V8H12V16Z" fill="currentColor" />
|
||||
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
|
||||
<path
|
||||
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
|
||||
<path
|
||||
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function NZSheep() {
|
||||
const frame = useCurrentFrame()
|
||||
|
||||
const count = Math.round(
|
||||
PER_SHEEP *
|
||||
interpolate(frame, [18, 90], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.out(Easing.cubic),
|
||||
}),
|
||||
)
|
||||
|
||||
// slow Ken Burns push-in (scale up only — never reveals an edge)
|
||||
const zoom = interpolate(frame, [0, 150], [1.06, 1.12], {
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
})
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: "#0c0c0c", overflow: "hidden" }}>
|
||||
{/* the sheep, staring */}
|
||||
<Img
|
||||
src={staticFile("sheep.jpg")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
objectPosition: "center 38%",
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: "center 35%",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* legibility scrims */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background:
|
||||
"linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0) 22%, rgba(0,0,0,0) 48%, rgba(0,0,0,0.78) 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* content */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
boxSizing: "border-box",
|
||||
padding: 64,
|
||||
color: c.white,
|
||||
fontFamily: MONO,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
{/* header */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<DataWordmark height={30} />
|
||||
<div style={{ fontSize: 20, fontWeight: 500, color: c.dim, letterSpacing: 1 }}>JUN 22–28, 2026</div>
|
||||
</div>
|
||||
|
||||
{/* bottom block */}
|
||||
<div>
|
||||
<div style={{ fontSize: 23, fontWeight: 600, color: c.dim, letterSpacing: 2, marginBottom: 8 }}>
|
||||
OPENCODE GO · NEW ZEALAND
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 168,
|
||||
fontWeight: 600,
|
||||
lineHeight: 0.92,
|
||||
letterSpacing: -5,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{nf.format(count)}
|
||||
</div>
|
||||
<div style={{ fontSize: 50, fontWeight: 600, letterSpacing: -1, marginTop: 4 }}>tokens per sheep</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginTop: 34,
|
||||
fontSize: 20,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<div style={{ color: c.dim }}>40.9B tokens ÷ 23.6M sheep · last week</div>
|
||||
<div style={{ color: c.white }}>opencode.ai/data</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import React from "react"
|
||||
import { AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion"
|
||||
import { days, launchIndex, glmWeekTokensT, segments } from "./data"
|
||||
|
||||
// stats.opencode.ai design tokens (light theme)
|
||||
const c = {
|
||||
bg: "#ffffff",
|
||||
ink: "#161616",
|
||||
muted: "#5c5c5c",
|
||||
faint: "#808080",
|
||||
line: "#e6e6e6",
|
||||
dot: "#ededed",
|
||||
accent: "#3b5cf6",
|
||||
accentHi: "#5b78ff",
|
||||
}
|
||||
|
||||
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
|
||||
const W = 1080
|
||||
|
||||
const DOT_MASK =
|
||||
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")"
|
||||
|
||||
const field = segments.filter((s) => !s.hero)
|
||||
const glmColor = segments.find((s) => s.hero)!.color
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
|
||||
|
||||
// the correct opencode "DATA" wordmark (from stats.opencode.ai header)
|
||||
function DataWordmark({ height = 30, color = c.ink }: { height?: number; color?: string }) {
|
||||
return (
|
||||
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color }}>
|
||||
<path opacity="0.2" d="M12 16H4V8H12V16Z" fill="currentColor" />
|
||||
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
|
||||
<path
|
||||
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
|
||||
<path
|
||||
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GLM52Rise() {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
// ---------- virtual camera ----------
|
||||
// open zoomed-in on the field, pan right while the blue fills, then pull back to reveal.
|
||||
const K = [0, 32, 150, 206, 240]
|
||||
const ease = Easing.inOut(Easing.cubic)
|
||||
const opt = { extrapolateLeft: "clamp" as const, extrapolateRight: "clamp" as const, easing: ease }
|
||||
const s = interpolate(frame, K, [1.82, 1.72, 1.72, 1.0, 1.0], opt)
|
||||
let fx = interpolate(frame, K, [420, 438, 760, 540, 540], opt)
|
||||
let fy = interpolate(frame, K, [664, 664, 664, 540, 540], opt)
|
||||
// keep the framing inside the 1080 canvas so edges never reveal black
|
||||
fx = clamp(fx, 540 / s, W - 540 / s)
|
||||
fy = clamp(fy, 540 / s, W - 540 / s)
|
||||
const camera = `translate(${540 - fx * s}px, ${540 - fy * s}px) scale(${s})`
|
||||
|
||||
// ---------- blue sweep (synced to the pan) ----------
|
||||
const p = interpolate(frame, [32, 150], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease })
|
||||
const revealAmount = p * (days.length - launchIndex) + 0.35
|
||||
const fillOf = (i: number) => clamp(revealAmount - (i - launchIndex), 0, 1)
|
||||
|
||||
// token number climbs as the camera pulls back and the headline re-enters frame
|
||||
const tokensT =
|
||||
glmWeekTokensT *
|
||||
interpolate(frame, [150, 202], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
|
||||
// chart geometry
|
||||
const chartH = 440
|
||||
const chartW = 936
|
||||
const gap = 14
|
||||
const EXAGGERATE = 2.876 // broken y-axis: GLM-5.2 magnified ~2.9x for emphasis
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: c.bg, overflow: "hidden" }}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: W,
|
||||
height: W,
|
||||
background: c.bg,
|
||||
transformOrigin: "0 0",
|
||||
transform: camera,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: W,
|
||||
height: W,
|
||||
boxSizing: "border-box",
|
||||
padding: 72,
|
||||
color: c.ink,
|
||||
fontFamily: MONO,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* header */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<DataWordmark height={30} />
|
||||
<div style={{ fontSize: 20, fontWeight: 500, color: c.faint, letterSpacing: 1 }}>JUN 12–25, 2026</div>
|
||||
</div>
|
||||
|
||||
{/* headline (static) */}
|
||||
<div style={{ marginTop: 52 }}>
|
||||
<div style={{ fontSize: 92, fontWeight: 600, lineHeight: 0.98, letterSpacing: -2 }}>
|
||||
GLM-5.2 <span style={{ color: c.accent }}>broke out</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 22, fontSize: 30, fontWeight: 500, color: c.muted }}>
|
||||
From 0 to <span style={{ color: c.ink }}>{tokensT.toFixed(2)}T tokens</span> in a week.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* stacked chart */}
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "flex-end", marginTop: 40 }}>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: chartW,
|
||||
height: chartH,
|
||||
margin: "0 auto",
|
||||
borderBottom: `2px solid ${c.ink}`,
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
{/* faint dotted backdrop */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: -8,
|
||||
right: -8,
|
||||
top: -8,
|
||||
bottom: 0,
|
||||
background: c.dot,
|
||||
WebkitMaskImage: DOT_MASK,
|
||||
maskImage: DOT_MASK,
|
||||
WebkitMaskSize: "12px 12px",
|
||||
maskSize: "12px 12px",
|
||||
WebkitMaskRepeat: "repeat",
|
||||
maskRepeat: "repeat",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* columns */}
|
||||
<div style={{ position: "absolute", inset: 0, display: "flex", gap, alignItems: "flex-end" }}>
|
||||
{days.map((d, i) => {
|
||||
const glmShare = d.glm / d.total
|
||||
const blueH = Math.round(glmShare * EXAGGERATE * chartH)
|
||||
const filled = Math.round(blueH * fillOf(i))
|
||||
const slotGap = filled > 2 ? 5 : 0
|
||||
const fieldH = chartH - filled - slotGap
|
||||
const fieldTotal = d.dsf + d.dsp + d.mm + d.others
|
||||
return (
|
||||
<div key={i} style={{ position: "relative", flex: 1, height: chartH }}>
|
||||
{/* gray field of other models (already in place) */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: fieldH,
|
||||
display: "flex",
|
||||
flexDirection: "column-reverse",
|
||||
}}
|
||||
>
|
||||
{field.map((seg) => (
|
||||
<div
|
||||
key={seg.key}
|
||||
style={{
|
||||
height: Math.round(((d[seg.key as keyof typeof d] as number) / fieldTotal) * fieldH),
|
||||
background: seg.color,
|
||||
borderTop: `2px solid ${c.bg}`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* GLM-5.2, animates in */}
|
||||
{filled > 2 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: filled,
|
||||
background: glmColor,
|
||||
borderTop: `2px solid ${c.accentHi}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* day axis */}
|
||||
<div style={{ display: "flex", gap, width: chartW, margin: "14px auto 0" }}>
|
||||
{days.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: "center",
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
color: i === days.length - 1 ? c.ink : c.faint,
|
||||
}}
|
||||
>
|
||||
{i === 0 || i === launchIndex || i === days.length - 1 ? d.date : ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* footer */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginTop: 28,
|
||||
paddingTop: 22,
|
||||
borderTop: `1px solid ${c.line}`,
|
||||
fontSize: 20,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: 9, color: c.muted }}>
|
||||
<span style={{ width: 13, height: 13, background: c.accent, display: "inline-block" }} />
|
||||
GLM-5.2
|
||||
</div>
|
||||
<div style={{ color: c.ink }}>opencode.ai/data</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -495,7 +495,6 @@ async function subscribeSessionEvents() {
|
||||
console.log("Subscribing to session events...")
|
||||
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
|
||||
bash: ["Bash", "\x1b[31m\x1b[1m"],
|
||||
edit: ["Edit", "\x1b[32m\x1b[1m"],
|
||||
glob: ["Glob", "\x1b[34m\x1b[1m"],
|
||||
|
||||
@@ -67,6 +67,10 @@ const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
|
||||
configuration: {
|
||||
enforceWorkgroupConfiguration: true,
|
||||
publishCloudwatchMetricsEnabled: true,
|
||||
// Athena bills $5/TB scanned; kill any query that would scan more than 2 TB
|
||||
// so a regression cannot silently burn money. Stats sync full passes scan
|
||||
// ~250 GB as of 2026-07.
|
||||
bytesScannedCutoffPerQuery: 2 * 1024 ** 4,
|
||||
resultConfiguration: {
|
||||
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
|
||||
},
|
||||
|
||||
+3
-1
@@ -185,7 +185,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
|
||||
cluster: lakeCluster,
|
||||
architecture: "arm64",
|
||||
cpu: "0.25 vCPU",
|
||||
memory: "0.5 GB",
|
||||
// 0.5 GB caused an OOM crash loop: every restart immediately re-ran the 4 Athena
|
||||
// stats queries (~$5/pass) every ~5 minutes instead of hourly.
|
||||
memory: "2 GB",
|
||||
image: {
|
||||
context: ".",
|
||||
dockerfile: "packages/stats/server/Dockerfile",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-ZmAIcG3wPgssKejLe/EMJOjPuMHCyRiiItRvSqIjaME=",
|
||||
"aarch64-linux": "sha256-lbAHoVI/CvgdrfKZ/9dKZ/O7DxJvuWEOrDgcCuFGPBk=",
|
||||
"aarch64-darwin": "sha256-Nrs4ijvzQ3J8AYigJSlsr66feUvLZ5iiYCfBdaZNJEc=",
|
||||
"x86_64-darwin": "sha256-ltCaJSO7kZWuMOU49269xvboNVX35OJDFj0gXCDf62Y="
|
||||
"x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=",
|
||||
"aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=",
|
||||
"aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=",
|
||||
"x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk="
|
||||
}
|
||||
}
|
||||
|
||||
+15
-8
@@ -2,23 +2,29 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
||||
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
||||
"typecheck": "bun turbo typecheck --concurrency=3",
|
||||
"typecheck:profile": "bun script/profile-typecheck.ts",
|
||||
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"postinstall": "bun run --cwd packages/core fix-node-pty",
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'",
|
||||
"sso": "aws sso login --sso-session=opencode --no-browser",
|
||||
"translate:app": "bun run script/translate-app.ts",
|
||||
"test": "echo 'do not run tests from root' && exit 1"
|
||||
},
|
||||
"workspaces": {
|
||||
@@ -39,13 +45,14 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"@opentui/core": "0.4.3",
|
||||
"@opentui/keymap": "0.4.3",
|
||||
"@opentui/solid": "0.4.3",
|
||||
"@tanstack/solid-virtual": "3.13.28",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@corvu/drawer": "0.2.4",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "24.12.2",
|
||||
"@types/semver": "7.7.1",
|
||||
@@ -94,18 +101,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "5.0.1",
|
||||
"@ast-grep/cli": "0.44.0",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"glob": "13.0.5",
|
||||
"husky": "9.1.7",
|
||||
"node-gyp": "12.4.0",
|
||||
"oxlint": "1.60.0",
|
||||
"oxlint-tsgolint": "0.21.0",
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "catalog:",
|
||||
"turbo": "2.8.13"
|
||||
"turbo": "2.10.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.933.0",
|
||||
@@ -147,7 +154,7 @@
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
|
||||
@@ -20,7 +20,7 @@ const profiles = [
|
||||
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
|
||||
{
|
||||
name: "multi patch",
|
||||
tool: "apply_patch",
|
||||
tool: "patch",
|
||||
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
|
||||
},
|
||||
] as const
|
||||
|
||||
@@ -25,7 +25,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
||||
toolPart(patchID, "patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
||||
textPart(followingID, "Following incremental patch"),
|
||||
],
|
||||
{ completed: false },
|
||||
@@ -49,7 +49,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||
partUpdated(
|
||||
toolPart(
|
||||
patchID,
|
||||
"apply_patch",
|
||||
"patch",
|
||||
"running",
|
||||
{ files: [first.filePath, second.filePath] },
|
||||
{ metadata: { files: [first, second] } },
|
||||
@@ -61,7 +61,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||
partUpdated(
|
||||
toolPart(
|
||||
patchID,
|
||||
"apply_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ files: [first.filePath, second.filePath, third.filePath] },
|
||||
{ metadata: { files: [first, second, third] } },
|
||||
|
||||
@@ -48,6 +48,40 @@ test("does not reverse visible rows when the user wheels during shell remeasurem
|
||||
await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1))
|
||||
})
|
||||
|
||||
test("keeps moving upward while drag-selecting above the timeline", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
messages: history(80),
|
||||
viewport: { width: 1400, height: 700 },
|
||||
reducedMotion: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const text = page.getByText("History 79.", { exact: false })
|
||||
await expect(text).toBeVisible()
|
||||
await scroller.evaluate((element) => {
|
||||
element.dataset.selectionLength = "0"
|
||||
document.addEventListener("selectionchange", () => {
|
||||
element.dataset.selectionLength = String(
|
||||
Math.max(Number(element.dataset.selectionLength), window.getSelection()?.toString().length ?? 0),
|
||||
)
|
||||
})
|
||||
})
|
||||
const textBox = await text.boundingBox()
|
||||
const scrollBox = await scroller.boundingBox()
|
||||
expect(textBox).not.toBeNull()
|
||||
expect(scrollBox).not.toBeNull()
|
||||
if (!textBox || !scrollBox) return
|
||||
|
||||
await page.mouse.move(textBox.x + textBox.width - 10, textBox.y + textBox.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(textBox.x + 20, scrollBox.y - 120, { steps: 30 })
|
||||
|
||||
await expect.poll(() => scroller.evaluate((element) => Number(element.dataset.selectionLength))).toBeGreaterThan(0)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeGreaterThan(500)
|
||||
await page.mouse.up()
|
||||
})
|
||||
|
||||
test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_keyboard_01_shell"
|
||||
const followingID = "prt_keyboard_02_following"
|
||||
|
||||
@@ -33,11 +33,9 @@ test.describe("timeline tool state stability", () => {
|
||||
}
|
||||
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
|
||||
const questionID = "prt_state_question"
|
||||
const todoID = "prt_state_todo"
|
||||
const initial = [
|
||||
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
|
||||
toolPart(questionID, "question", "pending", questionInput()),
|
||||
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
textPart("prt_state_following", "Following lightweight tools"),
|
||||
]
|
||||
const childID = "ses_timeline_child"
|
||||
@@ -49,7 +47,6 @@ test.describe("timeline tool state stability", () => {
|
||||
await timeline.send(status("busy"), 120)
|
||||
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
|
||||
const regionIDs = [
|
||||
"prt_state_webfetch",
|
||||
@@ -105,7 +102,6 @@ test.describe("timeline tool state stability", () => {
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
await expect(
|
||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||
).toBeVisible()
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { setupTimelineBenchmark } from "./session-timeline-benchmark.fixture"
|
||||
|
||||
const changedLinesPerFile = 100
|
||||
const linesPerSide = changedLinesPerFile / 2
|
||||
const fileCounts = [1, 10, 100, 1_000, 10_000]
|
||||
const filesPerDirectory = 100
|
||||
const readyFrames = 3
|
||||
const completionTimeoutMs = Number(process.env.REVIEW_PANE_COMPLETION_TIMEOUT_MS ?? 900_000)
|
||||
|
||||
type ReviewPaneScalingSample = {
|
||||
observedAtMs: number
|
||||
logicalRows: number
|
||||
treeRows: number
|
||||
fileRows: number
|
||||
diffLines: number
|
||||
header: string
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
type ReviewPaneScalingProbe = {
|
||||
startedAt?: number
|
||||
firstTreeRowMs?: number
|
||||
logicalTreeReadyMs?: number
|
||||
firstDiffRenderMs?: number
|
||||
stableReadyMs?: number
|
||||
samples: ReviewPaneScalingSample[]
|
||||
frameTimesMs: number[]
|
||||
longTasks: { startTime: number; duration: number }[]
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
benchmark.describe("performance: review pane scaling", () => {
|
||||
for (const fileCount of fileCounts) {
|
||||
const changedLines = fileCount * changedLinesPerFile
|
||||
|
||||
benchmark(
|
||||
`${changedLines} changed lines across ${fileCount} ${fileCount === 1 ? "file" : "files"}`,
|
||||
async ({ page, report }) => {
|
||||
benchmark.setTimeout(1_200_000)
|
||||
await page.emulateMedia({ reducedMotion: "reduce" })
|
||||
|
||||
const patchByteLimit = Number(process.env.REVIEW_PANE_PATCH_BYTE_LIMIT ?? Number.POSITIVE_INFINITY)
|
||||
if (Number.isNaN(patchByteLimit) || patchByteLimit < 0)
|
||||
throw new Error(`Invalid REVIEW_PANE_PATCH_BYTE_LIMIT: ${process.env.REVIEW_PANE_PATCH_BYTE_LIMIT}`)
|
||||
const responseBody = JSON.stringify(createScalingDiffs(fileCount, patchByteLimit))
|
||||
await setupTimelineBenchmark(page, {
|
||||
historyTurns: 0,
|
||||
eventBatch: 1,
|
||||
newLayoutDesigns: true,
|
||||
})
|
||||
await page.route("**/vcs/diff**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: responseBody,
|
||||
}),
|
||||
)
|
||||
|
||||
const expectedRows = fileCount + 2 + Math.ceil(fileCount / filesPerDirectory)
|
||||
const metrics = await measureReviewPaneLoad(page, {
|
||||
expectedFile: reviewFile(0),
|
||||
expectedRows,
|
||||
})
|
||||
const search = await measureBroadReviewSearch(page, fileCount)
|
||||
|
||||
expect(metrics.logicalRows).toBe(expectedRows)
|
||||
expect(metrics.fileRows).toBeGreaterThan(0)
|
||||
expect(metrics.treeRows).toBeGreaterThan(0)
|
||||
expect(metrics.diffLines).toBeGreaterThan(0)
|
||||
expect(search.logicalRows).toBe(fileCount)
|
||||
expect(search.renderedRows).toBeGreaterThan(0)
|
||||
report(
|
||||
{ ...metrics, search },
|
||||
{
|
||||
fileCount,
|
||||
changedLinesPerFile,
|
||||
changedLines,
|
||||
additions: changedLines / 2,
|
||||
deletions: changedLines / 2,
|
||||
patchLines: changedLines,
|
||||
patchByteLimit: Number.isFinite(patchByteLimit) ? patchByteLimit : null,
|
||||
payloadBytes: new TextEncoder().encode(responseBody).byteLength,
|
||||
expectedRows,
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
async function measureBroadReviewSearch(page: Page, expectedRows: number) {
|
||||
const filter = page.getByRole("searchbox", { name: "Filter files" })
|
||||
await filter.evaluate((element) => {
|
||||
element.addEventListener(
|
||||
"input",
|
||||
() => {
|
||||
;(window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt = performance.now()
|
||||
},
|
||||
{ once: true, capture: true },
|
||||
)
|
||||
})
|
||||
await filter.fill("file-")
|
||||
|
||||
return page.evaluate((expectedRows) => {
|
||||
const startedAt = (window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt!
|
||||
return new Promise<{ stableMs: number; logicalRows: number; renderedRows: number }>((resolve) => {
|
||||
let previous = -1
|
||||
let streak = 0
|
||||
const sample = () => {
|
||||
const tree = document.querySelector<HTMLElement>('#review-panel [data-component="file-tree-v2"]')
|
||||
const rows = [...document.querySelectorAll<HTMLElement>('#review-panel [data-slot="file-tree-v2-row"]')]
|
||||
const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
|
||||
const ready =
|
||||
logicalRows === expectedRows && rows.length > 0 && rows.every((row) => row.textContent?.includes("file-"))
|
||||
streak = ready && rows.length === previous ? streak + 1 : ready ? 1 : 0
|
||||
previous = rows.length
|
||||
if (streak >= 3) {
|
||||
resolve({ stableMs: performance.now() - startedAt, logicalRows, renderedRows: rows.length })
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
})
|
||||
}, expectedRows)
|
||||
}
|
||||
|
||||
function createScalingDiffs(fileCount: number, patchByteLimit: number) {
|
||||
const changes = Array.from({ length: linesPerSide }, (_, index) => {
|
||||
const line = String(index).padStart(3, "0")
|
||||
return `-export const value_${line} = "before"\n+export const value_${line} = "after"`
|
||||
}).join("\n")
|
||||
let patchBytes = 0
|
||||
let capped = false
|
||||
|
||||
return Array.from({ length: fileCount }, (_, index) => {
|
||||
const file = reviewFile(index)
|
||||
const fullPatch = [
|
||||
`diff --git a/${file} b/${file}`,
|
||||
`--- a/${file}`,
|
||||
`+++ b/${file}`,
|
||||
`@@ -1,${linesPerSide} +1,${linesPerSide} @@`,
|
||||
changes,
|
||||
].join("\n")
|
||||
if (index === 0 && fullPatch.length > patchByteLimit)
|
||||
throw new Error(`REVIEW_PANE_PATCH_BYTE_LIMIT must include the active patch (${fullPatch.length} bytes)`)
|
||||
const patch = !capped && patchBytes + fullPatch.length <= patchByteLimit ? fullPatch : emptyReviewPatch(file)
|
||||
if (patch === fullPatch) patchBytes += fullPatch.length
|
||||
else capped = true
|
||||
return {
|
||||
file,
|
||||
patch,
|
||||
additions: linesPerSide,
|
||||
deletions: linesPerSide,
|
||||
status: "modified" as const,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function emptyReviewPatch(file: string) {
|
||||
return [`diff --git a/${file} b/${file}`, `--- a/${file}`, `+++ b/${file}`].join("\n")
|
||||
}
|
||||
|
||||
function reviewFile(index: number) {
|
||||
return `src/review/d${String(Math.floor(index / filesPerDirectory)).padStart(5, "0")}/file-${String(index).padStart(5, "0")}.ts`
|
||||
}
|
||||
|
||||
async function measureReviewPaneLoad(page: Page, input: { expectedFile: string; expectedRows: number }) {
|
||||
const toggle = page.getByRole("button", { name: "Toggle review" })
|
||||
await expect(toggle).toBeVisible()
|
||||
await toggle.evaluate((element) => element.setAttribute("data-review-pane-scaling-toggle", ""))
|
||||
await installReviewPaneScalingProbe(page, input)
|
||||
await toggle.click()
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe
|
||||
?.stableReadyMs !== undefined,
|
||||
undefined,
|
||||
{ timeout: completionTimeoutMs },
|
||||
)
|
||||
|
||||
return page.evaluate(() => {
|
||||
const probe = (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe!
|
||||
probe.stop()
|
||||
const startedAt = probe.startedAt!
|
||||
const final = probe.samples.at(-1)!
|
||||
const resources = performance
|
||||
.getEntriesByType("resource")
|
||||
.filter((entry) => entry.name.includes("/vcs/diff")) as PerformanceResourceTiming[]
|
||||
const resource = resources.at(-1)
|
||||
const longTasks = probe.longTasks.filter(
|
||||
(entry) => entry.startTime >= startedAt && entry.startTime <= startedAt + probe.stableReadyMs!,
|
||||
)
|
||||
const frameGaps = probe.frameTimesMs.map((time, index) => time - (probe.frameTimesMs[index - 1] ?? 0))
|
||||
|
||||
return {
|
||||
firstTreeRowMs: probe.firstTreeRowMs ?? null,
|
||||
logicalTreeReadyMs: probe.logicalTreeReadyMs ?? null,
|
||||
firstDiffRenderMs: probe.firstDiffRenderMs ?? null,
|
||||
stableReadyMs: probe.stableReadyMs ?? null,
|
||||
responseStartMs: resource ? resource.responseStart - startedAt : null,
|
||||
responseEndMs: resource ? resource.responseEnd - startedAt : null,
|
||||
responseToStableMs: resource ? probe.stableReadyMs! - (resource.responseEnd - startedAt) : null,
|
||||
treeRows: final.treeRows,
|
||||
logicalRows: final.logicalRows,
|
||||
fileRows: final.fileRows,
|
||||
diffLines: final.diffLines,
|
||||
samples: probe.samples.length,
|
||||
maxFrameGapMs: Math.max(0, ...frameGaps),
|
||||
longTaskCount: longTasks.length,
|
||||
longTaskTotalMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
|
||||
maxLongTaskMs: Math.max(0, ...longTasks.map((entry) => entry.duration)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function installReviewPaneScalingProbe(page: Page, input: { expectedFile: string; expectedRows: number }) {
|
||||
await page.evaluate(
|
||||
({ expectedFile, expectedRows, stableFrames }) => {
|
||||
let running = true
|
||||
let readyStreak = 0
|
||||
const basename = expectedFile.split("/").at(-1)!
|
||||
const longTaskObserver = PerformanceObserver.supportedEntryTypes.includes("longtask")
|
||||
? new PerformanceObserver((list) => {
|
||||
probe.longTasks.push(
|
||||
...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })),
|
||||
)
|
||||
})
|
||||
: undefined
|
||||
const probe: ReviewPaneScalingProbe = {
|
||||
samples: [],
|
||||
frameTimesMs: [],
|
||||
longTasks: [],
|
||||
stop: () => {
|
||||
running = false
|
||||
longTaskObserver?.disconnect()
|
||||
},
|
||||
}
|
||||
|
||||
const sample = (time: number) => {
|
||||
if (!running || probe.startedAt === undefined) return
|
||||
const panel = document.querySelector<HTMLElement>("#review-panel")
|
||||
const tree = panel?.querySelector<HTMLElement>('[data-component="file-tree-v2"]')
|
||||
const rows = panel?.querySelectorAll('[data-slot="file-tree-v2-row"]') ?? []
|
||||
const fileRows = panel?.querySelectorAll('button[data-slot="file-tree-v2-row"]') ?? []
|
||||
const header =
|
||||
panel?.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')?.textContent?.trim() ?? ""
|
||||
const viewers = panel
|
||||
? [...panel.querySelectorAll<HTMLElement>('[data-component="file"][data-mode="diff"]')]
|
||||
: []
|
||||
const diffLines = viewers.reduce(
|
||||
(sum, viewer) =>
|
||||
sum + (viewer.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0),
|
||||
0,
|
||||
)
|
||||
const observedAtMs = time - probe.startedAt
|
||||
const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
|
||||
const ready =
|
||||
logicalRows === expectedRows &&
|
||||
fileRows.length > 0 &&
|
||||
header.includes(basename) &&
|
||||
viewers.length === 1 &&
|
||||
diffLines > 0
|
||||
const previous = probe.samples.at(-1)
|
||||
const stable =
|
||||
ready &&
|
||||
previous?.ready === true &&
|
||||
previous.logicalRows === logicalRows &&
|
||||
previous.treeRows === rows.length &&
|
||||
previous.fileRows === fileRows.length &&
|
||||
previous.diffLines === diffLines &&
|
||||
previous.header === header
|
||||
|
||||
probe.frameTimesMs.push(observedAtMs)
|
||||
probe.samples.push({
|
||||
observedAtMs,
|
||||
logicalRows,
|
||||
treeRows: rows.length,
|
||||
fileRows: fileRows.length,
|
||||
diffLines,
|
||||
header,
|
||||
ready,
|
||||
})
|
||||
if (probe.firstTreeRowMs === undefined && rows.length > 0) probe.firstTreeRowMs = observedAtMs
|
||||
if (probe.logicalTreeReadyMs === undefined && logicalRows === expectedRows)
|
||||
probe.logicalTreeReadyMs = observedAtMs
|
||||
if (probe.firstDiffRenderMs === undefined && diffLines > 0) probe.firstDiffRenderMs = observedAtMs
|
||||
readyStreak = !ready ? 0 : stable ? readyStreak + 1 : 1
|
||||
if (readyStreak === stableFrames) probe.stableReadyMs = observedAtMs
|
||||
if (probe.stableReadyMs === undefined) requestAnimationFrame(sample)
|
||||
}
|
||||
|
||||
longTaskObserver?.observe({ type: "longtask", buffered: true })
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const toggle = event.target instanceof Element ? event.target.closest("button") : undefined
|
||||
if (!toggle?.hasAttribute("data-review-pane-scaling-toggle")) return
|
||||
probe.startedAt = performance.now()
|
||||
performance.mark("opencode.review-pane-scaling.click")
|
||||
requestAnimationFrame(sample)
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
)
|
||||
;(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe = probe
|
||||
},
|
||||
{ ...input, stableFrames: readyFrames },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers"
|
||||
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
type ParentHydrationBenchmarkMode = "natural" | "candidate"
|
||||
|
||||
const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
|
||||
if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
|
||||
const userID = "msg_parent_hydration_user"
|
||||
const user = {
|
||||
...fixture.messages[fixture.targetID][0]!,
|
||||
info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } },
|
||||
parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({
|
||||
...part,
|
||||
id: `prt_parent_hydration_user_${index}`,
|
||||
messageID: userID,
|
||||
})),
|
||||
}
|
||||
const assistantSeed = fixture.messages[fixture.targetID][3]!
|
||||
const assistants = Array.from({ length: 14 }, (_, index) => {
|
||||
const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
|
||||
return {
|
||||
...assistantSeed,
|
||||
info: {
|
||||
...assistantSeed.info,
|
||||
id: messageID,
|
||||
parentID: userID,
|
||||
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
|
||||
},
|
||||
parts: assistantSeed.parts.map((part, partIndex) => ({
|
||||
...part,
|
||||
id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`,
|
||||
messageID,
|
||||
})),
|
||||
}
|
||||
})
|
||||
const messages = [user, ...assistants]
|
||||
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
||||
const lastID = userID
|
||||
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
|
||||
|
||||
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
||||
benchmark.setTimeout(180_000)
|
||||
const results = [] as Awaited<ReturnType<typeof trial>>[]
|
||||
for (let run = 0; run < 5; run++) {
|
||||
results.push(
|
||||
await withBenchmarkPage(
|
||||
browser,
|
||||
`session-parent-hydration-${mode}-${run}`,
|
||||
(page) => trial(page, mode),
|
||||
testInfo,
|
||||
),
|
||||
)
|
||||
}
|
||||
const timing = results.map((result) => result.metrics.firstCorrectObservedMs!).sort((a, b) => a - b)
|
||||
report(
|
||||
{
|
||||
results: results.map((result) => ({ ...result.metrics, historyGateCount: result.historyGateCount })),
|
||||
summary: {
|
||||
firstCorrectObservedMs: { min: timing[0], median: timing[2], max: timing.at(-1) },
|
||||
blankSamples: results.map((result) => result.metrics.blankSamples),
|
||||
requestCounts: {
|
||||
list: results.map((result) => result.requestCounts.list),
|
||||
parent: results.map((result) => result.requestCounts.parent),
|
||||
},
|
||||
historyGateCount: results.map((result) => result.historyGateCount),
|
||||
},
|
||||
},
|
||||
{ mode },
|
||||
)
|
||||
})
|
||||
|
||||
async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
||||
const requests: { type: "list" | "parent"; before?: string }[] = []
|
||||
const history = mode === "candidate" ? Promise.withResolvers<void>() : undefined
|
||||
let historyGates = 0
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions.filter((session) => session.id === fixture.sourceID),
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
messageDelay: 50,
|
||||
onMessages: (request) => {
|
||||
if (request.sessionID === fixture.targetID && request.phase === "start")
|
||||
requests.push({ type: "list", before: request.before })
|
||||
},
|
||||
beforeMessagesResponse: (request) => {
|
||||
if (mode !== "candidate" || request.sessionID !== fixture.targetID || !request.before) return Promise.resolve()
|
||||
historyGates++
|
||||
return history!.promise
|
||||
},
|
||||
onMessage: (request) => {
|
||||
if (request.sessionID === fixture.targetID && request.messageID === userID) requests.push({ type: "parent" })
|
||||
},
|
||||
message: (sessionID, messageID) => {
|
||||
if (sessionID !== fixture.targetID || messageID !== userID) return
|
||||
return user
|
||||
},
|
||||
pageMessages: (sessionID, limit, before) => {
|
||||
const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
|
||||
const end = before ? items.findIndex((message) => message.info.id === before) : items.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
|
||||
},
|
||||
})
|
||||
await page.route(`**/session/${fixture.targetID}`, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
|
||||
)
|
||||
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
await page.evaluate(
|
||||
({ href, title }) => {
|
||||
const link = document.createElement("a")
|
||||
link.id = "parent-hydration-target"
|
||||
link.href = href
|
||||
link.textContent = title
|
||||
document.body.append(link)
|
||||
},
|
||||
{ href, title: target.title },
|
||||
)
|
||||
const metrics = await measureSessionSwitch(page, {
|
||||
destinationIDs: messages.map((message) => message.info.id),
|
||||
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id),
|
||||
lastID,
|
||||
requiredPartID: lastPartID,
|
||||
requireBottomAnchor: false,
|
||||
href,
|
||||
switch: async () => {
|
||||
await page.locator("#parent-hydration-target").click()
|
||||
await expectSessionTitle(page, target.title)
|
||||
},
|
||||
}).finally(() => history?.resolve())
|
||||
expect(metrics.firstCorrectObservedMs).not.toBeNull()
|
||||
const requestCounts = {
|
||||
list: requests.filter((request) => request.type === "list").length,
|
||||
parent: requests.filter((request) => request.type === "parent").length,
|
||||
}
|
||||
if (mode === "candidate") {
|
||||
expect(requestCounts.parent).toBe(1)
|
||||
expect(historyGates).toBe(1)
|
||||
}
|
||||
return { metrics, requestCounts, historyGateCount: historyGates }
|
||||
}
|
||||
@@ -4,6 +4,8 @@ export type SessionSwitchSample = {
|
||||
source: string[]
|
||||
hasVisibleRows: boolean
|
||||
last: boolean
|
||||
requiredPartVisible?: boolean
|
||||
bottomAnchorRequired?: boolean
|
||||
bottomErrorPx?: number
|
||||
review?: {
|
||||
fileHost: boolean
|
||||
@@ -41,7 +43,8 @@ export function isCorrectDestination(sample: SessionSwitchSample) {
|
||||
sample.destination.length > 0 &&
|
||||
sample.source.length === 0 &&
|
||||
sample.last &&
|
||||
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1
|
||||
sample.requiredPartVisible !== false &&
|
||||
(sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,16 @@ type SessionSwitchProbe = {
|
||||
|
||||
async function installSessionSwitchProbe(
|
||||
page: Page,
|
||||
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string },
|
||||
input: {
|
||||
destinationIDs: string[]
|
||||
sourceIDs: string[]
|
||||
lastID: string
|
||||
requiredPartID?: string
|
||||
requireBottomAnchor?: boolean
|
||||
href: string
|
||||
},
|
||||
) {
|
||||
await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => {
|
||||
await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => {
|
||||
const destination = new Set(destinationIDs)
|
||||
const source = new Set(sourceIDs)
|
||||
const samples: SessionSwitchSample[] = []
|
||||
@@ -66,6 +73,13 @@ async function installSessionSwitchProbe(
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.bottom > view.top && rect.top < view.bottom
|
||||
})
|
||||
const requiredPartVisible = requiredPartID
|
||||
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
|
||||
if (element.dataset.timelinePartId !== requiredPartID) return false
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
||||
})
|
||||
: undefined
|
||||
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
|
||||
samples.push({
|
||||
observedAtMs,
|
||||
@@ -73,11 +87,22 @@ async function installSessionSwitchProbe(
|
||||
source: visible.filter((id) => source.has(id)),
|
||||
hasVisibleRows,
|
||||
last: visible.includes(lastID),
|
||||
requiredPartVisible,
|
||||
bottomAnchorRequired: requireBottomAnchor !== false,
|
||||
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
||||
review,
|
||||
})
|
||||
} else {
|
||||
samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false, review })
|
||||
samples.push({
|
||||
observedAtMs,
|
||||
destination: [],
|
||||
source: [],
|
||||
hasVisibleRows: false,
|
||||
last: false,
|
||||
requiredPartVisible: requiredPartID ? false : undefined,
|
||||
bottomAnchorRequired: requireBottomAnchor !== false,
|
||||
review,
|
||||
})
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}, 0)
|
||||
@@ -117,7 +142,8 @@ async function waitForStableSessionSwitch(page: Page) {
|
||||
sample.destination.length > 0 &&
|
||||
sample.source.length === 0 &&
|
||||
sample.last &&
|
||||
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1,
|
||||
sample.requiredPartVisible !== false &&
|
||||
(sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1),
|
||||
)
|
||||
)
|
||||
})
|
||||
@@ -135,13 +161,27 @@ async function collectSessionSwitchResult(page: Page) {
|
||||
|
||||
export async function measureSessionSwitch(
|
||||
page: Page,
|
||||
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise<void> },
|
||||
input: {
|
||||
destinationIDs: string[]
|
||||
sourceIDs: string[]
|
||||
lastID: string
|
||||
requiredPartID?: string
|
||||
requireBottomAnchor?: boolean
|
||||
href: string
|
||||
switch: () => Promise<void>
|
||||
},
|
||||
) {
|
||||
const { switch: run, ...probe } = input
|
||||
await installSessionSwitchProbe(page, probe)
|
||||
await run()
|
||||
await waitForStableSessionSwitch(page)
|
||||
return collectSessionSwitchResult(page)
|
||||
try {
|
||||
await run()
|
||||
await waitForStableSessionSwitch(page)
|
||||
return await collectSessionSwitchResult(page)
|
||||
} finally {
|
||||
await page.evaluate(() => {
|
||||
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForStableTimeline(page: Page, lastID: string) {
|
||||
|
||||
@@ -295,7 +295,7 @@ function performanceTurn(index: number) {
|
||||
messageID: assistantID,
|
||||
type: "tool",
|
||||
callID: `call_0000_${suffix}_patch`,
|
||||
tool: "apply_patch",
|
||||
tool: "patch",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { patchText: realisticPatch(index) },
|
||||
|
||||
@@ -131,7 +131,7 @@ function toolPart(
|
||||
): MessagePart {
|
||||
const metadata =
|
||||
metadataOverride ??
|
||||
(tool === "apply_patch"
|
||||
(tool === "patch"
|
||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||
: tool === "edit" || tool === "write"
|
||||
? {
|
||||
@@ -219,7 +219,7 @@ function turn(index: number): Message[] {
|
||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||
: []),
|
||||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
: []),
|
||||
...(index % 7 === 0
|
||||
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||
@@ -269,7 +269,6 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
|
||||
]).flat()
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text.trim()
|
||||
if (part.type === "reasoning") return !!part.text.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
|
||||
test("applies message latency after a list response gate is released", async () => {
|
||||
const events: string[] = []
|
||||
const gate = Promise.withResolvers<void>()
|
||||
let handler: ((route: Route) => Promise<void>) | undefined
|
||||
const page = {
|
||||
route: (_url: string, callback: (route: Route) => Promise<void>) => {
|
||||
handler = callback
|
||||
return Promise.resolve()
|
||||
},
|
||||
} as unknown as Page
|
||||
await mockOpenCodeServer(page, {
|
||||
provider: {},
|
||||
directory: "C:/OpenCode",
|
||||
project: {},
|
||||
sessions: [{ id: "session" }],
|
||||
messageDelay: 25,
|
||||
beforeMessagesResponse: () => {
|
||||
events.push("before")
|
||||
return gate.promise
|
||||
},
|
||||
onMessages: (request) => events.push(request.phase),
|
||||
pageMessages: () => {
|
||||
events.push("page")
|
||||
return { items: [] }
|
||||
},
|
||||
})
|
||||
|
||||
const response = handler!({
|
||||
request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }),
|
||||
fulfill: () => {
|
||||
events.push("fulfill")
|
||||
return Promise.resolve()
|
||||
},
|
||||
} as unknown as Route)
|
||||
expect(events).toEqual(["start", "before"])
|
||||
|
||||
const released = performance.now()
|
||||
gate.resolve()
|
||||
await response
|
||||
expect(performance.now() - released).toBeGreaterThanOrEqual(20)
|
||||
expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
|
||||
})
|
||||
@@ -52,3 +52,35 @@ test("reports missing correctness without throwing", () => {
|
||||
expect(result.firstCorrectObservedMs).toBeNull()
|
||||
expect(result.stableObservedMs).toBeNull()
|
||||
})
|
||||
|
||||
test("requires an explicitly tracked part to be visible", () => {
|
||||
const result = classifySessionSwitch([
|
||||
{
|
||||
observedAtMs: 16,
|
||||
destination: ["destination"],
|
||||
source: [],
|
||||
hasVisibleRows: true,
|
||||
last: true,
|
||||
requiredPartVisible: false,
|
||||
bottomErrorPx: 0,
|
||||
},
|
||||
])
|
||||
|
||||
expect(result.firstCorrectObservedMs).toBeNull()
|
||||
})
|
||||
|
||||
test("can measure content correctness without requiring a bottom anchor", () => {
|
||||
const result = classifySessionSwitch([
|
||||
{
|
||||
observedAtMs: 16,
|
||||
destination: ["destination"],
|
||||
source: [],
|
||||
hasVisibleRows: true,
|
||||
last: true,
|
||||
requiredPartVisible: true,
|
||||
bottomAnchorRequired: false,
|
||||
},
|
||||
])
|
||||
|
||||
expect(result.firstCorrectObservedMs).toBe(16)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { measureSessionSwitch } from "../timeline/session-tab-switch-probe"
|
||||
|
||||
function testPage(waitFailure?: Error) {
|
||||
const stops: unknown[] = []
|
||||
const page = {
|
||||
evaluate: async (_callback: unknown, input?: unknown) => {
|
||||
if (input) return
|
||||
stops.push(undefined)
|
||||
},
|
||||
waitForFunction: async () => {
|
||||
if (waitFailure) throw waitFailure
|
||||
},
|
||||
} as unknown as Page
|
||||
return { page, stops }
|
||||
}
|
||||
|
||||
function input(run: () => Promise<void>) {
|
||||
return {
|
||||
destinationIDs: ["destination"],
|
||||
sourceIDs: ["source"],
|
||||
lastID: "destination",
|
||||
href: "/session/destination",
|
||||
switch: run,
|
||||
}
|
||||
}
|
||||
|
||||
test("stops sampling when the session switch fails", async () => {
|
||||
const failure = new Error("switch failed")
|
||||
const context = testPage()
|
||||
|
||||
await expect(
|
||||
measureSessionSwitch(
|
||||
context.page,
|
||||
input(async () => Promise.reject(failure)),
|
||||
),
|
||||
).rejects.toBe(failure)
|
||||
|
||||
expect(context.stops).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("stops sampling when the stable wait fails", async () => {
|
||||
const failure = new Error("stable wait failed")
|
||||
const context = testPage(failure)
|
||||
|
||||
await expect(
|
||||
measureSessionSwitch(
|
||||
context.page,
|
||||
input(async () => {}),
|
||||
),
|
||||
).rejects.toBe(failure)
|
||||
|
||||
expect(context.stops).toHaveLength(1)
|
||||
})
|
||||
@@ -90,7 +90,7 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const draftID = "draft_legacy_new_session"
|
||||
const directory = "C:/OpenCode/LegacyNewSession"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_legacy_new_session",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "legacy-new-session",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
)
|
||||
},
|
||||
{ directory, draftID, server },
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
|
||||
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
|
||||
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
|
||||
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const draftID = "draft_new_session_panel_corner"
|
||||
const directory = "C:/OpenCode/NewSessionPanelCorner"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.use({
|
||||
viewport: { width: 935, height: 522 },
|
||||
deviceScaleFactor: 1,
|
||||
})
|
||||
|
||||
test("matches the rounded panel corners to the dark new-session background", async ({ page }, testInfo) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_new_session_panel_corner",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "new-session-panel-corner",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", "dark")
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
)
|
||||
},
|
||||
{ directory, draftID, server },
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
||||
const panel = page.locator('main div[class*="rounded-[10px]"][class*="overflow-hidden"]')
|
||||
await expect(panel).toHaveCount(1)
|
||||
const box = await panel.boundingBox()
|
||||
if (!box) throw new Error("New-session panel bounds are unavailable")
|
||||
|
||||
const screenshot = await page.screenshot({ path: testInfo.outputPath("new-session-dark.png") })
|
||||
const corners = await page.evaluate(
|
||||
async ({ source, points }) => {
|
||||
const image = new Image()
|
||||
image.src = source
|
||||
await image.decode()
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = image.naturalWidth
|
||||
canvas.height = image.naturalHeight
|
||||
const context = canvas.getContext("2d")
|
||||
if (!context) throw new Error("2D canvas is unavailable")
|
||||
context.drawImage(image, 0, 0)
|
||||
return points.map((point) => Array.from(context.getImageData(point.x, point.y, 1, 1).data))
|
||||
},
|
||||
{
|
||||
source: `data:image/png;base64,${screenshot.toString("base64")}`,
|
||||
points: [
|
||||
{ x: Math.floor(box.x), y: Math.floor(box.y) },
|
||||
{ x: Math.ceil(box.x + box.width) - 1, y: Math.floor(box.y) },
|
||||
{ x: Math.floor(box.x), y: Math.ceil(box.y + box.height) - 1 },
|
||||
{ x: Math.ceil(box.x + box.width) - 1, y: Math.ceil(box.y + box.height) - 1 },
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
expect(corners.every(([red, green, blue, alpha]) => red <= 8 && green <= 8 && blue <= 8 && alpha === 255)).toBe(true)
|
||||
})
|
||||
@@ -65,7 +65,7 @@ async function mockServers(page: Page) {
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ReviewOpenFile"
|
||||
const projectID = "proj_review_open_file"
|
||||
const sessionID = "ses_review_open_file"
|
||||
const title = "Review open file"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("opens and searches project files inline", async ({ page }) => {
|
||||
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "open-file-project",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: sessionID,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
vcsDiff: [fileDiff("src/changed.ts")],
|
||||
fileList: (path) => {
|
||||
if (path) return []
|
||||
return [
|
||||
fileNode("README.md"),
|
||||
{ name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
|
||||
]
|
||||
},
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
findFiles: (input) => {
|
||||
searches.push(input)
|
||||
return input.query === "nested" ? ["src/nested.ts"] : []
|
||||
},
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID },
|
||||
)
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const panel = page.locator("#review-panel")
|
||||
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
|
||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||
await contextButton.click()
|
||||
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebar).toBeVisible()
|
||||
await contextButton.click()
|
||||
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebar).toHaveCount(0)
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
||||
await expect(filter).toBeFocused()
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
|
||||
|
||||
await panel.getByRole("button", { name: "README.md" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
await expect(sidebar).toHaveCount(0)
|
||||
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
|
||||
await expect(sidebar).toBeVisible()
|
||||
await filter.fill("nested")
|
||||
const result = panel.getByRole("option", { name: /nested\.ts/ })
|
||||
await expect(result).toBeVisible()
|
||||
const resultID = await result.getAttribute("id")
|
||||
expect(resultID).toBeTruthy()
|
||||
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
||||
await filter.press("Enter")
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
||||
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
|
||||
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
await page.keyboard.press("Control+w")
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||
})
|
||||
|
||||
function fileNode(path: string) {
|
||||
return {
|
||||
name: path,
|
||||
path,
|
||||
absolute: `${directory}/${path}`,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
}
|
||||
}
|
||||
|
||||
function fileDiff(file: string) {
|
||||
return {
|
||||
file,
|
||||
before: "before\n",
|
||||
after: "after\n",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ReviewStatePersistence"
|
||||
const projectID = "proj_review_state_persistence"
|
||||
const sessionA = "ses_review_state_a"
|
||||
const sessionB = "ses_review_state_b"
|
||||
const titleA = "Alpha review state"
|
||||
const titleB = "Beta review state"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("restores review mode and selected file per session", async ({ page }) => {
|
||||
await setup(page)
|
||||
await page.goto(sessionHref(sessionA))
|
||||
await expectSessionTitle(page, titleA)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await selectFile(page, "beta.ts")
|
||||
|
||||
await switchSession(page, titleB)
|
||||
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||
await selectFile(page, "gamma.ts")
|
||||
|
||||
await switchSession(page, titleA)
|
||||
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "beta.ts")
|
||||
await selectMode(page, "Branch changes", "Git changes")
|
||||
await expectSelectedFile(page, "alpha.ts")
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await expectSelectedFile(page, "beta.ts")
|
||||
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "beta.ts")
|
||||
|
||||
await switchSession(page, titleB)
|
||||
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "gamma.ts")
|
||||
})
|
||||
|
||||
async function selectMode(page: Page, current: string, next: string) {
|
||||
await page.getByRole("button", { name: current }).click()
|
||||
await page.getByRole("option", { name: next }).click()
|
||||
}
|
||||
|
||||
async function selectFile(page: Page, file: string) {
|
||||
await page.getByRole("button", { name: file }).click()
|
||||
await expectSelectedFile(page, file)
|
||||
}
|
||||
|
||||
async function expectSelectedFile(page: Page, file: string) {
|
||||
await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file)
|
||||
}
|
||||
|
||||
async function switchSession(page: Page, title: string) {
|
||||
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
|
||||
await expectSessionTitle(page, title)
|
||||
}
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "review-state-persistence",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
|
||||
}),
|
||||
)
|
||||
await page.route("**/vcs/diff**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
new URL(route.request().url()).searchParams.get("mode") === "branch"
|
||||
? [diff("src/alpha.ts"), diff("src/beta.ts")]
|
||||
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
|
||||
),
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessions: [sessionA, sessionB] },
|
||||
)
|
||||
}
|
||||
|
||||
function session(id: string, title: string, created: number) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created, updated: created },
|
||||
}
|
||||
}
|
||||
|
||||
function diff(file: string) {
|
||||
return {
|
||||
file,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
|
||||
}
|
||||
}
|
||||
|
||||
function sessionHref(sessionID: string) {
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
@@ -10,6 +10,9 @@ const sessionB = "ses_review_tab_b"
|
||||
const titleA = "Alpha session"
|
||||
const titleB = "Beta session"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const diffs = Array.from({ length: 2_740 }, (_, index) =>
|
||||
fileDiff(`src/generated-${String(index).padStart(4, "0")}.ts`),
|
||||
)
|
||||
// Marks the review pane DOM node so a remount (fresh node) is detectable.
|
||||
const PROBE = "original"
|
||||
|
||||
@@ -25,20 +28,34 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac
|
||||
await expectSessionTitle(page, titleA)
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
const reviewTab = page.getByRole("tab", { name: /Review/ })
|
||||
const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ })
|
||||
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
|
||||
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
|
||||
const review = page.locator('#review-panel [data-component="session-review-v2"]')
|
||||
await expectAppVisible(review)
|
||||
await expectAppVisible(page.getByRole("button", { name: /example\.ts/ }))
|
||||
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||
await writeProbe(page)
|
||||
|
||||
await switchTab(page, titleB)
|
||||
await expectSessionTitle(page, titleB)
|
||||
await expectAppVisible(review)
|
||||
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
|
||||
await switchTab(page, titleA)
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expectAppVisible(review)
|
||||
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
|
||||
const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
||||
await viewport.hover()
|
||||
await page.mouse.wheel(0, 100_000)
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible()
|
||||
})
|
||||
|
||||
type Probed = HTMLElement & { __e2eProbe?: string }
|
||||
@@ -80,16 +97,7 @@ async function setup(page: Page) {
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||
vcsDiff: [
|
||||
{
|
||||
file: "src/example.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch:
|
||||
"diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
|
||||
},
|
||||
],
|
||||
vcsDiff: diffs,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
|
||||
@@ -127,3 +135,13 @@ function session(id: string, title: string, created: number) {
|
||||
function sessionHref(sessionID: string) {
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
function fileDiff(file: string) {
|
||||
return {
|
||||
file,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ReviewTerminalStacked"
|
||||
const projectID = "proj_review_terminal_stacked"
|
||||
const sessionID = "ses_review_terminal_stacked"
|
||||
const title = "Review terminal stacked"
|
||||
const branchDiffs = [
|
||||
fileDiff(".github/actions/setup-bun/action.yml", 7),
|
||||
...Array.from({ length: 2_739 }, (_, index) =>
|
||||
fileDiff(
|
||||
`src/branch/d${String(Math.floor(index / 100)).padStart(5, "0")}/generated-${String(index).padStart(4, "0")}.ts`,
|
||||
100,
|
||||
false,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
|
||||
test.setTimeout(120_000)
|
||||
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
|
||||
let detailVersion = 1
|
||||
let detailFailures = 1
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "review-terminal-stacked",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "review-terminal-stacked",
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
sessionStatus: { [sessionID]: { type: "idle" } },
|
||||
pageMessages: () => ({ items: [] }),
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 16,
|
||||
})
|
||||
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
|
||||
}),
|
||||
)
|
||||
await page.route("**/vcs/diff**", (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
|
||||
const detail = scope?.endsWith("/src/branch/d00027")
|
||||
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
url.searchParams.get("mode") === "branch"
|
||||
? detail
|
||||
? branchDiffs
|
||||
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
|
||||
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
|
||||
: branchDiffs
|
||||
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
|
||||
),
|
||||
})
|
||||
})
|
||||
await page.route("**/pty", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }),
|
||||
}),
|
||||
)
|
||||
await page.route("**/pty/pty_review_terminal", (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
)
|
||||
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await expectTree(page, 8, "git-0.ts")
|
||||
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible()
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectStackGeometry(page)
|
||||
|
||||
const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
||||
await treeViewport.hover()
|
||||
await page.mouse.wheel(0, 100_000)
|
||||
await expect
|
||||
.poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
const lastFile = page.getByRole("button", { name: "generated-2738.ts" })
|
||||
await expect(lastFile).toBeVisible()
|
||||
const bottomGap = await lastFile.evaluate((element) => {
|
||||
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
|
||||
return viewport.bottom - element.getBoundingClientRect().bottom
|
||||
})
|
||||
expect(bottomGap).toBeGreaterThanOrEqual(0)
|
||||
expect(bottomGap).toBeLessThanOrEqual(16)
|
||||
const lazyDiff = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return (
|
||||
url.pathname === "/vcs/diff" &&
|
||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
)
|
||||
})
|
||||
await lastFile.click()
|
||||
await lazyDiff
|
||||
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
|
||||
await expect(preview).toContainText("after-1")
|
||||
detailVersion = 2
|
||||
events.push(statusEvent("busy"))
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
const refreshedDiff = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return (
|
||||
url.pathname === "/vcs/diff" &&
|
||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
)
|
||||
})
|
||||
events.push(statusEvent("idle"))
|
||||
await refreshedDiff
|
||||
await expect(preview).toContainText("after-2")
|
||||
await selectMode(page, "Branch changes", "Git changes")
|
||||
await expectTree(page, 8, "git-0.ts")
|
||||
await page.getByRole("button", { name: "git-0.ts" }).click()
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
const filter = page.getByRole("searchbox", { name: "Filter files" })
|
||||
await filter.fill("generated-2738")
|
||||
await expectTree(page, 1, "generated-2738.ts")
|
||||
await filter.fill("")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
await page.getByRole("button", { name: "Toggle file tree" }).click()
|
||||
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
|
||||
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
|
||||
await page.getByRole("button", { name: "Toggle file tree" }).click()
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toHaveCount(0)
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await page.setViewportSize({ width: 1_000, height: 700 })
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectStackGeometry(page)
|
||||
await page.setViewportSize({ width: 1_000, height: 120 })
|
||||
await page.setViewportSize({ width: 1_400, height: 900 })
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectStackGeometry(page)
|
||||
})
|
||||
|
||||
async function selectMode(page: Page, current: string, next: string) {
|
||||
await page.getByRole("button", { name: current }).click()
|
||||
const option = page.getByRole("option", { name: next })
|
||||
await expect(option).toBeVisible()
|
||||
await option.click()
|
||||
}
|
||||
|
||||
async function expectTree(page: Page, total: number, file: string) {
|
||||
await expectMountedTree(page, total)
|
||||
await expect(page.getByRole("button", { name: file })).toBeVisible()
|
||||
}
|
||||
|
||||
async function expectMountedTree(page: Page, total: number) {
|
||||
const tree = page.locator('#review-panel [data-component="file-tree-v2"]')
|
||||
await expect(tree).toHaveAttribute("data-total-rows", String(total))
|
||||
await expect
|
||||
.poll(() => tree.evaluate((element) => element.querySelectorAll('[data-slot="file-tree-v2-row"]').length))
|
||||
.toBeGreaterThan(0)
|
||||
const state = await tree.evaluate((element) => ({
|
||||
root: element.getBoundingClientRect().height,
|
||||
viewport: element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect().height,
|
||||
rows: element.querySelectorAll('[data-slot="file-tree-v2-row"]').length,
|
||||
}))
|
||||
expect(state.viewport).toBeGreaterThan(0)
|
||||
expect(state.root).toBeGreaterThan(0)
|
||||
expect(state.rows).toBeGreaterThan(0)
|
||||
expect(state.rows).toBeLessThanOrEqual(60)
|
||||
}
|
||||
|
||||
async function expectStackGeometry(page: Page) {
|
||||
const geometry = await page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")!
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
|
||||
const reviewParent = review.parentElement!.getBoundingClientRect()
|
||||
const terminalParent = terminal.parentElement!.getBoundingClientRect()
|
||||
return {
|
||||
review: review.getBoundingClientRect().height,
|
||||
reviewParent: reviewParent.height,
|
||||
terminal: terminal.getBoundingClientRect().height,
|
||||
terminalParent: terminalParent.height,
|
||||
}
|
||||
})
|
||||
expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
|
||||
function statusEvent(type: "busy" | "idle") {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
||||
}
|
||||
}
|
||||
|
||||
function fileDiff(file: string, additions: number, loaded = true, version = 1) {
|
||||
return {
|
||||
file,
|
||||
additions,
|
||||
deletions: 0,
|
||||
status: "modified",
|
||||
patch: loaded
|
||||
? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n`
|
||||
: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`,
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ test("renders a completed single-file patch", async ({ page }) => {
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
id,
|
||||
"apply_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ files: ["src/a.ts"] },
|
||||
{
|
||||
|
||||
@@ -35,7 +35,7 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
patchID,
|
||||
"apply_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ files: files.map((file) => file.filePath) },
|
||||
{ metadata: { files } },
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
directory,
|
||||
messageUpdated,
|
||||
project,
|
||||
session,
|
||||
sessionID,
|
||||
status,
|
||||
textPart,
|
||||
title,
|
||||
userID,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const assistants = Array.from({ length: 14 }, (_, index) =>
|
||||
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
||||
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||
parentID: userID,
|
||||
created: 1700000001000 + index * 1_000,
|
||||
completed: index < 13,
|
||||
}),
|
||||
)
|
||||
const messages = [userMessage(), ...assistants]
|
||||
const lastAssistant = assistants.at(-1)!
|
||||
const lastPartID = assistants.at(-1)!.parts[0]!.id
|
||||
const userPartID = `prt_${userID}_text`
|
||||
const completed = {
|
||||
...lastAssistant.info,
|
||||
time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
|
||||
}
|
||||
const scenarios = [
|
||||
{ name: "completion", info: completed, idleFirst: false, interrupted: false },
|
||||
{
|
||||
name: "interruption",
|
||||
info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } },
|
||||
idleFirst: true,
|
||||
interrupted: true,
|
||||
},
|
||||
] as const
|
||||
|
||||
test.use({ viewport: { width: 646, height: 1385 } })
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => {
|
||||
const requests: { before?: string; phase: "start" | "end" }[] = []
|
||||
const pages: { before?: string; limit: number }[] = []
|
||||
const roots: { sessionID: string; messageID: string }[] = []
|
||||
const sequence: string[] = []
|
||||
const history = Promise.withResolvers<void>()
|
||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||
retry: 20,
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: {
|
||||
"claude-opus-4-6": {
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [session()],
|
||||
sessionStatus: { [sessionID]: { type: "busy" } },
|
||||
beforeMessagesResponse: (request) => (request.before ? history.promise : Promise.resolve()),
|
||||
onMessages: (request) => {
|
||||
requests.push(request)
|
||||
sequence.push(`messages:${request.phase}:${request.before ?? "latest"}`)
|
||||
},
|
||||
onMessage: (request) => {
|
||||
roots.push(request)
|
||||
sequence.push(`message:${request.messageID}`)
|
||||
},
|
||||
message: (requestedSessionID, messageID) => {
|
||||
if (requestedSessionID !== sessionID) return
|
||||
return messages.find((item) => item.info.id === messageID)
|
||||
},
|
||||
pageMessages: (_, limit, before) => {
|
||||
pages.push({ before, limit })
|
||||
const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: messages.slice(start, end),
|
||||
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
||||
}
|
||||
},
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ userPartID, lastPartID }) => {
|
||||
const state = { armed: false, hidden: false, samples: 0, stop: false }
|
||||
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
|
||||
const sample = () => {
|
||||
if (state.armed) {
|
||||
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
||||
const view = viewport?.getBoundingClientRect()
|
||||
const visible = (partID: string) => {
|
||||
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
|
||||
const rect = part?.getBoundingClientRect()
|
||||
return (
|
||||
!!rect &&
|
||||
!!view &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.bottom > view.top &&
|
||||
rect.top < view.bottom
|
||||
)
|
||||
}
|
||||
if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true
|
||||
state.samples++
|
||||
}
|
||||
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
|
||||
}
|
||||
requestAnimationFrame(() => setTimeout(sample, 0))
|
||||
},
|
||||
{ userPartID, lastPartID },
|
||||
)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
|
||||
await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2)
|
||||
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
|
||||
expect(sequence.slice(0, 4)).toEqual([
|
||||
"messages:start:latest",
|
||||
"messages:end:latest",
|
||||
`message:${userID}`,
|
||||
`messages:start:${messages.at(-2)!.info.id}`,
|
||||
])
|
||||
await page.evaluate(() => {
|
||||
;(
|
||||
window as Window & {
|
||||
__historyRootProbe?: { armed: boolean }
|
||||
}
|
||||
).__historyRootProbe!.armed = true
|
||||
})
|
||||
await waitForProbeSamples(page, 0)
|
||||
expect(await historyRootHidden(page)).toBe(false)
|
||||
const beforeHistory = await probeSamples(page)
|
||||
history.resolve()
|
||||
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14)
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
await waitForProbeSamples(page, beforeHistory)
|
||||
expect(pages[0]).toEqual({ before: undefined, limit: 2 })
|
||||
expect(roots).toEqual([{ sessionID, messageID: userID }])
|
||||
|
||||
const message = messageUpdated(scenario.info)
|
||||
const idle = status("idle")
|
||||
for (const event of scenario.idleFirst ? [idle, message] : [message, idle]) {
|
||||
const beforeEvent = await probeSamples(page)
|
||||
await transport.send(event)
|
||||
if (event === idle) await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
|
||||
if (event === message && scenario.interrupted)
|
||||
await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
|
||||
await waitForProbeSamples(page, beforeEvent)
|
||||
const current = await timelineState(page)
|
||||
expect(current, JSON.stringify(current)).toMatchObject({ virtual: true })
|
||||
expect(current.rows, JSON.stringify(current)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
expect(requests[0]).toEqual({ before: undefined, phase: "start", sessionID })
|
||||
expect(requests[1]).toEqual({ before: undefined, phase: "end", sessionID })
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="bottom-spacer"]')).toBeVisible()
|
||||
if (scenario.interrupted) await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
const state = (window as Window & { __historyRootProbe?: { hidden: boolean; stop: boolean } })
|
||||
.__historyRootProbe!
|
||||
state.stop = true
|
||||
return state.hidden
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
}
|
||||
|
||||
function timelineState(page: Page) {
|
||||
return page.evaluate(() => ({
|
||||
virtual: !!document.querySelector("[data-timeline-virtual-content]"),
|
||||
rows: document.querySelectorAll("[data-timeline-key]").length,
|
||||
}))
|
||||
}
|
||||
|
||||
function probeSamples(page: Page) {
|
||||
return page.evaluate(
|
||||
() => (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples,
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForProbeSamples(page: Page, after: number) {
|
||||
await page.waitForFunction(
|
||||
(after) =>
|
||||
(window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples >= after + 3,
|
||||
after,
|
||||
)
|
||||
}
|
||||
|
||||
function historyRootHidden(page: Page) {
|
||||
return page.evaluate(
|
||||
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
|
||||
)
|
||||
}
|
||||
@@ -35,7 +35,6 @@ test.describe("session timeline projection", () => {
|
||||
editPart("prt_edit"),
|
||||
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
|
||||
patchPart("prt_patch"),
|
||||
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
toolPart(
|
||||
"prt_question",
|
||||
"question",
|
||||
@@ -65,7 +64,6 @@ test.describe("session timeline projection", () => {
|
||||
]) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
|
||||
}
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
||||
@@ -246,7 +244,7 @@ function editPart(id: string) {
|
||||
function patchPart(id: string) {
|
||||
return toolPart(
|
||||
id,
|
||||
"apply_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ files: ["src/a.ts", "src/b.ts"] },
|
||||
{
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const deviceScaleFactor of [1.25, 1.5]) {
|
||||
test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => {
|
||||
const shellID = "prt_shell_outline"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])],
|
||||
settings: { newLayoutDesigns: true, shellToolPartsExpanded: true },
|
||||
reducedMotion: true,
|
||||
deviceScaleFactor,
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${shellID}"]`)
|
||||
const output = part.locator('[data-component="bash-output"]')
|
||||
const row = page.locator("[data-timeline-key]", { has: part })
|
||||
await expect(output).toBeVisible()
|
||||
await timeline.settle()
|
||||
|
||||
const geometry = await row.evaluate((element) => {
|
||||
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')
|
||||
if (!output) throw new Error("Shell output is unavailable")
|
||||
const rowRect = element.getBoundingClientRect()
|
||||
const outputRect = output.getBoundingClientRect()
|
||||
// Match a rounded-down measurement at a fractional device-pixel phase.
|
||||
element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px`
|
||||
element.style.transform = "translateY(0.25px)"
|
||||
output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)")
|
||||
output.style.setProperty("background", "rgb(0, 0, 0)", "important")
|
||||
const style = getComputedStyle(output)
|
||||
return {
|
||||
outputWidth: outputRect.width,
|
||||
outputHeight: outputRect.height,
|
||||
borderColor: style.borderTopColor,
|
||||
boxShadow: style.boxShadow,
|
||||
clipMargin: getComputedStyle(element).overflowClipMargin,
|
||||
}
|
||||
})
|
||||
await timeline.settle()
|
||||
|
||||
const clipped = await row.evaluate((element) => {
|
||||
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')!
|
||||
return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom
|
||||
})
|
||||
expect(clipped).toBeCloseTo(0.49, 1)
|
||||
|
||||
expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor)
|
||||
const edges = await captureCardEdges(page, output)
|
||||
|
||||
expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2)
|
||||
expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2)
|
||||
expect(geometry.borderColor).toBe("rgb(255, 0, 255)")
|
||||
expect(geometry.boxShadow).toBe("none")
|
||||
expect(geometry.clipMargin).toBe("0.5px")
|
||||
expect(edges.magenta.top).toBeGreaterThan(0.75)
|
||||
expect(edges.magenta.bottom).toBeGreaterThan(0.75)
|
||||
expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
}
|
||||
|
||||
test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
|
||||
const patchID = "prt_patch_outline"
|
||||
const file = {
|
||||
filePath: "src/outline.ts",
|
||||
relativePath: "src/outline.ts",
|
||||
type: "update",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
before: "const outline = false\n",
|
||||
after: "const outline = true\n",
|
||||
}
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true, newLayoutDesigns: true },
|
||||
reducedMotion: true,
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
||||
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
|
||||
const row = page.locator("[data-timeline-key]", { has: part })
|
||||
await expect(card).toBeVisible()
|
||||
await timeline.settle()
|
||||
|
||||
const geometry = await row.evaluate((element) => {
|
||||
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
|
||||
if (!card) throw new Error("Patch card is unavailable")
|
||||
const rowRect = element.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px`
|
||||
const clipMargin = getComputedStyle(element).overflowClipMargin
|
||||
const bottom = element.getBoundingClientRect().bottom
|
||||
return {
|
||||
overflow: card.getBoundingClientRect().bottom - bottom,
|
||||
paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin),
|
||||
clipMargin,
|
||||
cardWidth: cardRect.width,
|
||||
cardHeight: cardRect.height,
|
||||
}
|
||||
})
|
||||
await timeline.settle()
|
||||
|
||||
expect(geometry.overflow).toBeCloseTo(0.49, 1)
|
||||
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
|
||||
const edges = await captureCardEdges(page, card)
|
||||
expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2)
|
||||
expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2)
|
||||
expect(edges.luminance.top).toBeLessThan(245)
|
||||
expect(edges.luminance.bottom).toBeLessThan(245)
|
||||
expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10)
|
||||
expect(geometry.clipMargin).toBe("0.5px")
|
||||
})
|
||||
|
||||
test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => {
|
||||
const secondUserID = "msg_outline_second_user"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(undefined, {
|
||||
summary: {
|
||||
diffs: [
|
||||
{
|
||||
file: "src/summary.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
assistantMessage([textPart("prt_outline_text", "Assistant text")]),
|
||||
userMessage(undefined, { id: secondUserID, created: 1700000010000 }),
|
||||
assistantMessage([], {
|
||||
id: "msg_outline_second_assistant",
|
||||
parentID: secondUserID,
|
||||
created: 1700000011000,
|
||||
}),
|
||||
],
|
||||
})
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
||||
|
||||
const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
|
||||
elements.map((element) => ({
|
||||
tag: element.querySelector<HTMLElement>("[data-timeline-row]")?.dataset.timelineRow,
|
||||
clipMargin: getComputedStyle(element).overflowClipMargin,
|
||||
})),
|
||||
)
|
||||
expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true)
|
||||
expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }])
|
||||
})
|
||||
|
||||
async function captureCardEdges(page: Page, card: Locator) {
|
||||
const box = await card.boundingBox()
|
||||
if (!box) throw new Error("Tool card bounds are unavailable")
|
||||
const viewport = page.viewportSize()
|
||||
if (!viewport) throw new Error("Viewport bounds are unavailable")
|
||||
const screenshot = await page.screenshot()
|
||||
return page.evaluate(
|
||||
async ({ source, box, viewport }) => {
|
||||
const image = new Image()
|
||||
image.src = source
|
||||
await image.decode()
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = image.naturalWidth
|
||||
canvas.height = image.naturalHeight
|
||||
const context = canvas.getContext("2d")
|
||||
if (!context) throw new Error("2D canvas is unavailable")
|
||||
context.drawImage(image, 0, 0)
|
||||
const scale = {
|
||||
x: image.naturalWidth / viewport.width,
|
||||
y: image.naturalHeight / viewport.height,
|
||||
}
|
||||
const rows = (candidates: number[]) => {
|
||||
const left = Math.floor((box.x + 8) * scale.x)
|
||||
const width = Math.floor((box.width - 16) * scale.x)
|
||||
return candidates.map((row) => {
|
||||
const pixels = context.getImageData(left, row, width, 1).data
|
||||
const indexes = Array.from({ length: width }, (_, index) => index * 4)
|
||||
return {
|
||||
luminance:
|
||||
indexes
|
||||
.map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3)
|
||||
.reduce((sum, value) => sum + value, 0) / width,
|
||||
magenta:
|
||||
indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200)
|
||||
.length / width,
|
||||
}
|
||||
})
|
||||
}
|
||||
const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
|
||||
const columns = new Uint32Array(image.naturalWidth)
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
|
||||
columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
|
||||
}
|
||||
const top = box.y * scale.y
|
||||
const bottom = (box.y + box.height) * scale.y
|
||||
const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)])
|
||||
const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1])
|
||||
return {
|
||||
box,
|
||||
luminance: {
|
||||
top: Math.min(...topRows.map((row) => row.luminance)),
|
||||
bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance,
|
||||
},
|
||||
magenta: {
|
||||
top: Math.max(...topRows.map((row) => row.magenta)),
|
||||
bottom: Math.max(...bottomRows.map((row) => row.magenta)),
|
||||
vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length,
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
source: `data:image/png;base64,${screenshot.toString("base64")}`,
|
||||
viewport,
|
||||
box,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
|
||||
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
|
||||
const ordinary = ["bash", "edit", "write", "patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
|
||||
const parts = ordinary.map((tool, index) =>
|
||||
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
|
||||
)
|
||||
@@ -17,13 +17,11 @@ test("renders every tool error outcome without leaking hidden tools", async ({ p
|
||||
error: "The user dismissed this question",
|
||||
}),
|
||||
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
|
||||
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
|
||||
)
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
|
||||
await expect(page.getByText(/dismissed/i)).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
|
||||
for (let index = 0; index < ordinary.length; index++) {
|
||||
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
|
||||
}
|
||||
@@ -90,7 +88,7 @@ function questionInput() {
|
||||
function errorInput(tool: string) {
|
||||
if (tool === "bash") return { command: "exit 1" }
|
||||
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
|
||||
if (tool === "apply_patch") return { files: ["src/error.ts"] }
|
||||
if (tool === "patch") return { files: ["src/error.ts"] }
|
||||
if (tool === "webfetch") return { url: "https://example.com" }
|
||||
if (tool === "websearch") return { query: "failure" }
|
||||
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/TodoDockNavigation"
|
||||
const projectID = "proj_todo_dock_navigation"
|
||||
const sourceID = "ses_todo_dock_source"
|
||||
const otherID = "ses_todo_dock_other"
|
||||
const sourceTitle = "Todo dock animation"
|
||||
const otherTitle = "Separate session"
|
||||
|
||||
const activeTodos = [
|
||||
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
|
||||
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
|
||||
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
|
||||
]
|
||||
|
||||
type EventPayload = {
|
||||
directory: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
|
||||
|
||||
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
|
||||
test.setTimeout(90_000)
|
||||
const events: EventPayload[] = []
|
||||
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "todo-dock-navigation",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: {
|
||||
"claude-opus-4-6": {
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 16,
|
||||
todos: (sessionID) => todos[sessionID] ?? [],
|
||||
})
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(sessionHref(sourceID))
|
||||
await expectSessionTitle(page, sourceTitle)
|
||||
const dock = page.locator('[data-component="session-todo-dock"]')
|
||||
await expect(dock).toHaveCount(0)
|
||||
|
||||
events.push(statusEvent(sourceID, "busy"))
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
|
||||
await page.waitForTimeout(700)
|
||||
const opening = sampleDock(page, 1_000)
|
||||
todos[sourceID] = activeTodos
|
||||
events.push(todoEvent(sourceID, activeTodos))
|
||||
await expect(dock).toBeVisible()
|
||||
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||
|
||||
await switchSession(page, otherID, otherTitle)
|
||||
await expect(dock).toHaveCount(0)
|
||||
|
||||
const returningOpen = sampleDock(page, 700)
|
||||
await switchSession(page, sourceID, sourceTitle)
|
||||
const openSamples = (await returningOpen).filter((sample) => sample.present)
|
||||
expect(openSamples.length).toBeGreaterThan(0)
|
||||
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
|
||||
expect(openSamples[0]!.height).toBeGreaterThan(70)
|
||||
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||
|
||||
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
|
||||
const closing = sampleDock(page, 1_000)
|
||||
todos[sourceID] = completedTodos
|
||||
events.push(todoEvent(sourceID, completedTodos))
|
||||
await expect(dock).toHaveCount(0)
|
||||
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||
todos[sourceID] = []
|
||||
events.push(todoEvent(sourceID, []))
|
||||
|
||||
await switchSession(page, otherID, otherTitle)
|
||||
const returningEmpty = sampleDock(page, 700)
|
||||
await switchSession(page, sourceID, sourceTitle)
|
||||
await expect(dock).toHaveCount(0)
|
||||
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
|
||||
})
|
||||
|
||||
function session(id: string, title: string, created: number) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created, updated: created },
|
||||
}
|
||||
}
|
||||
|
||||
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
||||
}
|
||||
}
|
||||
|
||||
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
|
||||
}
|
||||
}
|
||||
|
||||
async function configurePage(page: Page) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.addInitScript(
|
||||
({ directory, dirBase64, server, sessionIDs }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||
)
|
||||
},
|
||||
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
|
||||
)
|
||||
}
|
||||
|
||||
function sessionHref(sessionID: string) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
async function switchSession(page: Page, sessionID: string, title: string) {
|
||||
const href = sessionHref(sessionID)
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
await expectSessionTitle(page, title)
|
||||
}
|
||||
|
||||
function sampleDock(page: Page, duration: number) {
|
||||
return page.evaluate(async (duration) => {
|
||||
const samples: { present: boolean; height: number; opacity: number }[] = []
|
||||
const start = performance.now()
|
||||
while (performance.now() - start < duration) {
|
||||
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
|
||||
const clip = dock?.parentElement?.parentElement
|
||||
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
|
||||
samples.push({
|
||||
present: !!dock,
|
||||
height: clip?.getBoundingClientRect().height ?? 0,
|
||||
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
|
||||
})
|
||||
await new Promise(requestAnimationFrame)
|
||||
}
|
||||
return samples
|
||||
}, duration)
|
||||
}
|
||||
@@ -63,7 +63,7 @@ async function mockServer(page: Page) {
|
||||
if (byId) return json(route, byId)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/TerminalComposerFocus"
|
||||
const projectID = "proj_terminal_composer_focus"
|
||||
const sessionID = "ses_terminal_composer_focus"
|
||||
const ptyID = "pty_terminal_composer_focus"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "terminal-composer-focus",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "terminal-composer-focus",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Terminal composer focus",
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/pty", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
||||
}),
|
||||
)
|
||||
await page.route(`**/pty/${ptyID}`, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
)
|
||||
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
|
||||
await page.keyboard.type("x")
|
||||
await expect(composer).toHaveText("")
|
||||
|
||||
await page.waitForTimeout(300)
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
await page.keyboard.type("a")
|
||||
|
||||
await expect(composer).toBeFocused()
|
||||
await expect(composer).toHaveText("a")
|
||||
})
|
||||
@@ -7,7 +7,7 @@ const projectID = "proj_hidden_terminal_regression"
|
||||
const sessionID = "ses_hidden_terminal_regression"
|
||||
const title = "Hidden terminal regression"
|
||||
|
||||
test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => {
|
||||
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
@@ -64,13 +64,14 @@ test("unmounts the terminal renderer while the pane is hidden", async ({ page })
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toHaveAttribute("aria-hidden", "true")
|
||||
await expect(panel).toHaveCount(0)
|
||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||
|
||||
await page.setViewportSize({ width: 1200, height: 700 })
|
||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ function toolPart(
|
||||
outputLength = 160,
|
||||
): MessagePart {
|
||||
const metadata =
|
||||
tool === "apply_patch"
|
||||
tool === "patch"
|
||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||
: tool === "edit" || tool === "write"
|
||||
? {
|
||||
@@ -199,7 +199,7 @@ function turn(index: number): Message[] {
|
||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||
: []),
|
||||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
: []),
|
||||
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
|
||||
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
||||
@@ -229,7 +229,6 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||
]).flat()
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text.trim()
|
||||
if (part.type === "reasoning") return !!part.text.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"./performance/timeline-stability/fixture.test.ts",
|
||||
"./performance/timeline-stability/fixture.ts",
|
||||
"./performance/unit/visual-stability.test.ts",
|
||||
"./regression/new-session-panel-corner.spec.ts",
|
||||
"./regression/session-timeline-context-resize.spec.ts",
|
||||
"./utils/**/*.ts"
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
|
||||
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
||||
|
||||
export interface MockServerConfig {
|
||||
provider: unknown
|
||||
@@ -11,14 +11,17 @@ export interface MockServerConfig {
|
||||
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
|
||||
vcsDiff?: unknown[]
|
||||
messageDelay?: number
|
||||
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
|
||||
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
||||
message?: (sessionID: string, messageID: string) => unknown
|
||||
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
||||
events?: () => unknown[]
|
||||
eventRetry?: number
|
||||
todos?: (sessionID: string) => unknown[]
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
fileList?: (path: string) => unknown | Promise<unknown>
|
||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
||||
sessionStatus?: unknown
|
||||
}
|
||||
|
||||
@@ -52,6 +55,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const path = url.pathname
|
||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||
if (path === "/global/health") return json(route, { healthy: true })
|
||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||
if (path === "/permission")
|
||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
if (path === "/question")
|
||||
@@ -62,6 +66,23 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
||||
if (path === "/file/content" && config.fileContent)
|
||||
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
|
||||
if (path === "/find/file" && config.findFiles)
|
||||
return json(
|
||||
route,
|
||||
await config.findFiles({
|
||||
query: url.searchParams.get("query") ?? "",
|
||||
dirs: url.searchParams.get("dirs") ?? undefined,
|
||||
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
||||
}),
|
||||
)
|
||||
if (path === "/api/reference")
|
||||
return json(route, {
|
||||
location: {
|
||||
directory: config.directory,
|
||||
project: { id: (config.project as { id?: string }).id, directory: config.directory },
|
||||
},
|
||||
data: [],
|
||||
})
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||
@@ -72,8 +93,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return json(route, session ?? {})
|
||||
}
|
||||
|
||||
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
||||
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||
const projectMatch = path.match(/^\/project\/([^/]+)$/)
|
||||
if (projectMatch) return json(route, config.project)
|
||||
|
||||
const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||
if (messageMatch) {
|
||||
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const message = config.message?.(messageMatch[1]!, messageMatch[2]!)
|
||||
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
|
||||
return json(route, message)
|
||||
}
|
||||
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||
|
||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||
@@ -82,7 +113,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const before = token ? cursors.get(token) : undefined
|
||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
||||
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const limit = Number(url.searchParams.get("limit") ?? 80)
|
||||
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.18",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -47,6 +47,7 @@
|
||||
"vite-plugin-solid": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
@@ -88,6 +89,7 @@
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
"tailwindcss": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
+124
-51
@@ -9,9 +9,10 @@ import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { Effect } from "effect"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import {
|
||||
type Component,
|
||||
createEffect,
|
||||
@@ -32,7 +33,7 @@ import { CommandProvider, useCommand, type CommandOption } from "@/context/comma
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
@@ -52,9 +53,10 @@ import LegacyLayout from "@/pages/layout"
|
||||
import NewLayout from "@/pages/layout-new"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||
|
||||
import { SessionPage, TargetSessionRouteContent } from "@/pages/session"
|
||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
||||
import { NewHome, LegacyHome } from "@/pages/home"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
@@ -88,10 +90,14 @@ const SessionRoute = () => {
|
||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||
})
|
||||
|
||||
return <SessionPage />
|
||||
return (
|
||||
<SessionRouteErrorBoundary sessionID={params.id}>
|
||||
<SessionPage />
|
||||
</SessionRouteErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
const TargetSessionRoute = () => {
|
||||
function TargetServerRoute(props: ParentProps) {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const global = useGlobal()
|
||||
const conn = createMemo(() => {
|
||||
@@ -105,14 +111,47 @@ const TargetSessionRoute = () => {
|
||||
// re-resolves reactively instead); both rely on this key for server changes.
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<TargetSessionRouteContent />
|
||||
</ServerSyncProvider>
|
||||
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const TargetSessionRoute = () => (
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
)
|
||||
|
||||
function LegacyTargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
return (
|
||||
<TargetServerRoute>
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
|
||||
<LegacyTargetSessionRedirect />
|
||||
</SessionRouteErrorBoundary>
|
||||
</TargetServerRoute>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyTargetSessionRedirect() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const sync = useServerSync()
|
||||
const current = createSessionLineage(
|
||||
() => params.id,
|
||||
() => sync().session.lineage,
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const directory = current()?.session.directory
|
||||
if (!directory) return
|
||||
navigate(legacySessionHref(directory, params.id), { replace: true })
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
||||
// Notification/Models + the visual Layout) for that server.
|
||||
@@ -126,16 +165,17 @@ function SelectedServerProviders(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyServerLayout(props: ParentProps) {
|
||||
function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||
return (
|
||||
<SelectedServerProviders>
|
||||
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
|
||||
<LegacyServerScopedShell serverScoped={props.serverScoped}>{props.children}</LegacyServerScopedShell>
|
||||
</SelectedServerProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function DraftRoute() {
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
@@ -144,7 +184,14 @@ function DraftRoute() {
|
||||
keyed
|
||||
fallback={<Navigate href="/" />}
|
||||
>
|
||||
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||
{(draft) => (
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
|
||||
>
|
||||
<ResolvedDraftRoute draft={draft} />
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
@@ -186,7 +233,7 @@ declare global {
|
||||
deepLinks?: string[]
|
||||
}
|
||||
api?: {
|
||||
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
||||
setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise<void>
|
||||
exportDebugLogs?: () => Promise<string>
|
||||
}
|
||||
}
|
||||
@@ -263,12 +310,14 @@ function DesktopCommands() {
|
||||
type ServerScopedShellProps = ParentProps<{
|
||||
directory?: () => string | undefined
|
||||
sessionID?: () => string | undefined
|
||||
serverScoped?: JSX.Element
|
||||
}>
|
||||
|
||||
function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<LayoutProvider>
|
||||
{props.serverScoped}
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
@@ -277,16 +326,16 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||
|
||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
|
||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}>
|
||||
<LegacyLayout>{props.children}</LegacyLayout>
|
||||
</ServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function NewAppLayout(props: ParentProps) {
|
||||
function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||
return (
|
||||
<SelectedServerProviders>
|
||||
<ServerScopedProviders>
|
||||
<ServerScopedProviders serverScoped={props.serverScoped}>
|
||||
<NewLayout>{props.children}</NewLayout>
|
||||
</ServerScopedProviders>
|
||||
</SelectedServerProviders>
|
||||
@@ -318,8 +367,8 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
<MetaProvider>
|
||||
<Font />
|
||||
<ThemeProvider
|
||||
onThemeApplied={(_, mode) => {
|
||||
void window.api?.setTitlebar?.({ mode })
|
||||
onThemeApplied={(_, mode, scheme) => {
|
||||
void window.api?.setTitlebar?.({ mode, scheme })
|
||||
}}
|
||||
>
|
||||
<LanguageProvider locale={props.locale}>
|
||||
@@ -347,7 +396,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
||||
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise<void> }>) {
|
||||
const server = useServer()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
@@ -376,34 +425,45 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
||||
const checking = createMemo(
|
||||
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
|
||||
)
|
||||
const [startup] = createResource(async () => {
|
||||
if (!props.startup) return true
|
||||
await props.startup.catch((error) => {
|
||||
console.error("[startup] startup gate failed", error)
|
||||
})
|
||||
return true
|
||||
})
|
||||
const startupChecking = createMemo(
|
||||
() => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state),
|
||||
)
|
||||
const loading = createMemo(() => checking() || startupChecking())
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!checking()}
|
||||
fallback={
|
||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<>
|
||||
<Show when={!checking()}>
|
||||
<Show
|
||||
when={startupHealthCheck.latest}
|
||||
fallback={
|
||||
<ConnectionError
|
||||
onRetry={() => {
|
||||
if (checkMode() === "background") void healthCheckActions.refetch()
|
||||
}}
|
||||
onServerSelected={(key) => {
|
||||
setCheckMode("blocking")
|
||||
server.setActive(key)
|
||||
void healthCheckActions.refetch()
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={loading()}>
|
||||
<div class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={startupHealthCheck.latest}
|
||||
fallback={
|
||||
<ConnectionError
|
||||
onRetry={() => {
|
||||
if (checkMode() === "background") void healthCheckActions.refetch()
|
||||
}}
|
||||
onServerSelected={(key) => {
|
||||
setCheckMode("blocking")
|
||||
server.setActive(key)
|
||||
void healthCheckActions.refetch()
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -470,6 +530,8 @@ export function AppInterface(props: {
|
||||
servers?: Array<ServerConnection.Any>
|
||||
router?: Component<BaseRouterProps>
|
||||
disableHealthCheck?: boolean
|
||||
startup?: Promise<void>
|
||||
serverScoped?: JSX.Element
|
||||
}) {
|
||||
// The visual new layout lives in the router root so it remains mounted across
|
||||
// route changes. Draft and session routes override only their server-bound data
|
||||
@@ -491,7 +553,7 @@ export function AppInterface(props: {
|
||||
>
|
||||
<GlobalProvider>
|
||||
<SettingsProvider>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
||||
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
@@ -500,14 +562,14 @@ export function AppInterface(props: {
|
||||
<NotificationProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
</NotificationProvider>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
<Routes />
|
||||
<Routes serverScoped={props.serverScoped} />
|
||||
</Dynamic>
|
||||
</Show>
|
||||
</ConnectionGate>
|
||||
@@ -517,13 +579,24 @@ export function AppInterface(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function Routes() {
|
||||
function Routes(props: { serverScoped?: JSX.Element }) {
|
||||
const settings = useSettings()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Route component={LegacyServerLayout}>
|
||||
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
|
||||
<Route
|
||||
component={(routeProps) => (
|
||||
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
|
||||
)}
|
||||
>
|
||||
<Show when={!settings.general.newLayoutDesigns()}>
|
||||
{
|
||||
<>
|
||||
<Route path="/" component={LegacyHome} />
|
||||
<Route path="/server/:serverKey/session/:id" component={LegacyTargetSessionRoute} />
|
||||
</>
|
||||
}
|
||||
</Show>
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
@@ -531,15 +604,15 @@ function Routes() {
|
||||
</Route>
|
||||
<Show when={settings.general.newLayoutDesigns()}>
|
||||
<Route path="/" component={NewHome} />
|
||||
<Route path="/:dir/session/:id" component={LegacyTargetSessionRoute} />
|
||||
<Route path="/:dir/session/:id" component={NewLayoutLegacySessionRedirect} />
|
||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||
</Show>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyTargetSessionRoute() {
|
||||
function NewLayoutLegacySessionRedirect() {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const params = useParams<{ id: string }>()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,325 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
export type CommandPaletteEntry = {
|
||||
id: string
|
||||
type: "command" | "file" | "session"
|
||||
title: string
|
||||
description?: string
|
||||
keybind?: string
|
||||
category: string
|
||||
option?: CommandOption
|
||||
path?: string
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
archived?: number
|
||||
updated?: number
|
||||
}
|
||||
|
||||
const ENTRY_LIMIT = 5
|
||||
const COMMON_COMMAND_IDS = [
|
||||
"session.new",
|
||||
"workspace.new",
|
||||
"session.previous",
|
||||
"session.next",
|
||||
"terminal.toggle",
|
||||
"review.toggle",
|
||||
] as const
|
||||
|
||||
export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) {
|
||||
const seen = new Set<string>()
|
||||
return items.filter((item) => {
|
||||
if (seen.has(item.id)) return false
|
||||
seen.add(item.id)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry {
|
||||
return {
|
||||
id: "file:" + path,
|
||||
type: "file",
|
||||
title: path,
|
||||
category,
|
||||
path,
|
||||
}
|
||||
}
|
||||
|
||||
export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) {
|
||||
const file = useFile()
|
||||
const layout = useLayout()
|
||||
const { tabs, view } = useSessionLayout()
|
||||
|
||||
return (path: string) => {
|
||||
const value = file.tab(path)
|
||||
void tabs().open(value)
|
||||
void file.load(path)
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
layout.fileTree.setTab("all")
|
||||
onOpenFile?.(path)
|
||||
tabs().setActive(value)
|
||||
}
|
||||
}
|
||||
|
||||
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const layout = useLayout()
|
||||
const file = useFile()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const serverSDK = useServerSDK()()
|
||||
const serverSync = useServerSync()
|
||||
const { params, tabs } = useSessionLayout()
|
||||
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
|
||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||
const filesOnly = () => props.filesOnly?.() ?? false
|
||||
|
||||
const allowedCommands = createMemo(() => {
|
||||
if (filesOnly()) return []
|
||||
return command.options.filter(
|
||||
(option) =>
|
||||
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
||||
)
|
||||
})
|
||||
const commandEntries = createMemo(() => {
|
||||
const category = language.t("palette.group.commands")
|
||||
return allowedCommands().map((option) => createCommandEntry(option, category))
|
||||
})
|
||||
const preferredCommandEntries = createMemo(() => {
|
||||
const all = allowedCommands()
|
||||
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
|
||||
const picked = all.filter((option) => order.has(option.id))
|
||||
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
||||
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
||||
const category = language.t("palette.group.commands")
|
||||
return sorted.map((option) => createCommandEntry(option, category))
|
||||
})
|
||||
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
})
|
||||
const recentFileEntries = createMemo(() => {
|
||||
const all = tabState.openedTabs()
|
||||
const active = tabState.activeFileTab()
|
||||
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
||||
const seen = new Set<string>()
|
||||
const category = language.t("palette.group.files")
|
||||
return order
|
||||
.map((item) => file.pathFromTab(item))
|
||||
.filter((path): path is string => {
|
||||
if (!path || seen.has(path)) return false
|
||||
seen.add(path)
|
||||
return true
|
||||
})
|
||||
.slice(0, ENTRY_LIMIT)
|
||||
.map((path) => createCommandPaletteFileEntry(path, category))
|
||||
})
|
||||
const rootFileEntries = createMemo(() => {
|
||||
const category = language.t("palette.group.files")
|
||||
return file.tree
|
||||
.children("")
|
||||
.filter((node) => node.type === "file")
|
||||
.map((node) => node.path)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.slice(0, ENTRY_LIMIT)
|
||||
.map((path) => createCommandPaletteFileEntry(path, category))
|
||||
})
|
||||
|
||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||
const project = createMemo(() => {
|
||||
const directory = projectDirectory()
|
||||
if (!directory) return undefined
|
||||
return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
|
||||
})
|
||||
const workspaces = createMemo(() => {
|
||||
const directory = projectDirectory()
|
||||
const current = project()
|
||||
if (!current) return directory ? [directory] : []
|
||||
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
|
||||
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
||||
return dirs
|
||||
})
|
||||
const homedir = createMemo(() => serverSync().data.path.home)
|
||||
const sessions = createSessionEntries({
|
||||
workspaces,
|
||||
label: (directory) => {
|
||||
const current = project()
|
||||
const kind =
|
||||
current && directory === current.worktree
|
||||
? language.t("workspace.type.local")
|
||||
: language.t("workspace.type.sandbox")
|
||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||
const home = homedir()
|
||||
const path = home ? directory.replace(home, "~") : directory
|
||||
const name = store.vcs?.branch ?? getFilename(directory)
|
||||
return `${kind} : ${name || path}`
|
||||
},
|
||||
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
|
||||
untitled: () => language.t("command.session.new"),
|
||||
category: () => language.t("command.category.session"),
|
||||
})
|
||||
|
||||
const highlight = (item: CommandPaletteEntry | undefined) => {
|
||||
state.cleanup?.()
|
||||
state.cleanup = undefined
|
||||
if (item?.type !== "command") return
|
||||
state.cleanup = item.option?.onHighlight?.()
|
||||
}
|
||||
|
||||
const select = (item: CommandPaletteEntry | undefined) => {
|
||||
if (!item) return
|
||||
state.committed = true
|
||||
state.cleanup = undefined
|
||||
dialog.close()
|
||||
if (item.type === "command") {
|
||||
item.option?.onSelect?.("palette")
|
||||
return
|
||||
}
|
||||
if (item.type === "session") {
|
||||
if (!item.directory || !item.sessionID) return
|
||||
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
|
||||
return
|
||||
}
|
||||
if (!item.path) return
|
||||
openFile(item.path)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (state.committed) return
|
||||
state.cleanup?.()
|
||||
})
|
||||
|
||||
return {
|
||||
language,
|
||||
file,
|
||||
commandEntries,
|
||||
preferredCommandEntries,
|
||||
recentFileEntries,
|
||||
rootFileEntries,
|
||||
sessions,
|
||||
highlight,
|
||||
select,
|
||||
close: () => dialog.close(),
|
||||
}
|
||||
}
|
||||
|
||||
function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
|
||||
return {
|
||||
id: "command:" + option.id,
|
||||
type: "command",
|
||||
title: option.title,
|
||||
description: option.description,
|
||||
keybind: option.keybind,
|
||||
category,
|
||||
option,
|
||||
}
|
||||
}
|
||||
|
||||
function createSessionEntries(props: {
|
||||
workspaces: () => string[]
|
||||
label: (directory: string) => string
|
||||
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
|
||||
untitled: () => string
|
||||
category: () => string
|
||||
}) {
|
||||
const state: {
|
||||
token: number
|
||||
inflight: Promise<CommandPaletteEntry[]> | undefined
|
||||
cached: CommandPaletteEntry[] | undefined
|
||||
} = { token: 0, inflight: undefined, cached: undefined }
|
||||
|
||||
return (text: string) => {
|
||||
if (!text.trim()) {
|
||||
state.token += 1
|
||||
state.inflight = undefined
|
||||
state.cached = undefined
|
||||
return [] as CommandPaletteEntry[]
|
||||
}
|
||||
if (state.cached) return state.cached
|
||||
if (state.inflight) return state.inflight
|
||||
|
||||
const current = state.token
|
||||
const dirs = props.workspaces()
|
||||
if (dirs.length === 0) return [] as CommandPaletteEntry[]
|
||||
|
||||
state.inflight = Promise.all(
|
||||
dirs.map((directory) => {
|
||||
const description = props.label(directory)
|
||||
return props
|
||||
.load(directory)
|
||||
.then((result) =>
|
||||
(result.data ?? [])
|
||||
.filter((session) => !!session?.id)
|
||||
.map((session) => ({
|
||||
id: session.id,
|
||||
title: session.title ?? props.untitled(),
|
||||
description,
|
||||
directory,
|
||||
archived: session.time?.archived,
|
||||
updated: session.time?.updated,
|
||||
})),
|
||||
)
|
||||
.catch(() => [] as SessionEntryInput[])
|
||||
}),
|
||||
)
|
||||
.then((results) => {
|
||||
if (state.token !== current) return [] as CommandPaletteEntry[]
|
||||
const seen = new Set<string>()
|
||||
const next = results
|
||||
.flat()
|
||||
.filter((item) => {
|
||||
const key = `${item.directory}:${item.id}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
.map((item) => createSessionEntry(item, props.category()))
|
||||
state.cached = next
|
||||
return next
|
||||
})
|
||||
.catch(() => [] as CommandPaletteEntry[])
|
||||
.finally(() => {
|
||||
state.inflight = undefined
|
||||
})
|
||||
|
||||
return state.inflight
|
||||
}
|
||||
}
|
||||
|
||||
type SessionEntryInput = {
|
||||
directory: string
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
archived?: number
|
||||
updated?: number
|
||||
}
|
||||
|
||||
function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
|
||||
return {
|
||||
id: `session:${input.directory}:${input.id}`,
|
||||
type: "session",
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
category,
|
||||
directory: input.directory,
|
||||
sessionID: input.id,
|
||||
archived: input.archived,
|
||||
updated: input.updated,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
.command-palette-v2 {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the
|
||||
top stays put while the content-driven height grows and shrinks. */
|
||||
[data-component="dialog-v2"]:has(.command-palette-v2) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] {
|
||||
width: min(calc(100vw - 24px), 640px);
|
||||
height: auto;
|
||||
min-height: 280px;
|
||||
max-height: min(calc(100vh - 96px), 480px);
|
||||
margin-top: max(48px, calc((100vh - 480px) / 2));
|
||||
border-radius: 12px;
|
||||
background: var(--v2-background-bg-base);
|
||||
box-shadow: var(--v2-elevation-floating);
|
||||
}
|
||||
|
||||
.command-palette-v2-body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.command-palette-v2-search {
|
||||
flex-shrink: 0;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.command-palette-v2-search [data-component="text-input-v2"] {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
outline: 0;
|
||||
background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent);
|
||||
box-shadow: none;
|
||||
transition:
|
||||
background-color 120ms ease-in-out,
|
||||
box-shadow 120ms ease-in-out;
|
||||
}
|
||||
|
||||
.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]),
|
||||
.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.command-palette-v2-scroll {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.command-palette-v2-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 6px 6px 8px;
|
||||
}
|
||||
|
||||
.command-palette-v2-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.command-palette-v2-group-title {
|
||||
margin: 6px 0;
|
||||
padding: 0 12px;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.04px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.command-palette-v2-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--v2-text-text-base);
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
scroll-margin: 6px 0;
|
||||
}
|
||||
|
||||
.command-palette-v2-row[data-active] {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
.command-palette-v2-row:focus-visible {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.command-palette-v2-row-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.command-palette-v2-row-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
|
||||
.command-palette-v2-row-text {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.command-palette-v2-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.04px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.command-palette-v2-description,
|
||||
.command-palette-v2-meta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.04px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.command-palette-v2-meta {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.command-palette-v2-file-path {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.04px;
|
||||
}
|
||||
|
||||
.command-palette-v2-file-dir {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-weight: 440;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.command-palette-v2-file-name {
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: 530;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.command-palette-v2-state {
|
||||
display: grid;
|
||||
min-height: 120px;
|
||||
place-items: center;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.04px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.command-palette-v2-row-text {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.command-palette-v2-description {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { formatKeybindParts } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
import {
|
||||
createCommandPaletteFileEntry,
|
||||
createCommandPaletteModel,
|
||||
uniqueCommandPaletteEntries,
|
||||
type CommandPaletteEntry,
|
||||
} from "./command-palette"
|
||||
import "./dialog-command-palette-v2.css"
|
||||
|
||||
function groups(entries: CommandPaletteEntry[]) {
|
||||
const map = new Map<string, CommandPaletteEntry[]>()
|
||||
for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry])
|
||||
return Array.from(map.entries()).map(([category, entries]) => ({ category, entries }))
|
||||
}
|
||||
|
||||
function matchesEntry(entry: CommandPaletteEntry, query: string) {
|
||||
const value = query.toLowerCase()
|
||||
return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value))
|
||||
}
|
||||
|
||||
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
|
||||
const palette = createCommandPaletteModel(props)
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [active, setActive] = createSignal(0)
|
||||
|
||||
const loadItems = async (text: string) => {
|
||||
const q = text.trim()
|
||||
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
|
||||
|
||||
const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))])
|
||||
const category = palette.language.t("palette.group.files")
|
||||
return [
|
||||
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
|
||||
...nextSessions.filter((entry) => matchesEntry(entry, q)),
|
||||
...files.map((path) => createCommandPaletteFileEntry(path, category)),
|
||||
]
|
||||
}
|
||||
|
||||
const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
|
||||
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
|
||||
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
|
||||
const groupedEntries = createMemo(() => groups(visibleEntries()))
|
||||
const activeEntry = createMemo(() => visibleEntries()[active()])
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
visibleEntries()
|
||||
setActive(0)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
palette.highlight(activeEntry())
|
||||
})
|
||||
|
||||
let resultsRef: HTMLDivElement | undefined
|
||||
|
||||
const move = (delta: -1 | 1) => {
|
||||
const count = visibleEntries().length
|
||||
if (count === 0) return
|
||||
setActive((index) => (index + delta + count) % count)
|
||||
requestAnimationFrame(() => {
|
||||
resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" })
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
move(1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
move(-1)
|
||||
return
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
palette.select(activeEntry())
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
palette.close()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog class="command-palette-v2" size="large">
|
||||
<DialogBody class="command-palette-v2-body">
|
||||
<div class="command-palette-v2-search">
|
||||
<TextInputV2
|
||||
value={query()}
|
||||
autofocus
|
||||
autocomplete="off"
|
||||
spellcheck={false}
|
||||
appearance="large"
|
||||
placeholder={palette.language.t("palette.search.placeholder")}
|
||||
leadingIcon={<Icon name="magnifying-glass" />}
|
||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<ScrollView class="command-palette-v2-scroll" viewportRef={(el) => (resultsRef = el)}>
|
||||
<div class="command-palette-v2-results" role="listbox">
|
||||
<Show
|
||||
when={visibleEntries().length > 0}
|
||||
fallback={
|
||||
<div class="command-palette-v2-state">
|
||||
{entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={groupedEntries()}>
|
||||
{(group) => (
|
||||
<div class="command-palette-v2-group">
|
||||
<Show when={group.category}>
|
||||
<div class="command-palette-v2-group-title">{group.category}</div>
|
||||
</Show>
|
||||
<For each={group.entries}>
|
||||
{(item) => (
|
||||
<PaletteRow
|
||||
item={item}
|
||||
active={activeEntry()?.id === item.id}
|
||||
language={palette.language}
|
||||
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
|
||||
onSelect={() => palette.select(item)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function PaletteRow(props: {
|
||||
item: CommandPaletteEntry
|
||||
active: boolean
|
||||
language: ReturnType<typeof useLanguage>
|
||||
onActive: () => void
|
||||
onSelect: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="command-palette-v2-row"
|
||||
role="option"
|
||||
aria-selected={props.active}
|
||||
data-active={props.active ? "" : undefined}
|
||||
onMouseMove={(event) => {
|
||||
// Ignore hover from a static cursor when keyboard scrolling moves rows underneath it.
|
||||
if (event.movementX === 0 && event.movementY === 0) return
|
||||
props.onActive()
|
||||
}}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={props.onSelect}
|
||||
>
|
||||
<Switch
|
||||
fallback={
|
||||
<div class="command-palette-v2-row-main">
|
||||
<FileIcon node={{ path: props.item.path ?? "", type: "file" }} class="command-palette-v2-row-icon size-4" />
|
||||
<div class="command-palette-v2-file-path">
|
||||
<span class="command-palette-v2-file-dir">{getDirectory(props.item.path ?? "")}</span>
|
||||
<span class="command-palette-v2-file-name">{getFilename(props.item.path ?? "")}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Match when={props.item.type === "command"}>
|
||||
<div class="command-palette-v2-row-main">
|
||||
<div class="command-palette-v2-row-text">
|
||||
<span class="command-palette-v2-title">{props.item.title}</span>
|
||||
<Show when={props.item.description}>
|
||||
<span class="command-palette-v2-description">{props.item.description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.item.keybind}>
|
||||
<KeybindV2 keys={formatKeybindParts(props.item.keybind ?? "", props.language.t)} variant="neutral" />
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={props.item.type === "session"}>
|
||||
<div class="command-palette-v2-row-main">
|
||||
<Icon name="status" class="command-palette-v2-row-icon" />
|
||||
<div class="command-palette-v2-row-text">
|
||||
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
|
||||
{props.item.title}
|
||||
</span>
|
||||
<Show when={props.item.description}>
|
||||
<span class="command-palette-v2-description" classList={{ "opacity-70": !!props.item.archived }}>
|
||||
{props.item.description}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.item.updated}>
|
||||
<span class="command-palette-v2-meta">
|
||||
{getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)}
|
||||
</span>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -7,29 +7,174 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
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 { Tag } from "@opencode-ai/ui/tag"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||
import {
|
||||
type Accessor,
|
||||
type Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
Match,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
Switch,
|
||||
} from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||
|
||||
export function DialogConnectProvider(props: { provider: string; directory?: Accessor<string | undefined> }) {
|
||||
const CUSTOM_ID = "_custom"
|
||||
|
||||
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||
const reset = () => setStore("selected", undefined)
|
||||
|
||||
return {
|
||||
selected: () => store.selected,
|
||||
select: (provider?: string) => setStore("selected", provider),
|
||||
back: options.onBack ?? reset,
|
||||
}
|
||||
}
|
||||
|
||||
export const DialogConnectProvider: Component<{
|
||||
directory?: Accessor<string | undefined>
|
||||
controller?: ReturnType<typeof useProviderConnectController>
|
||||
}> = (props) => {
|
||||
const fallback = useProviderConnectController()
|
||||
const controller = props.controller ?? fallback
|
||||
const language = useLanguage()
|
||||
const reset = controller.back
|
||||
const back = { current: reset }
|
||||
const select = (provider?: string) => {
|
||||
back.current = reset
|
||||
controller.select(provider)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
class="h-full"
|
||||
transition
|
||||
title={
|
||||
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={() => back.current()}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={controller.selected() === CUSTOM_ID}>
|
||||
<CustomProviderForm />
|
||||
</Match>
|
||||
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
||||
{(provider) => (
|
||||
<ProviderConnection
|
||||
provider={provider()}
|
||||
directory={props.directory}
|
||||
onBack={reset}
|
||||
setBack={(handler) => (back.current = handler)}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<ProviderPicker directory={props.directory} onSelect={select} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSelect: (provider: string) => void }) {
|
||||
const providers = useProviders(props.directory)
|
||||
const language = useLanguage()
|
||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||
const customLabel = () => language.t("settings.providers.tag.custom")
|
||||
const note = (id: string) => {
|
||||
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
|
||||
if (id === "openai") return language.t("dialog.provider.openai.note")
|
||||
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
|
||||
if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
class="px-3"
|
||||
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
|
||||
emptyMessage={language.t("dialog.provider.empty")}
|
||||
activeIcon="plus-small"
|
||||
key={(x) => x?.id}
|
||||
items={() => {
|
||||
language.locale()
|
||||
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
|
||||
}}
|
||||
filterKeys={["id", "name"]}
|
||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
||||
sortBy={(a, b) => {
|
||||
if (a.id === CUSTOM_ID) return -1
|
||||
if (b.id === CUSTOM_ID) return 1
|
||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
|
||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
||||
return a.name.localeCompare(b.name)
|
||||
}}
|
||||
sortGroupsBy={(a, b) => {
|
||||
const popular = popularGroup()
|
||||
if (a.category === popular && b.category !== popular) return -1
|
||||
if (b.category === popular && a.category !== popular) return 1
|
||||
return 0
|
||||
}}
|
||||
onSelect={(x) => {
|
||||
if (!x) return
|
||||
props.onSelect(x.id)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="px-1.25 w-full flex items-center gap-x-3">
|
||||
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
|
||||
<span>{i.name}</span>
|
||||
<Show when={i.id === "opencode"}>
|
||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
|
||||
</Show>
|
||||
<Show when={i.id === CUSTOM_ID}>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</Show>
|
||||
<Show when={i.id === "opencode"}>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
|
||||
<Show when={i.id === "opencode-go"}>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderConnection(props: {
|
||||
provider: string
|
||||
directory?: Accessor<string | undefined>
|
||||
onBack: () => void
|
||||
setBack: (handler: () => void) => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const providers = useProviders(props.directory)
|
||||
|
||||
const all = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider directory={props.directory} />)
|
||||
})
|
||||
}
|
||||
|
||||
const alive = { value: true }
|
||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
|
||||
@@ -364,21 +509,15 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (methods().length === 1) {
|
||||
all()
|
||||
return
|
||||
}
|
||||
if (store.authorization) {
|
||||
if (methods().length > 1 && store.methodIndex !== undefined) {
|
||||
dispatch({ type: "method.reset" })
|
||||
return
|
||||
}
|
||||
if (store.methodIndex !== undefined) {
|
||||
dispatch({ type: "method.reset" })
|
||||
return
|
||||
}
|
||||
all()
|
||||
props.onBack()
|
||||
}
|
||||
|
||||
props.setBack(goBack)
|
||||
|
||||
function MethodSelection() {
|
||||
return (
|
||||
<>
|
||||
@@ -599,79 +738,67 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={goBack}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-6 px-2.5 pb-3">
|
||||
<div class="px-2.5 flex gap-4 items-center">
|
||||
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">
|
||||
<Switch>
|
||||
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
||||
{language.t("provider.connect.title.anthropicProMax")}
|
||||
</Match>
|
||||
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.methodIndex === undefined}>
|
||||
<MethodSelection />
|
||||
</Match>
|
||||
<Match when={store.state === "pending"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.state === "prompt"}>
|
||||
<AuthPromptsView />
|
||||
</Match>
|
||||
<Match when={store.state === "error"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={method()?.type === "api"}>
|
||||
<ApiAuthView />
|
||||
</Match>
|
||||
<Match when={method()?.type === "oauth"}>
|
||||
<Switch>
|
||||
<Match when={store.authorization?.method === "code"}>
|
||||
<OAuthCodeView />
|
||||
</Match>
|
||||
<Match when={store.authorization?.method === "auto"}>
|
||||
<OAuthAutoView />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<div class="flex flex-col gap-6 px-2.5 pb-3">
|
||||
<div class="px-2.5 flex gap-4 items-center">
|
||||
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">
|
||||
<Switch>
|
||||
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
||||
{language.t("provider.connect.title.anthropicProMax")}
|
||||
</Match>
|
||||
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.methodIndex === undefined}>
|
||||
<MethodSelection />
|
||||
</Match>
|
||||
<Match when={store.state === "pending"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.state === "prompt"}>
|
||||
<AuthPromptsView />
|
||||
</Match>
|
||||
<Match when={store.state === "error"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={method()?.type === "api"}>
|
||||
<ApiAuthView />
|
||||
</Match>
|
||||
<Match when={method()?.type === "oauth"}>
|
||||
<Switch>
|
||||
<Match when={store.authorization?.method === "code"}>
|
||||
<OAuthCodeView />
|
||||
</Match>
|
||||
<Match when={store.authorization?.method === "auto"}>
|
||||
<OAuthAutoView />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,21 +6,41 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { type Accessor, batch, For } from "solid-js"
|
||||
import { batch, For } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
|
||||
type Props = {
|
||||
back?: "providers" | "close"
|
||||
directory?: Accessor<string | undefined>
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function DialogCustomProvider(props: Props) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
class="h-full"
|
||||
title={
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={props.onBack}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
}
|
||||
transition
|
||||
>
|
||||
<CustomProviderForm />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function CustomProviderForm() {
|
||||
const dialog = useDialog()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
@@ -36,14 +56,6 @@ export function DialogCustomProvider(props: Props) {
|
||||
err: {},
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
if (props.back === "close") {
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogSelectProvider directory={props.directory} />)
|
||||
}
|
||||
|
||||
const addModel = () => {
|
||||
setForm(
|
||||
"models",
|
||||
@@ -163,168 +175,155 @@ export function DialogCustomProvider(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={goBack}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
}
|
||||
transition
|
||||
>
|
||||
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
||||
<div class="px-2.5 flex gap-4 items-center">
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
||||
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
||||
<div class="px-2.5 flex gap-4 items-center">
|
||||
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
||||
<p class="text-14-regular text-text-base">
|
||||
{language.t("provider.custom.description.prefix")}
|
||||
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
||||
{language.t("provider.custom.description.link")}
|
||||
</Link>
|
||||
{language.t("provider.custom.description.suffix")}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
label={language.t("provider.custom.field.providerID.label")}
|
||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||
description={language.t("provider.custom.field.providerID.description")}
|
||||
value={form.providerID}
|
||||
onChange={(v) => setField("providerID", v)}
|
||||
validationState={form.err.providerID ? "invalid" : undefined}
|
||||
error={form.err.providerID}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.name.label")}
|
||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||
value={form.name}
|
||||
onChange={(v) => setField("name", v)}
|
||||
validationState={form.err.name ? "invalid" : undefined}
|
||||
error={form.err.name}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.baseURL.label")}
|
||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||
value={form.baseURL}
|
||||
onChange={(v) => setField("baseURL", v)}
|
||||
validationState={form.err.baseURL ? "invalid" : undefined}
|
||||
error={form.err.baseURL}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.apiKey.label")}
|
||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||
description={language.t("provider.custom.field.apiKey.description")}
|
||||
value={form.apiKey}
|
||||
onChange={(v) => setField("apiKey", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
||||
<p class="text-14-regular text-text-base">
|
||||
{language.t("provider.custom.description.prefix")}
|
||||
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
||||
{language.t("provider.custom.description.link")}
|
||||
</Link>
|
||||
{language.t("provider.custom.description.suffix")}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
label={language.t("provider.custom.field.providerID.label")}
|
||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||
description={language.t("provider.custom.field.providerID.description")}
|
||||
value={form.providerID}
|
||||
onChange={(v) => setField("providerID", v)}
|
||||
validationState={form.err.providerID ? "invalid" : undefined}
|
||||
error={form.err.providerID}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.name.label")}
|
||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||
value={form.name}
|
||||
onChange={(v) => setField("name", v)}
|
||||
validationState={form.err.name ? "invalid" : undefined}
|
||||
error={form.err.name}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.baseURL.label")}
|
||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||
value={form.baseURL}
|
||||
onChange={(v) => setField("baseURL", v)}
|
||||
validationState={form.err.baseURL ? "invalid" : undefined}
|
||||
error={form.err.baseURL}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.apiKey.label")}
|
||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||
description={language.t("provider.custom.field.apiKey.description")}
|
||||
value={form.apiKey}
|
||||
onChange={(v) => setField("apiKey", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
||||
<For each={form.models}>
|
||||
{(m, i) => (
|
||||
<div class="flex gap-2 items-start" data-row={m.row}>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.models.id.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.models.id.placeholder")}
|
||||
value={m.id}
|
||||
onChange={(v) => setModel(i(), "id", v)}
|
||||
validationState={m.err.id ? "invalid" : undefined}
|
||||
error={m.err.id}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.models.name.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.models.name.placeholder")}
|
||||
value={m.name}
|
||||
onChange={(v) => setModel(i(), "name", v)}
|
||||
validationState={m.err.name ? "invalid" : undefined}
|
||||
error={m.err.name}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="trash"
|
||||
variant="ghost"
|
||||
class="mt-1.5"
|
||||
onClick={() => removeModel(i())}
|
||||
disabled={form.models.length <= 1}
|
||||
aria-label={language.t("provider.custom.models.remove")}
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
||||
<For each={form.models}>
|
||||
{(m, i) => (
|
||||
<div class="flex gap-2 items-start" data-row={m.row}>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.models.id.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.models.id.placeholder")}
|
||||
value={m.id}
|
||||
onChange={(v) => setModel(i(), "id", v)}
|
||||
validationState={m.err.id ? "invalid" : undefined}
|
||||
error={m.err.id}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
|
||||
{language.t("provider.custom.models.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
||||
<For each={form.headers}>
|
||||
{(h, i) => (
|
||||
<div class="flex gap-2 items-start" data-row={h.row}>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.headers.key.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
||||
value={h.key}
|
||||
onChange={(v) => setHeader(i(), "key", v)}
|
||||
validationState={h.err.key ? "invalid" : undefined}
|
||||
error={h.err.key}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.headers.value.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
||||
value={h.value}
|
||||
onChange={(v) => setHeader(i(), "value", v)}
|
||||
validationState={h.err.value ? "invalid" : undefined}
|
||||
error={h.err.value}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="trash"
|
||||
variant="ghost"
|
||||
class="mt-1.5"
|
||||
onClick={() => removeHeader(i())}
|
||||
disabled={form.headers.length <= 1}
|
||||
aria-label={language.t("provider.custom.headers.remove")}
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.models.name.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.models.name.placeholder")}
|
||||
value={m.name}
|
||||
onChange={(v) => setModel(i(), "name", v)}
|
||||
validationState={m.err.name ? "invalid" : undefined}
|
||||
error={m.err.name}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
|
||||
{language.t("provider.custom.headers.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="w-auto self-start"
|
||||
type="submit"
|
||||
size="large"
|
||||
variant="primary"
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="trash"
|
||||
variant="ghost"
|
||||
class="mt-1.5"
|
||||
onClick={() => removeModel(i())}
|
||||
disabled={form.models.length <= 1}
|
||||
aria-label={language.t("provider.custom.models.remove")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
|
||||
{language.t("provider.custom.models.add")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
||||
<For each={form.headers}>
|
||||
{(h, i) => (
|
||||
<div class="flex gap-2 items-start" data-row={h.row}>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.headers.key.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
||||
value={h.key}
|
||||
onChange={(v) => setHeader(i(), "key", v)}
|
||||
validationState={h.err.key ? "invalid" : undefined}
|
||||
error={h.err.key}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.headers.value.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
||||
value={h.value}
|
||||
onChange={(v) => setHeader(i(), "value", v)}
|
||||
validationState={h.err.value ? "invalid" : undefined}
|
||||
error={h.err.value}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="trash"
|
||||
variant="ghost"
|
||||
class="mt-1.5"
|
||||
onClick={() => removeHeader(i())}
|
||||
disabled={form.headers.length <= 1}
|
||||
aria-label={language.t("provider.custom.headers.remove")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
|
||||
{language.t("provider.custom.headers.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="w-auto self-start"
|
||||
type="submit"
|
||||
size="large"
|
||||
variant="primary"
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useLocal } from "@/context/local"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { SettingsListV2 } from "./settings-v2/parts/list"
|
||||
import { SettingsRowV2 } from "./settings-v2/parts/row"
|
||||
@@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => {
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
dialog.show(() => <DialogSelectProvider directory={directory} />)
|
||||
void dialog.show(() => <DialogConnectProvider directory={directory} />)
|
||||
}
|
||||
const providerRank = (id: string) => popularProviders.indexOf(id)
|
||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||
@@ -123,7 +123,7 @@ export const DialogManageModelsV2: Component = () => {
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
dialog.show(() => <DialogSelectProvider directory={directory} />)
|
||||
void dialog.show(() => <DialogConnectProvider directory={directory} />)
|
||||
}
|
||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||
const providerVisible = (providerID: string) =>
|
||||
|
||||
@@ -1,329 +1,81 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useFile } from "@/context/file"
|
||||
import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
|
||||
import { formatKeybind } from "@/context/command"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
import {
|
||||
createCommandPaletteFileEntry,
|
||||
createCommandPaletteFileOpener,
|
||||
createCommandPaletteModel,
|
||||
uniqueCommandPaletteEntries,
|
||||
type CommandPaletteEntry,
|
||||
} from "./command-palette"
|
||||
import { DialogCommandPaletteV2 } from "./dialog-command-palette-v2"
|
||||
|
||||
const DialogSelectFileV2 = lazy(() =>
|
||||
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
|
||||
)
|
||||
|
||||
type EntryType = "command" | "file" | "session"
|
||||
|
||||
type Entry = {
|
||||
id: string
|
||||
type: EntryType
|
||||
title: string
|
||||
description?: string
|
||||
keybind?: string
|
||||
category: string
|
||||
option?: CommandOption
|
||||
path?: string
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
archived?: number
|
||||
updated?: number
|
||||
}
|
||||
|
||||
type DialogSelectFileMode = "all" | "files"
|
||||
|
||||
const ENTRY_LIMIT = 5
|
||||
const COMMON_COMMAND_IDS = [
|
||||
"session.new",
|
||||
"workspace.new",
|
||||
"session.previous",
|
||||
"session.next",
|
||||
"terminal.toggle",
|
||||
"review.toggle",
|
||||
] as const
|
||||
|
||||
const uniqueEntries = (items: Entry[]) => {
|
||||
const seen = new Set<string>()
|
||||
const out: Entry[] = []
|
||||
for (const item of items) {
|
||||
if (seen.has(item.id)) continue
|
||||
seen.add(item.id)
|
||||
out.push(item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const createCommandEntry = (option: CommandOption, category: string): Entry => ({
|
||||
id: "command:" + option.id,
|
||||
type: "command",
|
||||
title: option.title,
|
||||
description: option.description,
|
||||
keybind: option.keybind,
|
||||
category,
|
||||
option,
|
||||
})
|
||||
|
||||
const createFileEntry = (path: string, category: string): Entry => ({
|
||||
id: "file:" + path,
|
||||
type: "file",
|
||||
title: path,
|
||||
category,
|
||||
path,
|
||||
})
|
||||
|
||||
const createSessionEntry = (
|
||||
input: {
|
||||
directory: string
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
archived?: number
|
||||
updated?: number
|
||||
},
|
||||
category: string,
|
||||
): Entry => ({
|
||||
id: `session:${input.directory}:${input.id}`,
|
||||
type: "session",
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
category,
|
||||
directory: input.directory,
|
||||
sessionID: input.id,
|
||||
archived: input.archived,
|
||||
updated: input.updated,
|
||||
})
|
||||
|
||||
function createCommandEntries(props: {
|
||||
filesOnly: () => boolean
|
||||
command: ReturnType<typeof useCommand>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const allowed = createMemo(() => {
|
||||
if (props.filesOnly()) return []
|
||||
return props.command.options.filter(
|
||||
(option) =>
|
||||
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
||||
)
|
||||
})
|
||||
|
||||
const list = createMemo(() => {
|
||||
const category = props.language.t("palette.group.commands")
|
||||
return allowed().map((option) => createCommandEntry(option, category))
|
||||
})
|
||||
|
||||
const picks = createMemo(() => {
|
||||
const all = allowed()
|
||||
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
|
||||
const picked = all.filter((option) => order.has(option.id))
|
||||
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
||||
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
||||
const category = props.language.t("palette.group.commands")
|
||||
return sorted.map((option) => createCommandEntry(option, category))
|
||||
})
|
||||
|
||||
return { allowed, list, picks }
|
||||
}
|
||||
|
||||
function createFileEntries(props: {
|
||||
file: ReturnType<typeof useFile>
|
||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const tabState = createSessionTabs({
|
||||
tabs: props.tabs,
|
||||
pathFromTab: props.file.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
|
||||
})
|
||||
const recent = createMemo(() => {
|
||||
const all = tabState.openedTabs()
|
||||
const active = tabState.activeFileTab()
|
||||
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
||||
const seen = new Set<string>()
|
||||
const category = props.language.t("palette.group.files")
|
||||
const items: Entry[] = []
|
||||
|
||||
for (const item of order) {
|
||||
const path = props.file.pathFromTab(item)
|
||||
if (!path) continue
|
||||
if (seen.has(path)) continue
|
||||
seen.add(path)
|
||||
items.push(createFileEntry(path, category))
|
||||
}
|
||||
|
||||
return items.slice(0, ENTRY_LIMIT)
|
||||
})
|
||||
|
||||
const root = createMemo(() => {
|
||||
const category = props.language.t("palette.group.files")
|
||||
const nodes = props.file.tree.children("")
|
||||
const paths = nodes
|
||||
.filter((node) => node.type === "file")
|
||||
.map((node) => node.path)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
|
||||
})
|
||||
|
||||
return { recent, root }
|
||||
}
|
||||
|
||||
function createSessionEntries(props: {
|
||||
workspaces: () => string[]
|
||||
label: (directory: string) => string
|
||||
serverSDK: ServerSDK
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const state: {
|
||||
token: number
|
||||
inflight: Promise<Entry[]> | undefined
|
||||
cached: Entry[] | undefined
|
||||
} = {
|
||||
token: 0,
|
||||
inflight: undefined,
|
||||
cached: undefined,
|
||||
}
|
||||
|
||||
const sessions = (text: string) => {
|
||||
const query = text.trim()
|
||||
if (!query) {
|
||||
state.token += 1
|
||||
state.inflight = undefined
|
||||
state.cached = undefined
|
||||
return [] as Entry[]
|
||||
}
|
||||
|
||||
if (state.cached) return state.cached
|
||||
if (state.inflight) return state.inflight
|
||||
|
||||
const current = state.token
|
||||
const dirs = props.workspaces()
|
||||
if (dirs.length === 0) return [] as Entry[]
|
||||
|
||||
state.inflight = Promise.all(
|
||||
dirs.map((directory) => {
|
||||
const description = props.label(directory)
|
||||
return props.serverSDK.client.session
|
||||
.list({ directory, roots: true })
|
||||
.then((x) =>
|
||||
(x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title ?? props.language.t("command.session.new"),
|
||||
description,
|
||||
directory,
|
||||
archived: s.time?.archived,
|
||||
updated: s.time?.updated,
|
||||
})),
|
||||
)
|
||||
.catch(
|
||||
() =>
|
||||
[] as {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
directory: string
|
||||
archived?: number
|
||||
updated?: number
|
||||
}[],
|
||||
)
|
||||
}),
|
||||
)
|
||||
.then((results) => {
|
||||
if (state.token !== current) return [] as Entry[]
|
||||
const seen = new Set<string>()
|
||||
const category = props.language.t("command.category.session")
|
||||
const next = results
|
||||
.flat()
|
||||
.filter((item) => {
|
||||
const key = `${item.directory}:${item.id}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
.map((item) => createSessionEntry(item, category))
|
||||
state.cached = next
|
||||
return next
|
||||
})
|
||||
.catch(() => [] as Entry[])
|
||||
.finally(() => {
|
||||
state.inflight = undefined
|
||||
})
|
||||
|
||||
return state.inflight
|
||||
}
|
||||
|
||||
return { sessions }
|
||||
}
|
||||
|
||||
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const layout = useLayout()
|
||||
const file = useFile()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
const filesOnly = () => props.mode === "files"
|
||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||
const [grouped, setGrouped] = createSignal(false)
|
||||
const commandEntries = createCommandEntries({ filesOnly, command, language })
|
||||
const fileEntries = createFileEntries({ file, tabs, language })
|
||||
|
||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||
const project = createMemo(() => {
|
||||
const directory = projectDirectory()
|
||||
if (!directory) return
|
||||
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
|
||||
})
|
||||
const workspaces = createMemo(() => {
|
||||
const directory = projectDirectory()
|
||||
const current = project()
|
||||
if (!current) return directory ? [directory] : []
|
||||
|
||||
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
|
||||
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
||||
return dirs
|
||||
})
|
||||
const homedir = createMemo(() => serverSync().data.path.home)
|
||||
const label = (directory: string) => {
|
||||
const current = project()
|
||||
const kind =
|
||||
current && directory === current.worktree
|
||||
? language.t("workspace.type.local")
|
||||
: language.t("workspace.type.sandbox")
|
||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||
const home = homedir()
|
||||
const path = home ? directory.replace(home, "~") : directory
|
||||
const name = store.vcs?.branch ?? getFilename(directory)
|
||||
return `${kind} : ${name || path}`
|
||||
if (!filesOnly() && settings.general.newLayoutDesigns()) {
|
||||
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
|
||||
}
|
||||
|
||||
const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
|
||||
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
|
||||
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
|
||||
}
|
||||
|
||||
return <DialogSelectFileLegacy filesOnly={filesOnly} onOpenFile={props.onOpenFile} />
|
||||
}
|
||||
|
||||
function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const { params } = useSessionLayout()
|
||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
|
||||
|
||||
return (
|
||||
<DialogSelectFileV2
|
||||
server={serverSDK().server}
|
||||
mode="file"
|
||||
start={projectDirectory()}
|
||||
title={language.t("session.header.searchFiles")}
|
||||
onSelect={(result) => {
|
||||
if (typeof result !== "string") return
|
||||
openFile(result)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) {
|
||||
const palette = createCommandPaletteModel(props)
|
||||
const [grouped, setGrouped] = createSignal(false)
|
||||
|
||||
const items = async (text: string) => {
|
||||
const query = text.trim()
|
||||
setGrouped(query.length > 0)
|
||||
|
||||
if (!query && filesOnly()) {
|
||||
const loaded = file.tree.state("")?.loaded
|
||||
const pending = loaded ? Promise.resolve() : file.tree.list("")
|
||||
const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
|
||||
if (!query && props.filesOnly()) {
|
||||
const loaded = palette.file.tree.state("")?.loaded
|
||||
const pending = loaded ? Promise.resolve() : palette.file.tree.list("")
|
||||
const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
|
||||
|
||||
if (loaded || next.length > 0) {
|
||||
void pending
|
||||
@@ -331,79 +83,24 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
}
|
||||
|
||||
await pending
|
||||
return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
|
||||
return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
|
||||
}
|
||||
|
||||
if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
|
||||
if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
|
||||
|
||||
if (filesOnly()) {
|
||||
const files = await file.searchFiles(query)
|
||||
const category = language.t("palette.group.files")
|
||||
return files.map((path) => createFileEntry(path, category))
|
||||
if (props.filesOnly()) {
|
||||
const files = await palette.file.searchFiles(query)
|
||||
const category = palette.language.t("palette.group.files")
|
||||
return files.map((path) => createCommandPaletteFileEntry(path, category))
|
||||
}
|
||||
|
||||
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
|
||||
const category = language.t("palette.group.files")
|
||||
const entries = files.map((path) => createFileEntry(path, category))
|
||||
return [...commandEntries.list(), ...nextSessions, ...entries]
|
||||
}
|
||||
|
||||
const handleMove = (item: Entry | undefined) => {
|
||||
state.cleanup?.()
|
||||
if (!item) return
|
||||
if (item.type !== "command") return
|
||||
state.cleanup = item.option?.onHighlight?.()
|
||||
}
|
||||
|
||||
const open = (path: string) => {
|
||||
const value = file.tab(path)
|
||||
void tabs().open(value)
|
||||
void file.load(path)
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
layout.fileTree.setTab("all")
|
||||
props.onOpenFile?.(path)
|
||||
tabs().setActive(value)
|
||||
}
|
||||
|
||||
const handleSelect = (item: Entry | undefined) => {
|
||||
if (!item) return
|
||||
state.committed = true
|
||||
state.cleanup = undefined
|
||||
dialog.close()
|
||||
|
||||
if (item.type === "command") {
|
||||
item.option?.onSelect?.("palette")
|
||||
return
|
||||
}
|
||||
|
||||
if (item.type === "session") {
|
||||
if (!item.directory || !item.sessionID) return
|
||||
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!item.path) return
|
||||
open(item.path)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (state.committed) return
|
||||
state.cleanup?.()
|
||||
})
|
||||
|
||||
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
|
||||
return (
|
||||
<DialogSelectFileV2
|
||||
server={serverSDK().server}
|
||||
mode="file"
|
||||
start={projectDirectory()}
|
||||
title={language.t("session.header.searchFiles")}
|
||||
onSelect={(result) => {
|
||||
if (typeof result !== "string") return
|
||||
open(result)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
const [files, nextSessions] = await Promise.all([
|
||||
palette.file.searchFiles(query),
|
||||
Promise.resolve(palette.sessions(query)),
|
||||
])
|
||||
const category = palette.language.t("palette.group.files")
|
||||
const entries = files.map((path) => createCommandPaletteFileEntry(path, category))
|
||||
return [...palette.commandEntries(), ...nextSessions, ...entries]
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -411,21 +108,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
<List
|
||||
class="px-3"
|
||||
search={{
|
||||
placeholder: filesOnly()
|
||||
? language.t("session.header.searchFiles")
|
||||
: language.t("palette.search.placeholder"),
|
||||
placeholder: props.filesOnly()
|
||||
? palette.language.t("session.header.searchFiles")
|
||||
: palette.language.t("palette.search.placeholder"),
|
||||
autofocus: true,
|
||||
hideIcon: true,
|
||||
}}
|
||||
emptyMessage={language.t("palette.empty")}
|
||||
loadingMessage={language.t("common.loading")}
|
||||
emptyMessage={palette.language.t("palette.empty")}
|
||||
loadingMessage={palette.language.t("common.loading")}
|
||||
items={items}
|
||||
key={(item) => item.id}
|
||||
filterKeys={["title", "description", "category"]}
|
||||
skipFilter={(item) => item.type === "file"}
|
||||
groupBy={grouped() ? (item) => item.category : () => ""}
|
||||
onMove={handleMove}
|
||||
onSelect={handleSelect}
|
||||
onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)}
|
||||
onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)}
|
||||
>
|
||||
{(item) => (
|
||||
<Switch
|
||||
@@ -452,7 +149,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={item.keybind}>
|
||||
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind>
|
||||
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", palette.language.t)}</Keybind>
|
||||
</Show>
|
||||
</div>
|
||||
</Match>
|
||||
@@ -479,7 +176,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
</div>
|
||||
<Show when={item.updated}>
|
||||
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
|
||||
{getRelativeTime(new Date(item.updated!).toISOString(), language.t)}
|
||||
{getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { matchesModelSearch } from "./dialog-select-model-search"
|
||||
|
||||
describe("matchesModelSearch", () => {
|
||||
test("matches model names across separators", () => {
|
||||
expect(matchesModelSearch("gpt 5", ["GPT-5.5"])).toBe(true)
|
||||
expect(matchesModelSearch("gpt-5", ["GPT-5.5"])).toBe(true)
|
||||
expect(matchesModelSearch("gpt5", ["GPT-5.5"])).toBe(true)
|
||||
})
|
||||
|
||||
test("matches any searchable model field", () => {
|
||||
expect(matchesModelSearch("open ai", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(true)
|
||||
expect(matchesModelSearch("gpt 5", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(true)
|
||||
})
|
||||
|
||||
test("does not match unrelated searches", () => {
|
||||
expect(matchesModelSearch("claude", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
export const normalizeModelSearch = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{Letter}\p{Number}]+/gu, " ")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
|
||||
export const compactModelSearch = (value: string) => normalizeModelSearch(value).replaceAll(" ", "")
|
||||
|
||||
export const matchesModelSearch = (query: string, values: string[]) => {
|
||||
const search = normalizeModelSearch(query)
|
||||
if (!search) return true
|
||||
|
||||
const compactSearch = compactModelSearch(query)
|
||||
return values.some(
|
||||
(value) => normalizeModelSearch(value).includes(search) || compactModelSearch(value).includes(compactSearch),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
|
||||
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
|
||||
const local = useLocal()
|
||||
const model = props.model ?? local.model
|
||||
const dialog = useDialog()
|
||||
const directory = () => decode64(local.slug())
|
||||
const providers = useProviders(directory)
|
||||
const language = useLanguage()
|
||||
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
|
||||
const currentKey = createMemo(() => {
|
||||
const c = model.current()
|
||||
return c ? `${c.provider.id}:${c.id}` : undefined
|
||||
})
|
||||
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
|
||||
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
|
||||
|
||||
const openProviders = (provider?: string) => {
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
const controller = x.useProviderConnectController()
|
||||
controller.select(provider)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
|
||||
})
|
||||
}
|
||||
|
||||
const selectModel = (item: ReturnType<ModelState["list"]>[number]) => {
|
||||
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
// Focus starts on the dialog's close button, outside the list, so listen at the
|
||||
// document level while the dialog is mounted instead of on the list container.
|
||||
let listEl: HTMLDivElement | undefined
|
||||
onMount(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
|
||||
if (!listEl) return
|
||||
const buttons = Array.from(listEl.querySelectorAll<HTMLButtonElement>("button"))
|
||||
if (buttons.length === 0) return
|
||||
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
|
||||
const next =
|
||||
index < 0 ? (e.key === "ArrowDown" ? 0 : buttons.length - 1) : index + (e.key === "ArrowDown" ? 1 : -1)
|
||||
buttons[(next + buttons.length) % buttons.length]?.focus()
|
||||
e.preventDefault()
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogV2 containerClass="!h-[min(calc(100vh_-_16px),480px)] !w-[min(calc(100vw_-_16px),560px)]">
|
||||
<DialogHeader closeLabel={language.t("common.close")}>
|
||||
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="h-px w-full shrink-0 bg-v2-border-border-muted" />
|
||||
<DialogBody class="min-h-0 flex-1 gap-0">
|
||||
<ScrollView class="min-h-0 flex-1 w-full">
|
||||
<div ref={listEl} class="flex min-h-full flex-col">
|
||||
<div class="flex h-fit w-full flex-col items-start gap-0.5 px-3.5 pb-3.5 pt-3">
|
||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 pb-2 pt-1">
|
||||
<div class="flex h-5 flex-none flex-row items-center p-0 font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.model.unpaid.freeModels.title")}
|
||||
</div>
|
||||
</div>
|
||||
<For each={model.list()}>
|
||||
{(item) => (
|
||||
<TooltipV2
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
onClick={() => selectModel(item)}
|
||||
>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
<Show when={isFree(item)}>
|
||||
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
||||
</Show>
|
||||
<Show when={item.latest}>
|
||||
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
|
||||
</Show>
|
||||
<Show when={currentKey() === modelKey(item)}>
|
||||
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
|
||||
</Show>
|
||||
</button>
|
||||
</TooltipV2>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col p-2.5 pt-0">
|
||||
<div class="flex h-fit w-full flex-none grow-0 flex-col items-start gap-0.5 self-stretch rounded-lg bg-v2-background-bg-layer-02 p-1 shadow-[var(--v2-elevation-switch-off)]">
|
||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
|
||||
<div class="flex h-5 flex-none flex-row items-center p-0 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.model.unpaid.addMore.title")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col">
|
||||
<For
|
||||
each={[...providers.popular()].sort((a, b) => {
|
||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) {
|
||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})}
|
||||
>
|
||||
{(provider) => (
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
onClick={() => openProviders(provider.id)}
|
||||
>
|
||||
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{provider.name}</span>
|
||||
<Show when={provider.id === "opencode"}>
|
||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.provider.opencode.tagline")}
|
||||
</span>
|
||||
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={provider.id === "opencode-go"}>
|
||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.provider.opencodeGo.tagline")}
|
||||
</span>
|
||||
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={provider.id === "anthropic"}>
|
||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.provider.anthropic.note")}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-full scroll-my-3.5 flex-row items-center justify-start gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
onClick={() => openProviders()}
|
||||
>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Icon name="dot-grid" size="small" />
|
||||
</span>
|
||||
<span class="min-w-0 truncate text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.provider.viewAll")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</DialogBody>
|
||||
</DialogV2>
|
||||
)
|
||||
}
|
||||
@@ -22,17 +22,16 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||
const providers = useProviders(directory)
|
||||
const language = useLanguage()
|
||||
|
||||
const connect = (provider: string) => {
|
||||
const openProviders = (provider?: string) => {
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogConnectProvider provider={provider} directory={directory} />)
|
||||
const controller = x.useProviderConnectController()
|
||||
controller.select(provider)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
|
||||
})
|
||||
}
|
||||
|
||||
const all = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
||||
})
|
||||
}
|
||||
const connect = (provider: string) => openProviders(provider)
|
||||
const all = () => openProviders()
|
||||
|
||||
let listRef: ListRef | undefined
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||
import { Component, ComponentProps, createMemo, For, JSX, Show, ValidComponent } from "solid-js"
|
||||
import {
|
||||
Component,
|
||||
ComponentProps,
|
||||
createEffect,
|
||||
createMemo,
|
||||
For,
|
||||
JSX,
|
||||
onCleanup,
|
||||
Show,
|
||||
ValidComponent,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -14,9 +24,13 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { matchesModelSearch } from "./dialog-select-model-search"
|
||||
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
@@ -79,6 +93,7 @@ const ModelList: Component<{
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||
>
|
||||
{node}
|
||||
@@ -142,8 +157,8 @@ export function ModelSelectorPopover(props: {
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
close("provider")
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
|
||||
})
|
||||
}
|
||||
const language = useLanguage()
|
||||
@@ -242,14 +257,9 @@ export function ModelSelectorPopoverV2(props: {
|
||||
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
||||
)
|
||||
const models = createMemo(() => {
|
||||
const search = store.search.trim().toLowerCase()
|
||||
const search = store.search.trim()
|
||||
const filtered = search
|
||||
? allModels().filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(search) ||
|
||||
item.id.toLowerCase().includes(search) ||
|
||||
item.provider.name.toLowerCase().includes(search),
|
||||
)
|
||||
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
||||
: allModels()
|
||||
|
||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||
@@ -333,19 +343,23 @@ export function ModelSelectorPopoverV2(props: {
|
||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||
}
|
||||
const setSearch = (value: string) => {
|
||||
const search = value.trim().toLowerCase()
|
||||
const search = value.trim()
|
||||
const first = [...allModels()]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.find(
|
||||
(item) =>
|
||||
!search ||
|
||||
item.name.toLowerCase().includes(search) ||
|
||||
item.id.toLowerCase().includes(search) ||
|
||||
item.provider.name.toLowerCase().includes(search),
|
||||
)
|
||||
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
||||
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.open) return
|
||||
createEventListener(
|
||||
document,
|
||||
"keydown",
|
||||
(event: KeyboardEvent) => handleDocumentSearchKeydown(searchRef, event, store.search, setSearch),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
||||
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||
@@ -434,26 +448,41 @@ export function ModelSelectorPopoverV2(props: {
|
||||
<MenuV2.RadioGroup value={current()}>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<MenuV2.RadioItem
|
||||
value={modelKey(item)}
|
||||
data-option-key={modelKey(item)}
|
||||
data-selected-model={current() === modelKey(item) ? true : undefined}
|
||||
class="scroll-my-6"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||
onMouseEnter={() => {
|
||||
setStore("active", modelKey(item))
|
||||
setTimeout(() => searchRef?.focus())
|
||||
}}
|
||||
onSelect={() => selectModel(item)}
|
||||
<TooltipV2
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
latest={item.latest}
|
||||
free={isFree(item.provider.id, item.cost)}
|
||||
v2
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
|
||||
</Show>
|
||||
<Show when={item.latest}>
|
||||
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
|
||||
</Show>
|
||||
</MenuV2.RadioItem>
|
||||
<MenuV2.RadioItem
|
||||
value={modelKey(item)}
|
||||
data-option-key={modelKey(item)}
|
||||
data-selected-model={current() === modelKey(item) ? true : undefined}
|
||||
class="scroll-my-6 w-full"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||
onMouseEnter={() => {
|
||||
setStore("active", modelKey(item))
|
||||
setTimeout(() => searchRef?.focus())
|
||||
}}
|
||||
onSelect={() => selectModel(item)}
|
||||
>
|
||||
<span class="min-w-0 truncate leading-5">{item.name}</span>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
|
||||
</Show>
|
||||
<Show when={item.latest}>
|
||||
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
|
||||
</Show>
|
||||
</MenuV2.RadioItem>
|
||||
</TooltipV2>
|
||||
)}
|
||||
</For>
|
||||
</MenuV2.RadioGroup>
|
||||
@@ -491,8 +520,8 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const provider = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { type Accessor, Component, Show } from "solid-js"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
|
||||
const CUSTOM_ID = "_custom"
|
||||
|
||||
export const DialogSelectProvider: Component<{ directory?: Accessor<string | undefined> }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders(props.directory)
|
||||
const language = useLanguage()
|
||||
|
||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||
const customLabel = () => language.t("settings.providers.tag.custom")
|
||||
const note = (id: string) => {
|
||||
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
|
||||
if (id === "openai") return language.t("dialog.provider.openai.note")
|
||||
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
|
||||
if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("command.provider.connect")} transition>
|
||||
<List
|
||||
class="px-3"
|
||||
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
|
||||
emptyMessage={language.t("dialog.provider.empty")}
|
||||
activeIcon="plus-small"
|
||||
key={(x) => x?.id}
|
||||
items={() => {
|
||||
language.locale()
|
||||
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
|
||||
}}
|
||||
filterKeys={["id", "name"]}
|
||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
||||
sortBy={(a, b) => {
|
||||
if (a.id === CUSTOM_ID) return -1
|
||||
if (b.id === CUSTOM_ID) return 1
|
||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
|
||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
||||
return a.name.localeCompare(b.name)
|
||||
}}
|
||||
sortGroupsBy={(a, b) => {
|
||||
const popular = popularGroup()
|
||||
if (a.category === popular && b.category !== popular) return -1
|
||||
if (b.category === popular && a.category !== popular) return 1
|
||||
return 0
|
||||
}}
|
||||
onSelect={(x) => {
|
||||
if (!x) return
|
||||
if (x.id === CUSTOM_ID) {
|
||||
dialog.show(() => <DialogCustomProvider back="providers" directory={props.directory} />)
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogConnectProvider provider={x.id} directory={props.directory} />)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="px-1.25 w-full flex items-center gap-x-3">
|
||||
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
|
||||
<span>{i.name}</span>
|
||||
<Show when={i.id === "opencode"}>
|
||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
|
||||
</Show>
|
||||
<Show when={i.id === CUSTOM_ID}>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</Show>
|
||||
<Show when={i.id === "opencode"}>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
|
||||
<Show when={i.id === "opencode-go"}>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,35 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Component, createSignal, startTransition } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { SettingsGeneral } from "./settings-general"
|
||||
import { SettingsKeybinds } from "./settings-keybinds"
|
||||
import { SettingsProviders } from "./settings-providers"
|
||||
import { SettingsModels } from "./settings-models"
|
||||
import { SettingsServers } from "./settings-servers"
|
||||
|
||||
export const DialogSettings: Component = () => {
|
||||
export const DialogSettings: Component<{ defaultValue?: string }> = (props) => {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||
|
||||
const showProviders = () => {
|
||||
void dialog.show(() => <DialogSettings defaultValue="providers" />)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog size="x-large" transition>
|
||||
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
variant="settings"
|
||||
value={tab()}
|
||||
onChange={(value) => void startTransition(() => setTab(value))}
|
||||
class="h-full settings-dialog"
|
||||
>
|
||||
<Tabs.List>
|
||||
<div class="flex flex-col justify-between h-full w-full gap-4">
|
||||
<div class="flex flex-col gap-3 w-full pt-3">
|
||||
@@ -70,7 +83,7 @@ export const DialogSettings: Component = () => {
|
||||
<SettingsServers />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="providers" class="no-scrollbar">
|
||||
<SettingsProviders />
|
||||
<SettingsProviders onBack={showProviders} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="models" class="no-scrollbar">
|
||||
<SettingsModels />
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model"
|
||||
|
||||
describe("file tree v2 model", () => {
|
||||
test("builds sorted depth-first rows", () => {
|
||||
const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
|
||||
|
||||
expect(model.total).toBe(8)
|
||||
expect(flattenFileTreeV2(model, () => true).map((row) => [row.node.path, row.node.type, row.level])).toEqual([
|
||||
["docs", "directory", 0],
|
||||
["docs/guide.md", "file", 1],
|
||||
["src", "directory", 0],
|
||||
["src/lib", "directory", 1],
|
||||
["src/lib/a.ts", "file", 2],
|
||||
["src/lib/b.ts", "file", 2],
|
||||
["src/z.ts", "file", 1],
|
||||
["README.md", "file", 0],
|
||||
])
|
||||
})
|
||||
|
||||
test("omits descendants of collapsed directories", () => {
|
||||
const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
|
||||
|
||||
expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
|
||||
"src",
|
||||
"src/lib",
|
||||
"src/z.ts",
|
||||
])
|
||||
})
|
||||
|
||||
test("normalizes separators and duplicate paths", () => {
|
||||
const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
|
||||
const rows = flattenFileTreeV2(model, () => true)
|
||||
|
||||
expect(model.total).toBe(4)
|
||||
expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"])
|
||||
expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts")
|
||||
})
|
||||
|
||||
test("supports paths deeper than the legacy recursion limit", () => {
|
||||
const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts`
|
||||
const model = buildFileTreeV2Model([file])
|
||||
|
||||
expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export type FileTreeV2Model = {
|
||||
children: ReadonlyMap<string, readonly FileTreeV2Node[]>
|
||||
total: number
|
||||
}
|
||||
|
||||
export type FileTreeV2Node = FileNode & { originalPath: string }
|
||||
|
||||
export type FileTreeV2Row = {
|
||||
node: FileTreeV2Node
|
||||
level: number
|
||||
}
|
||||
|
||||
export function normalizeFileTreeV2Path(value: string) {
|
||||
return value
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.replace(/\/{2,}/g, "/")
|
||||
}
|
||||
|
||||
export function buildFileTreeV2Model(paths: readonly string[]): FileTreeV2Model {
|
||||
const nodes = new Map<string, FileTreeV2Node>()
|
||||
|
||||
paths.forEach((value) => {
|
||||
const file = normalizeFileTreeV2Path(value)
|
||||
if (!file) return
|
||||
|
||||
const parts = file.split("/")
|
||||
parts.forEach((name, index) => {
|
||||
const path = parts.slice(0, index + 1).join("/")
|
||||
if (nodes.has(path)) return
|
||||
nodes.set(path, {
|
||||
name,
|
||||
path,
|
||||
absolute: path,
|
||||
type: index === parts.length - 1 ? "file" : "directory",
|
||||
ignored: false,
|
||||
originalPath: index === parts.length - 1 ? value : path,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const children = new Map<string, FileTreeV2Node[]>()
|
||||
nodes.forEach((node) => {
|
||||
const index = node.path.lastIndexOf("/")
|
||||
const parent = index === -1 ? "" : node.path.slice(0, index)
|
||||
const list = children.get(parent)
|
||||
if (list) list.push(node)
|
||||
else children.set(parent, [node])
|
||||
})
|
||||
children.forEach((nodes) =>
|
||||
nodes.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
}),
|
||||
)
|
||||
|
||||
return { children, total: nodes.size }
|
||||
}
|
||||
|
||||
export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: string) => boolean) {
|
||||
const rows: FileTreeV2Row[] = []
|
||||
const stack = (model.children.get("") ?? []).toReversed().map((node) => ({ node, level: 0 }))
|
||||
|
||||
while (stack.length > 0) {
|
||||
const row = stack.pop()!
|
||||
rows.push(row)
|
||||
if (row.node.type !== "directory" || !expanded(row.node.path)) continue
|
||||
const children = model.children.get(row.node.path) ?? []
|
||||
for (let index = children.length - 1; index >= 0; index--) {
|
||||
stack.push({ node: children[index]!, level: row.level + 1 })
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -1,95 +1,26 @@
|
||||
import { useFile } from "@/context/file"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import "@opencode-ai/ui/v2/file-tree-v2.css"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
on,
|
||||
Show,
|
||||
splitProps,
|
||||
Switch,
|
||||
untrack,
|
||||
type ComponentProps,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import {
|
||||
dirsToExpand,
|
||||
pathToFileUrl,
|
||||
shouldListRoot,
|
||||
visibleKind,
|
||||
withFileDragImage,
|
||||
type Filter,
|
||||
type Kind,
|
||||
} from "@/components/file-tree"
|
||||
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
|
||||
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
||||
import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
|
||||
import { virtualScrollElement } from "@/components/virtual-scroll-element"
|
||||
|
||||
export type { Kind } from "@/components/file-tree"
|
||||
|
||||
const MAX_DEPTH = 128
|
||||
|
||||
function visibleNodesForPath(path: string, children: (dir: string) => FileNode[], current: Filter | undefined) {
|
||||
const nodes = children(path)
|
||||
if (!current) return nodes
|
||||
|
||||
const parent = (item: string) => {
|
||||
const idx = item.lastIndexOf("/")
|
||||
if (idx === -1) return ""
|
||||
return item.slice(0, idx)
|
||||
}
|
||||
|
||||
const leaf = (item: string) => {
|
||||
const idx = item.lastIndexOf("/")
|
||||
return idx === -1 ? item : item.slice(idx + 1)
|
||||
}
|
||||
|
||||
const out = nodes.filter((node) => {
|
||||
if (node.type === "file") return current.files.has(node.path)
|
||||
return current.dirs.has(node.path)
|
||||
})
|
||||
|
||||
const seen = new Set(out.map((node) => node.path))
|
||||
|
||||
for (const dir of current.dirs) {
|
||||
if (parent(dir) !== path) continue
|
||||
if (seen.has(dir)) continue
|
||||
out.push({
|
||||
name: leaf(dir),
|
||||
path: dir,
|
||||
absolute: dir,
|
||||
type: "directory",
|
||||
ignored: false,
|
||||
})
|
||||
seen.add(dir)
|
||||
}
|
||||
|
||||
for (const item of current.files) {
|
||||
if (parent(item) !== path) continue
|
||||
if (seen.has(item)) continue
|
||||
out.push({
|
||||
name: leaf(item),
|
||||
path: item,
|
||||
absolute: item,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
})
|
||||
seen.add(item)
|
||||
}
|
||||
|
||||
out.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const INDENT_STEP = 16
|
||||
|
||||
function rowPaddingLeft(level: number, type: FileNode["type"]) {
|
||||
@@ -123,7 +54,6 @@ const FileTreeNodeV2 = (
|
||||
active?: string
|
||||
draggable: boolean
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
marks?: Set<string>
|
||||
as?: "div" | "button"
|
||||
},
|
||||
) => {
|
||||
@@ -133,18 +63,18 @@ const FileTreeNodeV2 = (
|
||||
"active",
|
||||
"draggable",
|
||||
"kinds",
|
||||
"marks",
|
||||
"as",
|
||||
"children",
|
||||
"class",
|
||||
"classList",
|
||||
])
|
||||
const kind = () => visibleKind(local.node, local.kinds, local.marks)
|
||||
const kind = () => local.kinds?.get(local.node.path)
|
||||
|
||||
return (
|
||||
<Dynamic
|
||||
component={local.as ?? "div"}
|
||||
data-slot="file-tree-v2-row"
|
||||
data-path={local.node.path}
|
||||
data-selected={local.node.path === local.active ? "" : undefined}
|
||||
data-ignored={local.node.ignored ? "" : undefined}
|
||||
classList={{
|
||||
@@ -177,244 +107,161 @@ const FileTreeNodeV2 = (
|
||||
)
|
||||
}
|
||||
|
||||
// V2-styled fork of FileTree for the review sidebar. Unlike the v1 tree it never
|
||||
// lists unloaded subdirectories, so callers must pass `allowed` (nodes are
|
||||
// synthesized from that list) for nested content to appear.
|
||||
function GuideLines(props: { level: number }) {
|
||||
return (
|
||||
<For each={Array.from({ length: props.level })}>
|
||||
{(_, index) => (
|
||||
<div
|
||||
class="absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 group-hover/file-tree-v2:opacity-50"
|
||||
style={`left: ${guideLineLeft(index())}px`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FileTreeV2(props: {
|
||||
path: string
|
||||
active?: string
|
||||
level?: number
|
||||
allowed?: readonly string[]
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
draggable?: boolean
|
||||
onFileClick?: (file: FileNode) => void
|
||||
|
||||
_filter?: Filter
|
||||
_marks?: Set<string>
|
||||
_deeps?: Map<string, number>
|
||||
_kinds?: ReadonlyMap<string, Kind>
|
||||
_chain?: readonly string[]
|
||||
}) {
|
||||
const file = useFile()
|
||||
const level = props.level ?? 0
|
||||
const draggable = () => props.draggable ?? true
|
||||
|
||||
const key = (p: string) =>
|
||||
file
|
||||
.normalize(p)
|
||||
.replace(/[\\/]+$/, "")
|
||||
.replaceAll("\\", "/")
|
||||
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
|
||||
|
||||
const filter = createMemo(() => {
|
||||
if (props._filter) return props._filter
|
||||
|
||||
const allowed = props.allowed
|
||||
if (!allowed) return
|
||||
|
||||
const files = new Set(allowed)
|
||||
const dirs = new Set<string>()
|
||||
|
||||
for (const item of allowed) {
|
||||
const parts = item.split("/")
|
||||
const parents = parts.slice(0, -1)
|
||||
for (const [idx] of parents.entries()) {
|
||||
const dir = parents.slice(0, idx + 1).join("/")
|
||||
if (dir) dirs.add(dir)
|
||||
}
|
||||
}
|
||||
|
||||
return { files, dirs }
|
||||
const active = () => normalizeFileTreeV2Path(props.active ?? "")
|
||||
const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? []))
|
||||
const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true))
|
||||
const [root, setRoot] = createSignal<HTMLDivElement>()
|
||||
const [focused, setFocused] = createSignal<string>()
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
get count() {
|
||||
return rows().length
|
||||
},
|
||||
getScrollElement: () => virtualScrollElement(root()),
|
||||
initialRect: { width: 0, height: 600 },
|
||||
estimateSize: () => 28,
|
||||
gap: 2,
|
||||
overscan: 10,
|
||||
get getItemKey() {
|
||||
const current = rows()
|
||||
return (index: number) => current[index]?.node.path ?? index
|
||||
},
|
||||
rangeExtractor: (range) => {
|
||||
const indexes = defaultRangeExtractor(range)
|
||||
const path = focused()
|
||||
const index = path ? rows().findIndex((row) => row.node.path === path) : -1
|
||||
if (index < 0 || indexes.includes(index)) return indexes
|
||||
return [...indexes, index].sort((a, b) => a - b)
|
||||
},
|
||||
})
|
||||
|
||||
const marks = createMemo(() => {
|
||||
if (props._marks) return props._marks
|
||||
|
||||
const out = new Set<string>(props.kinds?.keys() ?? [])
|
||||
if (out.size === 0) return
|
||||
return out
|
||||
})
|
||||
|
||||
const kinds = createMemo(() => {
|
||||
if (props._kinds) return props._kinds
|
||||
return props.kinds
|
||||
})
|
||||
|
||||
const deeps = createMemo(() => {
|
||||
if (props._deeps) return props._deeps
|
||||
|
||||
const out = new Map<string, number>()
|
||||
|
||||
const root = props.path
|
||||
if (!(file.tree.state(root)?.expanded ?? false)) return out
|
||||
|
||||
const seen = new Set<string>()
|
||||
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
|
||||
|
||||
const push = (dir: string, lvl: number) => {
|
||||
const id = key(dir)
|
||||
if (seen.has(id)) return
|
||||
seen.add(id)
|
||||
|
||||
const kids = file.tree
|
||||
.children(dir)
|
||||
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
|
||||
.map((node) => node.path)
|
||||
|
||||
stack.push({ dir, lvl, i: 0, kids, max: lvl })
|
||||
}
|
||||
|
||||
push(root, level - 1)
|
||||
|
||||
while (stack.length > 0) {
|
||||
const top = stack[stack.length - 1]!
|
||||
|
||||
if (top.i < top.kids.length) {
|
||||
const next = top.kids[top.i]!
|
||||
top.i++
|
||||
push(next, top.lvl + 1)
|
||||
continue
|
||||
}
|
||||
|
||||
out.set(top.dir, top.max)
|
||||
stack.pop()
|
||||
|
||||
const parent = stack[stack.length - 1]
|
||||
if (!parent) continue
|
||||
parent.max = Math.max(parent.max, top.max)
|
||||
}
|
||||
|
||||
return out
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const current = filter()
|
||||
const dirs = dirsToExpand({
|
||||
level,
|
||||
filter: current,
|
||||
expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
|
||||
const path = active()
|
||||
if (!path) return
|
||||
const index = rows().findIndex((row) => row.node.path === path)
|
||||
if (index < 0) return
|
||||
queueMicrotask(() => {
|
||||
if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
|
||||
virtualizer.scrollToIndex(index, { align: "auto" })
|
||||
})
|
||||
// Nodes come from the `allowed` filter; skip listing so directories that only
|
||||
// exist on the diff's base branch do not each fail with an error toast.
|
||||
for (const dir of dirs) file.tree.expand(dir, { list: false })
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.path,
|
||||
(path) => {
|
||||
const dir = untrack(() => file.tree.state(path))
|
||||
if (!shouldListRoot({ level, dir })) return
|
||||
void file.tree.list(path)
|
||||
},
|
||||
{ defer: false },
|
||||
),
|
||||
const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
|
||||
const virtualItemByKey = createMemo(
|
||||
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
||||
)
|
||||
|
||||
const nodes = createMemo(() => visibleNodesForPath(props.path, file.tree.children, filter()))
|
||||
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
|
||||
|
||||
return (
|
||||
// group/file-tree-v2 scopes the group-hover guide lines below; hosts may add
|
||||
// an outer group with the same name to widen the hover area.
|
||||
<div data-component="file-tree-v2" class="group/file-tree-v2">
|
||||
<For each={nodes()}>
|
||||
{(node) => {
|
||||
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
||||
const deep = () => deeps().get(node.path) ?? -1
|
||||
const hasChildren = () => visibleNodesForPath(node.path, file.tree.children, filter()).length > 0
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={node.type === "directory"}>
|
||||
<Collapsible
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
data-scope="file-tree-v2"
|
||||
forceMount={false}
|
||||
open={expanded()}
|
||||
onOpenChange={(open) =>
|
||||
open ? file.tree.expand(node.path, { list: false }) : file.tree.collapse(node.path)
|
||||
}
|
||||
>
|
||||
<Collapsible.Trigger>
|
||||
<FileTreeNodeV2
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
>
|
||||
<div
|
||||
data-slot="file-tree-v2-chevron"
|
||||
data-expanded={expanded() ? "" : undefined}
|
||||
class="size-4 flex items-center justify-center"
|
||||
>
|
||||
<Icon name="chevron-down" />
|
||||
</div>
|
||||
</FileTreeNodeV2>
|
||||
</Collapsible.Trigger>
|
||||
<Show when={hasChildren()}>
|
||||
<Collapsible.Content class="relative">
|
||||
<div
|
||||
classList={{
|
||||
"absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 transition-opacity duration-150 ease-out motion-reduce:transition-none": true,
|
||||
"group-hover/file-tree-v2:opacity-100": expanded() && deep() === level,
|
||||
"group-hover/file-tree-v2:opacity-50": !(expanded() && deep() === level),
|
||||
}}
|
||||
style={`left: ${guideLineLeft(level)}px`}
|
||||
/>
|
||||
<Show
|
||||
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
|
||||
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
|
||||
>
|
||||
<FileTreeV2
|
||||
path={node.path}
|
||||
level={level + 1}
|
||||
allowed={props.allowed}
|
||||
<div
|
||||
ref={setRoot}
|
||||
data-component="file-tree-v2"
|
||||
data-total-rows={model().total}
|
||||
class="group/file-tree-v2"
|
||||
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
<For each={virtualRowKeys()}>
|
||||
{(key) => (
|
||||
<Show when={virtualItemByKey().get(key)}>
|
||||
{(item) => (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: `${item().size}px`,
|
||||
transform: `translateY(${item().start}px)`,
|
||||
}}
|
||||
>
|
||||
<Show when={rowByKey().get(key as string)}>
|
||||
{(row) => (
|
||||
<Show
|
||||
when={row().node.type === "directory"}
|
||||
fallback={
|
||||
<FileTreeNodeV2
|
||||
node={row().node}
|
||||
level={row().level}
|
||||
active={active()}
|
||||
draggable={draggable()}
|
||||
kinds={props.kinds}
|
||||
active={props.active}
|
||||
draggable={props.draggable}
|
||||
onFileClick={props.onFileClick}
|
||||
_filter={filter()}
|
||||
_marks={marks()}
|
||||
_deeps={deeps()}
|
||||
_kinds={kinds()}
|
||||
_chain={chain}
|
||||
/>
|
||||
</Show>
|
||||
</Collapsible.Content>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
</Match>
|
||||
<Match when={node.type === "file"}>
|
||||
<FileTreeNodeV2
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => props.onFileClick?.(node)}
|
||||
>
|
||||
<Show when={level > 0}>
|
||||
<div class="w-4 shrink-0" />
|
||||
</Show>
|
||||
<Show
|
||||
when={!node.ignored}
|
||||
fallback={<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />}
|
||||
>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--color" />
|
||||
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />
|
||||
</span>
|
||||
</Show>
|
||||
</FileTreeNodeV2>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}}
|
||||
as="button"
|
||||
type="button"
|
||||
class="relative"
|
||||
onFocus={() => setFocused(row().node.path)}
|
||||
onBlur={() => setFocused(undefined)}
|
||||
onClick={() =>
|
||||
props.onFileClick?.({
|
||||
...row().node,
|
||||
path: row().node.originalPath,
|
||||
absolute: row().node.originalPath,
|
||||
})
|
||||
}
|
||||
>
|
||||
<GuideLines level={row().level} />
|
||||
<Show when={row().level > 0}>
|
||||
<div class="w-4 shrink-0" />
|
||||
</Show>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
|
||||
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
|
||||
</span>
|
||||
</FileTreeNodeV2>
|
||||
}
|
||||
>
|
||||
<FileTreeNodeV2
|
||||
node={row().node}
|
||||
level={row().level}
|
||||
active={active()}
|
||||
draggable={draggable()}
|
||||
kinds={props.kinds}
|
||||
as="button"
|
||||
type="button"
|
||||
class="relative"
|
||||
onFocus={() => setFocused(row().node.path)}
|
||||
onBlur={() => setFocused(undefined)}
|
||||
aria-expanded={file.tree.state(row().node.path)?.expanded ?? true}
|
||||
onClick={() =>
|
||||
file.tree.state(row().node.path)?.expanded === false
|
||||
? file.tree.expand(row().node.path, { list: false })
|
||||
: file.tree.collapse(row().node.path)
|
||||
}
|
||||
>
|
||||
<GuideLines level={row().level} />
|
||||
<div
|
||||
data-slot="file-tree-v2-chevron"
|
||||
data-expanded={file.tree.state(row().node.path)?.expanded === false ? undefined : ""}
|
||||
class="size-4 flex items-center justify-center"
|
||||
>
|
||||
<Icon name="chevron-down" />
|
||||
</div>
|
||||
</FileTreeNodeV2>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -201,6 +201,7 @@ export default function FileTree(props: {
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
draggable?: boolean
|
||||
onFileClick?: (file: FileNode) => void
|
||||
onFileDoubleClick?: (file: FileNode) => void
|
||||
|
||||
_filter?: Filter
|
||||
_marks?: Set<string>
|
||||
@@ -440,6 +441,7 @@ export default function FileTree(props: {
|
||||
active={props.active}
|
||||
draggable={props.draggable}
|
||||
onFileClick={props.onFileClick}
|
||||
onFileDoubleClick={props.onFileDoubleClick}
|
||||
_filter={filter()}
|
||||
_marks={marks()}
|
||||
_deeps={deeps()}
|
||||
@@ -462,6 +464,7 @@ export default function FileTree(props: {
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => props.onFileClick?.(node)}
|
||||
onDblClick={() => props.onFileDoubleClick?.(node)}
|
||||
>
|
||||
<div class="w-4 shrink-0" />
|
||||
<Switch>
|
||||
|
||||
@@ -1,54 +1,144 @@
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const helpIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
data-slot="icon-svg"
|
||||
>
|
||||
<path
|
||||
d="M6.94235 10.5714V10.4854C6.94617 9.76302 7.01879 9.18777 7.16022 8.75968C7.30546 8.33158 7.50804 7.98567 7.76796 7.72193C8.02787 7.45819 8.34321 7.21548 8.71397 6.99379C8.93948 6.85619 9.14206 6.69374 9.32171 6.50645C9.50518 6.31916 9.64851 6.10511 9.75171 5.86431C9.85874 5.62351 9.91225 5.35404 9.91225 5.0559C9.91225 4.69661 9.82625 4.38509 9.65424 4.12136C9.48607 3.85762 9.26055 3.65504 8.9777 3.51362C8.69486 3.36837 8.38143 3.29575 8.03743 3.29575C7.73165 3.29575 7.43733 3.35882 7.15448 3.48495C6.87546 3.61108 6.6423 3.80984 6.45501 4.08122C6.26772 4.3526 6.15878 4.70425 6.12821 5.13617H4.56299C4.59357 4.47109 4.76557 3.9054 5.07899 3.43908C5.39242 2.96894 5.80522 2.61156 6.31741 2.36694C6.83341 2.12231 7.40675 2 8.03743 2C8.72161 2 9.31789 2.13378 9.82625 2.40134C10.3384 2.66507 10.734 3.0301 11.0131 3.49642C11.2959 3.96273 11.4373 4.49976 11.4373 5.1075C11.4373 5.53177 11.3724 5.914 11.2424 6.25418C11.1124 6.59436 10.9251 6.89823 10.6805 7.16579C10.4397 7.43335 10.1492 7.67033 9.80905 7.87673C9.48033 8.08313 9.21468 8.301 9.0121 8.53034C8.80952 8.75585 8.66237 9.02341 8.57063 9.33302C8.4789 9.64262 8.42921 10.0268 8.42156 10.4854V10.5714H6.94235ZM7.72782 14C7.43351 14 7.17933 13.8949 6.96528 13.6847C6.75506 13.4744 6.64994 13.2203 6.64994 12.9221C6.64994 12.6278 6.75506 12.3755 6.96528 12.1653C7.17933 11.9551 7.43351 11.85 7.72782 11.85C8.02214 11.85 8.27441 11.9551 8.48463 12.1653C8.69868 12.3755 8.8057 12.6278 8.8057 12.9221C8.8057 13.1209 8.75601 13.3024 8.65663 13.4668C8.55726 13.6273 8.4273 13.7573 8.26676 13.8567C8.10623 13.9522 7.92658 14 7.72782 14Z"
|
||||
fill="var(--v2-icon-icon-base)"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const triggerClass =
|
||||
"size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]"
|
||||
|
||||
// TODO: wire to changelog / seen-state when available
|
||||
const showPopover = () => true
|
||||
|
||||
export function HelpButton() {
|
||||
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
|
||||
|
||||
const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const platform = usePlatform()
|
||||
|
||||
return (
|
||||
<Show when={!state.dismissed}>
|
||||
<div class="fixed bottom-4 right-4 z-50 hidden md:block">
|
||||
<Popover
|
||||
open={shown()}
|
||||
onOpenChange={setShown}
|
||||
triggerAs="button"
|
||||
triggerProps={{
|
||||
type: "button",
|
||||
"aria-label": "Help",
|
||||
class:
|
||||
"size-7 rounded-full bg-background-base shadow-[var(--shadow-lg-border-base)] flex items-center justify-center text-text-base hover:text-text-strong transition-colors",
|
||||
}}
|
||||
trigger={<span aria-hidden="true">?</span>}
|
||||
class="[&_[data-slot=popover-body]]:p-0 w-[320px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
|
||||
gutter={8}
|
||||
placement="top-end"
|
||||
<a
|
||||
href="https://opencode.ai"
|
||||
aria-label="Open the OpenCode website"
|
||||
data-component="icon-button-v2"
|
||||
data-size="large"
|
||||
class={`${triggerClass} fixed bottom-5 right-5 z-50 flex items-center justify-center`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
platform.openLink(event.currentTarget.href)
|
||||
}}
|
||||
>
|
||||
{helpIcon}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
// can remove this after the tabs rollout has been out for a while
|
||||
export function TabsInfoPopup() {
|
||||
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
|
||||
|
||||
const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false }))
|
||||
// setState({ dismissed: false }) // for testing
|
||||
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
||||
|
||||
return (
|
||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
||||
<Show when={!state.dismissed}>
|
||||
<div
|
||||
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
||||
aria-label="Introducing Tabs. A faster, more intuitive way to work."
|
||||
>
|
||||
<Show when={shown()}>
|
||||
<div class="relative flex flex-col gap-1 w-[320px] p-4 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
class="absolute top-3.5 right-3.5 size-6 rounded-md flex items-center justify-center text-text-base hover:text-text-strong hover:bg-surface-raised-base-hover transition-colors"
|
||||
onClick={() => {
|
||||
setShown(false)
|
||||
setState("dismissed", true)
|
||||
}}
|
||||
>
|
||||
<Icon name="xmark-small" />
|
||||
</button>
|
||||
<span class="text-14-regular text-text-strong">Lorem ipsum dolor sit amet</span>
|
||||
<p class="text-12-regular text-text-weak">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
|
||||
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss Tabs information"
|
||||
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
||||
onClick={() => setState("dismissed", true)}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="white" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
||||
onClick={() => {
|
||||
setState("dismissed", true)
|
||||
setDrawerOpen(true)
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={introducingTabsVideo}
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
loop
|
||||
muted
|
||||
autoplay
|
||||
playsinline
|
||||
aria-hidden="true"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
/>
|
||||
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
|
||||
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
|
||||
Introducing Tabs
|
||||
</p>
|
||||
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
||||
A faster, more intuitive way to work.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</Popover>
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<DrawerContent>
|
||||
<div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
|
||||
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
||||
June 16
|
||||
</p>
|
||||
<DrawerClose
|
||||
as={IconButtonV2}
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
aria-label="Close"
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</div>
|
||||
<div class="relative flex w-full flex-col items-start gap-6 p-8">
|
||||
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
||||
Introducing Tabs Navigation.
|
||||
</p>
|
||||
<p class="w-full flex-1 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||
We've introduced tabs as the primary navigation in OpenCode. Your most important session are now pinned at
|
||||
the top of your screen at all times. No more hunting through menus or losing your place mid-session. Switch
|
||||
contexts instantly, pick up exactly where you left off, and keep your focus where it belongs: on the
|
||||
sessions.
|
||||
</p>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Show, type Component } from "solid-js"
|
||||
import { Show, type Component, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
type InputKey = "text" | "image" | "audio" | "video" | "pdf"
|
||||
@@ -23,7 +23,18 @@ type ModelInfo = {
|
||||
}
|
||||
}
|
||||
|
||||
export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean }> = (props) => {
|
||||
function ModelTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
|
||||
return (
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
|
||||
<span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean; v2?: boolean }> = (
|
||||
props,
|
||||
) => {
|
||||
const language = useLanguage()
|
||||
const sourceName = (model: ModelInfo) => {
|
||||
const value = `${model.id} ${model.name}`.toLowerCase()
|
||||
@@ -51,6 +62,13 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
|
||||
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
|
||||
return `${sourceName(props.model)} ${props.model.name}${suffix}`
|
||||
}
|
||||
const name = () => {
|
||||
const tags: Array<string> = []
|
||||
if (props.latest) tags.push(language.t("model.tag.latest"))
|
||||
if (props.free) tags.push(language.t("model.tag.free"))
|
||||
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
|
||||
return `${props.model.name}${suffix}`
|
||||
}
|
||||
const inputs = () => {
|
||||
if (props.model.capabilities) {
|
||||
const input = props.model.capabilities.input
|
||||
@@ -73,6 +91,21 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
|
||||
: language.t("model.tooltip.reasoning.none")
|
||||
}
|
||||
const context = () => language.t("model.tooltip.context", { limit: props.model.limit.context.toLocaleString() })
|
||||
const contextLimit = () => props.model.limit.context.toLocaleString(language.intl())
|
||||
|
||||
if (props.v2) {
|
||||
return (
|
||||
<div class="flex w-[180px] flex-col gap-2">
|
||||
<ModelTooltipRow name={language.t("model.tooltip.model")} value={name()} />
|
||||
<ModelTooltipRow name={language.t("model.tooltip.provider")} value={props.model.provider.name} />
|
||||
<Show when={inputs()}>
|
||||
{(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
|
||||
</Show>
|
||||
<ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
|
||||
<ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-1 py-1">
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { createPromptState } from "@/context/prompt"
|
||||
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
|
||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||
|
||||
function createPromptInputStoryRuntime() {
|
||||
@@ -104,94 +102,6 @@ function PromptInputExample() {
|
||||
)
|
||||
}
|
||||
|
||||
const todos: Todo[] = [
|
||||
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
|
||||
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
|
||||
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
|
||||
]
|
||||
|
||||
function PromptInputWithOpenDock() {
|
||||
const input = createPromptInputStoryRuntime()
|
||||
const [controls, setControls] = createStore({
|
||||
agent: "build",
|
||||
activeTab: undefined as string | undefined,
|
||||
todoCollapsed: false,
|
||||
})
|
||||
const inputControls = {
|
||||
agents: {
|
||||
available: [],
|
||||
options: ["build"],
|
||||
get current() {
|
||||
return controls.agent
|
||||
},
|
||||
loading: false,
|
||||
visible: true,
|
||||
select: (agent?: string) => setControls("agent", agent ?? "build"),
|
||||
},
|
||||
model: {
|
||||
selection: {
|
||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||
variant: { list: () => [], current: () => undefined, set: () => {} },
|
||||
},
|
||||
paid: true,
|
||||
loading: false,
|
||||
},
|
||||
session: {
|
||||
id: "story-session",
|
||||
tabs: {
|
||||
active: () => controls.activeTab,
|
||||
all: () => [],
|
||||
open: () => {},
|
||||
setActive: (tab: string) => setControls("activeTab", tab),
|
||||
},
|
||||
reviewPanel: { opened: () => false, open: () => {} },
|
||||
},
|
||||
newLayoutDesigns: true,
|
||||
}
|
||||
const state = {
|
||||
blocked: () => false,
|
||||
questionRequest: () => undefined,
|
||||
permissionRequest: () => undefined,
|
||||
permissionResponding: () => false,
|
||||
decide: () => {},
|
||||
todos: () => todos,
|
||||
dock: () => true,
|
||||
closing: () => false,
|
||||
opening: () => false,
|
||||
}
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={createSessionComposerRegionController({
|
||||
state,
|
||||
sessionKey: () => "story-session",
|
||||
sessionID: () => "story-session",
|
||||
prompt: input.state,
|
||||
ready: () => true,
|
||||
centered: () => false,
|
||||
todo: {
|
||||
collapsed: () => controls.todoCollapsed,
|
||||
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
|
||||
},
|
||||
followup: () => undefined,
|
||||
revert: () => undefined,
|
||||
onResponseSubmit: () => {},
|
||||
openParent: () => {},
|
||||
setPromptRef: () => {},
|
||||
setDockRef: () => {},
|
||||
})}
|
||||
promptInput={
|
||||
<PromptInput
|
||||
controls={inputControls}
|
||||
{...input}
|
||||
ref={() => {}}
|
||||
newSessionWorktree=""
|
||||
onNewSessionWorktreeReset={() => {}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/PromptInput",
|
||||
id: "app-prompt-input",
|
||||
@@ -206,12 +116,3 @@ export const Basic = {
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const DockAlreadyOpen = {
|
||||
render: () => (
|
||||
<div class="pt-10">
|
||||
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
|
||||
<PromptInputWithOpenDock />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
@@ -43,6 +45,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { usePermission } from "@/context/permission"
|
||||
@@ -218,6 +222,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
let scrollRef!: HTMLDivElement
|
||||
let slashPopoverRef!: HTMLDivElement
|
||||
let restoreEndOnFocus = true
|
||||
let savedCursor: number | null = null
|
||||
|
||||
const mirror = { input: false }
|
||||
const inset = 56
|
||||
@@ -519,7 +524,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const setMode = (mode: "normal" | "shell") => {
|
||||
setStore("mode", mode)
|
||||
setStore("popover", null)
|
||||
setStore({ popover: null, slashMenu: false, slashMenuQuery: "" })
|
||||
requestAnimationFrame(() => editorRef?.focus())
|
||||
}
|
||||
|
||||
@@ -553,7 +558,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
},
|
||||
])
|
||||
|
||||
const closePopover = () => setStore("popover", null)
|
||||
const closePopover = () => setStore({ popover: null, slashMenu: false, slashMenuQuery: "" })
|
||||
|
||||
const resetHistoryNavigation = (force = false) => {
|
||||
if (!force && (store.historyIndex < 0 || store.applyingHistory)) return
|
||||
@@ -590,7 +595,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const restoreFocus = () => {
|
||||
requestAnimationFrame(() => {
|
||||
const cursor = prompt.cursor() ?? promptLength(prompt.current())
|
||||
const cursor = savedCursor ?? prompt.cursor() ?? promptLength(prompt.current())
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, cursor)
|
||||
queueScroll()
|
||||
@@ -627,6 +632,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
||||
|
||||
const handleBlur = () => {
|
||||
savedCursor = currentCursor()
|
||||
closePopover()
|
||||
setComposing(false)
|
||||
}
|
||||
@@ -798,17 +804,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
||||
if (!cmd) return
|
||||
const menu = store.slashMenu
|
||||
closePopover()
|
||||
const images = imageAttachments()
|
||||
|
||||
if (cmd.type === "custom") {
|
||||
const text = `/${cmd.trigger} `
|
||||
if (menu) {
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, 0)
|
||||
addPart({ type: "text", content: text, start: 0, end: text.length })
|
||||
focusEditorEnd()
|
||||
return
|
||||
}
|
||||
setEditorText(text)
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
|
||||
focusEditorEnd()
|
||||
return
|
||||
}
|
||||
|
||||
if (menu) {
|
||||
command.trigger(cmd.id, "slash")
|
||||
return
|
||||
}
|
||||
|
||||
clearEditor()
|
||||
prompt.set([...DEFAULT_PROMPT, ...images], 0)
|
||||
command.trigger(cmd.id, "slash")
|
||||
@@ -1070,10 +1089,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
if (atMatch) {
|
||||
atOnInput(atMatch[1])
|
||||
setStore("popover", "at")
|
||||
setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" })
|
||||
} else if (slashMatch) {
|
||||
slashOnInput(slashMatch[1])
|
||||
setStore("popover", "slash")
|
||||
setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" })
|
||||
} else {
|
||||
closePopover()
|
||||
}
|
||||
@@ -1169,6 +1188,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const openCommands = () => {
|
||||
const populated = prompt.dirty() || commentCount() > 0
|
||||
requestAnimationFrame(() => {
|
||||
if (!populated) {
|
||||
if (!addPart({ type: "text", content: "/", start: 0, end: 0 })) return
|
||||
slashOnInput("")
|
||||
setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" })
|
||||
return
|
||||
}
|
||||
slashOnInput("")
|
||||
setStore({ popover: "slash", slashMenu: true, slashMenuQuery: "" })
|
||||
})
|
||||
}
|
||||
|
||||
const openContext = () => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!addPart({ type: "text", content: "@", start: 0, end: 0 })) return
|
||||
atOnInput("")
|
||||
setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" })
|
||||
})
|
||||
}
|
||||
|
||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||
history.add(prompt, mode, mode === "shell" ? [] : historyComments())
|
||||
}
|
||||
@@ -1197,7 +1238,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
setStore("mode", "normal")
|
||||
setStore("popover", null)
|
||||
closePopover()
|
||||
setStore("historyIndex", -1)
|
||||
setStore("savedPrompt", null)
|
||||
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||
@@ -1284,13 +1325,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
resetHistoryNavigation(true)
|
||||
},
|
||||
setMode: (mode) => setStore("mode", mode),
|
||||
setPopover: (popover) => setStore("popover", popover),
|
||||
setPopover: (popover) => {
|
||||
if (!popover) return closePopover()
|
||||
setStore({ popover, slashMenu: false, slashMenuQuery: "" })
|
||||
},
|
||||
newSessionWorktree: () => props.newSessionWorktree,
|
||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||
shouldQueue: props.shouldQueue,
|
||||
onQueue: props.onQueue,
|
||||
onAbort: props.onAbort,
|
||||
onSubmit: props.onSubmit,
|
||||
model: props.controls.model.selection,
|
||||
})
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -1323,7 +1368,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const cursorPosition = getCursorPosition(editorRef)
|
||||
if (cursorPosition === 0) {
|
||||
setStore("mode", "shell")
|
||||
setStore("popover", null)
|
||||
closePopover()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
@@ -1458,6 +1503,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSlashMenuKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
closePopover()
|
||||
requestAnimationFrame(() => editorRef.focus())
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Tab") {
|
||||
selectPopoverActive()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
|
||||
const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter"
|
||||
const ctrlNav = ctrl && (event.key === "n" || event.key === "p")
|
||||
if (!nav && !ctrlNav) return
|
||||
slashOnKeyDown(event)
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) scrollSlashActiveIntoView()
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const agentsLoading = () => props.controls.agents.loading
|
||||
const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading())
|
||||
const providersLoading = () => props.controls.model.loading
|
||||
@@ -1486,9 +1554,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
style: control(),
|
||||
onClose: restoreFocus,
|
||||
onUnpaidClick: () => {
|
||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModelUnpaid model={props.controls.model.selection} />)
|
||||
})
|
||||
if (props.controls.newLayoutDesigns) {
|
||||
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controls.model.selection} />)
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -1525,6 +1595,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
slashActive={slashActive() ?? undefined}
|
||||
setSlashActive={setSlashActive}
|
||||
onSlashSelect={handleSlashSelect}
|
||||
slashMenu={store.slashMenu}
|
||||
slashMenuQuery={store.slashMenuQuery}
|
||||
onSlashMenuInput={(value) => {
|
||||
setStore("slashMenuQuery", value)
|
||||
slashOnInput(value)
|
||||
}}
|
||||
onSlashMenuKeyDown={handleSlashMenuKeyDown}
|
||||
commandKeybind={command.keybind}
|
||||
commandKeybindParts={command.keybindParts}
|
||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||
@@ -1623,23 +1700,42 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
placement="top"
|
||||
value={
|
||||
<>
|
||||
{language.t("prompt.action.attachFile")}
|
||||
{language.t("prompt.menu.addImagesAndFiles")}
|
||||
<KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
data-action="prompt-attach"
|
||||
type="button"
|
||||
icon="plus"
|
||||
variant="ghost"
|
||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
|
||||
style={buttons()}
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
aria-label={language.t("prompt.action.attachFile")}
|
||||
/>
|
||||
<MenuV2 gutter={6} modal={false} placement="top-start">
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
data-action="prompt-attach"
|
||||
type="button"
|
||||
icon={<IconV2 name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
style={buttons()}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
aria-label={language.t("prompt.menu.addImagesAndFiles")}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content style={{ "min-width": "180px" }}>
|
||||
<MenuV2.Item onSelect={pick} shortcut={command.keybind("file.attach")}>
|
||||
{language.t("prompt.menu.imagesAndFiles")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={openCommands} shortcut="/">
|
||||
{language.t("prompt.menu.commands")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={openContext} shortcut="@">
|
||||
{language.t("prompt.menu.context")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => setMode("shell")} shortcut="!">
|
||||
{language.t("prompt.menu.shellCommand")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</TooltipV2>
|
||||
<Show when={showAgentControl()}>
|
||||
<ComposerAgentControl state={agentControlState()} />
|
||||
@@ -1680,7 +1776,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
class="max-w-[160px] justify-start capitalize"
|
||||
style={control()}
|
||||
>
|
||||
<span class="truncate">
|
||||
<span class="truncate leading-5">
|
||||
{props.controls.model.selection.variant.current() ?? language.t("common.default")}
|
||||
</span>
|
||||
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
||||
@@ -1967,11 +2063,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||
style={control()}
|
||||
onClick={() => {
|
||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||
dialog.show(() => (
|
||||
<x.DialogSelectModelUnpaid model={props.controls.model.selection} />
|
||||
))
|
||||
})
|
||||
dialog.show(() => (
|
||||
<DialogSelectModelUnpaid model={props.controls.model.selection} />
|
||||
))
|
||||
}}
|
||||
>
|
||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||
@@ -2138,30 +2232,47 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
|
||||
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
||||
style={props.state.style}
|
||||
onClick={props.state.onUnpaidClick}
|
||||
<Show
|
||||
when={props.state.newLayoutDesigns}
|
||||
fallback={
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
|
||||
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
||||
style={props.state.style}
|
||||
onClick={props.state.onUnpaidClick}
|
||||
>
|
||||
<Show when={props.state.providerID}>
|
||||
{(providerID) => (
|
||||
<ProviderIcon
|
||||
id={providerID()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<span class="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Show when={props.state.providerID}>
|
||||
{(providerID) => (
|
||||
<ProviderIcon
|
||||
id={providerID()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<span class="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</Button>
|
||||
<ButtonV2
|
||||
data-action="prompt-model"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
||||
style={props.state.style}
|
||||
onClick={props.state.onUnpaidClick}
|
||||
>
|
||||
<ModelControlContent state={props.state} v2 />
|
||||
</ButtonV2>
|
||||
</Show>
|
||||
</TooltipV2>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
|
||||
import type { ContextItem } from "@/context/prompt"
|
||||
|
||||
@@ -27,7 +27,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
const selected = props.active(item)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
<TooltipV2
|
||||
value={
|
||||
<span class="flex max-w-[300px]">
|
||||
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
|
||||
@@ -78,7 +78,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</TooltipV2>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
@@ -41,6 +41,10 @@ type PromptPopoverProps = {
|
||||
slashActive?: string
|
||||
setSlashActive: (id: string) => void
|
||||
onSlashSelect: (item: SlashCommand) => void
|
||||
slashMenu: boolean
|
||||
slashMenuQuery: string
|
||||
onSlashMenuInput: (value: string) => void
|
||||
onSlashMenuKeyDown: (event: KeyboardEvent) => void
|
||||
commandKeybind: (id: string) => string | undefined
|
||||
commandKeybindParts: (id: string) => string[]
|
||||
newLayoutDesigns: boolean
|
||||
@@ -254,6 +258,20 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={props.popover === "slash"}>
|
||||
<Show when={props.slashMenu}>
|
||||
<div class="px-2 py-1">
|
||||
<input
|
||||
ref={(el) => requestAnimationFrame(() => el.focus())}
|
||||
value={props.slashMenuQuery}
|
||||
onInput={(event) => props.onSlashMenuInput(event.currentTarget.value)}
|
||||
onKeyDown={props.onSlashMenuKeyDown}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
aria-label={props.t("prompt.menu.commands")}
|
||||
placeholder="/"
|
||||
class="w-full bg-transparent outline-none text-[13px] leading-5 text-v2-text-text-base placeholder:text-v2-text-text-faint"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={props.slashFlat.length > 0}
|
||||
fallback={
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import type { ModelSelection } from "@/context/local"
|
||||
|
||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
@@ -33,6 +34,10 @@ const prompt = {
|
||||
current: () => promptValue,
|
||||
cursor: () => 0,
|
||||
dirty: () => true,
|
||||
model: {
|
||||
current: () => undefined,
|
||||
set: () => undefined,
|
||||
},
|
||||
reset: () => undefined,
|
||||
set: () => undefined,
|
||||
context: {
|
||||
@@ -378,6 +383,39 @@ describe("prompt submit worktree selection", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("uses an injected model selection", async () => {
|
||||
params = { id: "session-1" }
|
||||
const model = {
|
||||
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
|
||||
variant: { current: () => "draft-variant" },
|
||||
} as unknown as ModelSelection
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
model,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
|
||||
expect(optimistic[0]).toMatchObject({
|
||||
message: {
|
||||
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("seeds new sessions before optimistic prompts are added", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user