mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-05 06:03:58 +00:00
Compare commits
306 Commits
collapse-q
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
354fe7f884 | ||
|
|
380cbea0fe | ||
|
|
77c5474d37 | ||
|
|
adb4a46f11 | ||
|
|
ba1fb54832 | ||
|
|
4bf23b9be4 | ||
|
|
9d113e0093 | ||
|
|
83638fc507 | ||
|
|
d61f905e6d | ||
|
|
f4db8132e1 | ||
|
|
d750f80576 | ||
|
|
13b23b8403 | ||
|
|
0c398d63ae | ||
|
|
d15eac5037 | ||
|
|
4ae9a5efeb | ||
|
|
62ccc2332b | ||
|
|
f2bc2b59a8 | ||
|
|
85ff05670a | ||
|
|
f03910cc4b | ||
|
|
78cd0c9d7a | ||
|
|
e67bedb0b2 | ||
|
|
37d3ad0475 | ||
|
|
7de99b6a94 | ||
|
|
6e123c5f2a | ||
|
|
2b5a95bb88 | ||
|
|
11522769fb | ||
|
|
1fca726266 | ||
|
|
3a654ccd56 | ||
|
|
6784f917c7 | ||
|
|
a2fb4ca322 | ||
|
|
6082393db3 | ||
|
|
b7be8787fb | ||
|
|
92534bbabd | ||
|
|
302f4bd863 | ||
|
|
3032fe445d | ||
|
|
1abb7efc28 | ||
|
|
7fe615247b | ||
|
|
6455ccb609 | ||
|
|
868fc1829a | ||
|
|
4c0f6cbc38 | ||
|
|
c2796fa803 | ||
|
|
8efadf9858 | ||
|
|
2dcb54c5f2 | ||
|
|
c0fd605d73 | ||
|
|
986a6b19e0 | ||
|
|
2c39a7fcb3 | ||
|
|
9939e735bb | ||
|
|
0461efa8ab | ||
|
|
bffda3716e | ||
|
|
bb8b850b5f | ||
|
|
4ef8ae9ddb | ||
|
|
abe4a2548f | ||
|
|
82a29ba9b9 | ||
|
|
7ebf1c3707 | ||
|
|
24bcc96511 | ||
|
|
7e5477c7c4 | ||
|
|
a0296094c5 | ||
|
|
7add2e67c3 | ||
|
|
a16d77b08d | ||
|
|
83de487dcd | ||
|
|
4e05d3487c | ||
|
|
9cba8f9349 | ||
|
|
45eb4be7f2 | ||
|
|
8851c619b4 | ||
|
|
418ecc7073 | ||
|
|
55762b2bbb | ||
|
|
0ce963bfb3 | ||
|
|
6f8f0d1830 | ||
|
|
969f434769 | ||
|
|
49f55ae426 | ||
|
|
28538bc65d | ||
|
|
1b9ca3da27 | ||
|
|
c96a3b15c5 | ||
|
|
21b6a5f5fd | ||
|
|
474e2e5165 | ||
|
|
eadd42bfe1 | ||
|
|
b939299a0f | ||
|
|
bc56419124 | ||
|
|
55ab1f094c | ||
|
|
14f88ae889 | ||
|
|
9d53b3a221 | ||
|
|
0ad0d84402 | ||
|
|
33b6ba68fc | ||
|
|
90345c57e1 | ||
|
|
324230806e | ||
|
|
7f7e622426 | ||
|
|
27447bab26 | ||
|
|
45ac20b8aa | ||
|
|
218330aec1 | ||
|
|
67fa7903c3 | ||
|
|
cd3a09c6a7 | ||
|
|
f8685a4d53 | ||
|
|
6cbb1ef1c2 | ||
|
|
0b825ca383 | ||
|
|
22a4c5a77e | ||
|
|
29dbfc25e5 | ||
|
|
40fc406424 | ||
|
|
6f23271741 | ||
|
|
b7198c28c8 | ||
|
|
de6a6af5ab | ||
|
|
0f1f55a24c | ||
|
|
744c38cc7c | ||
|
|
e9de2505f6 | ||
|
|
22fcde926f | ||
|
|
b42a63b882 | ||
|
|
ca5a7378de | ||
|
|
c6187ee40f | ||
|
|
d94c516402 | ||
|
|
61795d794e | ||
|
|
7c215c0d02 | ||
|
|
715b844c2a | ||
|
|
1b0d65f80e | ||
|
|
faf501200e | ||
|
|
e3267413c2 | ||
|
|
18cad10647 | ||
|
|
a69742ccb2 | ||
|
|
64b21135f9 | ||
|
|
e482405cdc | ||
|
|
ad56338108 | ||
|
|
2ccf21de99 | ||
|
|
dd4ad5f2c5 | ||
|
|
eb71856733 | ||
|
|
d7569a5625 | ||
|
|
0a2aa8688d | ||
|
|
e44cdaf887 | ||
|
|
5709561917 | ||
|
|
9909f94048 | ||
|
|
e8f67ddbcc | ||
|
|
0541d756a6 | ||
|
|
a2d3d62db3 | ||
|
|
850fd9419e | ||
|
|
db3eddc51f | ||
|
|
5dcf3301e0 | ||
|
|
b82f1d6145 | ||
|
|
5cf235fa6c | ||
|
|
e4f0825c56 | ||
|
|
695a26a168 | ||
|
|
3ebebe0a96 | ||
|
|
2a0be8316b | ||
|
|
7f37acdaaa | ||
|
|
e79d41c70e | ||
|
|
109ea1709b | ||
|
|
9a42927268 | ||
|
|
e66d829d18 | ||
|
|
c4ffd93caa | ||
|
|
c78e7e1a28 | ||
|
|
e3a787a7a3 | ||
|
|
92aab78442 | ||
|
|
1bbd3a71fb | ||
|
|
12f4315d9d | ||
|
|
d80334b2bc | ||
|
|
c2f5abe759 | ||
|
|
74ebb4147f | ||
|
|
1663c11f40 | ||
|
|
b1c166edf4 | ||
|
|
3c8ce4ab90 | ||
|
|
502dbb65fc | ||
|
|
bb2e9fffb1 | ||
|
|
9d427c1ef8 | ||
|
|
70c6fcfbbf | ||
|
|
6f90c3d73a | ||
|
|
3310c25dd1 | ||
|
|
b751bd0373 | ||
|
|
c2091acd8c | ||
|
|
fd4d3094bf | ||
|
|
10c325810b | ||
|
|
fa45422bf9 | ||
|
|
da82d4035a | ||
|
|
70b6a05235 | ||
|
|
cbf0570489 | ||
|
|
356b5d4601 | ||
|
|
7305fc044d | ||
|
|
1e2da60162 | ||
|
|
e4af1bb422 | ||
|
|
5e8742f431 | ||
|
|
18850c4f91 | ||
|
|
48412f75ac | ||
|
|
6deb27e852 | ||
|
|
b985ea344b | ||
|
|
1233ebcce1 | ||
|
|
881ca86432 | ||
|
|
6aa4928e9e | ||
|
|
9f150b0776 | ||
|
|
7e3e85ba59 | ||
|
|
e41b53504f | ||
|
|
96d6fb78da | ||
|
|
fd6f7133c5 | ||
|
|
98c75be7e1 | ||
|
|
b5dc6e670a | ||
|
|
9d7852b5c3 | ||
|
|
78069369e2 | ||
|
|
e993acec31 | ||
|
|
1cd77b1072 | ||
|
|
8176bafc55 | ||
|
|
0a3a3216db | ||
|
|
633a3ba03a | ||
|
|
d60696ded8 | ||
|
|
4c2aa4ab90 | ||
|
|
51e6000194 | ||
|
|
bf2cc3aa2f | ||
|
|
4b9e19f72f | ||
|
|
be20f865ac | ||
|
|
7bfbb1fcf8 | ||
|
|
b1bfecb71d | ||
|
|
cf78855165 | ||
|
|
a692e6fdd4 | ||
|
|
a44f78c34a | ||
|
|
a5d727e7f9 | ||
|
|
7b5b665b4a | ||
|
|
b5515dd2f7 | ||
|
|
d16e5b98dc | ||
|
|
9dbf3a2042 | ||
|
|
d1938a472d | ||
|
|
c0483affa6 | ||
|
|
ae0f69e1fa | ||
|
|
90270c615d | ||
|
|
6b7e6bde4d | ||
|
|
b15fb21191 | ||
|
|
c8866e60ba | ||
|
|
f5eade1d2b | ||
|
|
438610aa64 | ||
|
|
c4c0b23bff | ||
|
|
da34dfa80b | ||
|
|
5e9dd5dca3 | ||
|
|
1ac4a1a1fa | ||
|
|
8405c70993 | ||
|
|
d7b2a15959 | ||
|
|
5fee541d52 | ||
|
|
38704acacd | ||
|
|
4d968ebd64 | ||
|
|
b88e8e0e0b | ||
|
|
3ee1653f40 | ||
|
|
fcd733e3d6 | ||
|
|
cec16dfe95 | ||
|
|
114eb42444 | ||
|
|
fe0f298293 | ||
|
|
29d90056e9 | ||
|
|
276d60e82a | ||
|
|
9ea36ccd9d | ||
|
|
e1e18c7abd | ||
|
|
971bd30516 | ||
|
|
b9ca79f3b6 | ||
|
|
323e7a36da | ||
|
|
9faaa6130d | ||
|
|
2a2082233d | ||
|
|
267d2c82de | ||
|
|
0b8c1f1f7d | ||
|
|
2eb1d4cb9a | ||
|
|
d2a8f44c22 | ||
|
|
1f1f36aac1 | ||
|
|
7f851da15e | ||
|
|
a3bdb974b3 | ||
|
|
46d678fce9 | ||
|
|
1f2348c1ef | ||
|
|
f347194e31 | ||
|
|
7ff2710ce3 | ||
|
|
c12ce2ffff | ||
|
|
d20698401b | ||
|
|
14acf269aa | ||
|
|
ab44597018 | ||
|
|
1d0d427b5f | ||
|
|
aec95c4d10 | ||
|
|
b2c82cb897 | ||
|
|
20905212f9 | ||
|
|
76cda30896 | ||
|
|
4f740306f0 | ||
|
|
c7e9851826 | ||
|
|
bf53e1c24b | ||
|
|
50004d1f94 | ||
|
|
acd7c5ad55 | ||
|
|
cf54b544e3 | ||
|
|
52b42258fa | ||
|
|
3026a005b6 | ||
|
|
a6f802d7fe | ||
|
|
9ef803be82 | ||
|
|
ce5c827a6e | ||
|
|
56decd79db | ||
|
|
fc258ea74f | ||
|
|
abd9e195ac | ||
|
|
9d78b69cd3 | ||
|
|
e31f00ad22 | ||
|
|
70b555472e | ||
|
|
e514919cc4 | ||
|
|
a90e8de050 | ||
|
|
ba5121ce0b | ||
|
|
eabf770053 | ||
|
|
86d7bdc542 | ||
|
|
d3ab78bba0 | ||
|
|
a531f3f36d | ||
|
|
bb3382311d | ||
|
|
ad545d0cc9 | ||
|
|
ac244b1458 | ||
|
|
f202536b65 | ||
|
|
405cc3f610 | ||
|
|
878c1b8c2d | ||
|
|
d8bcfd90d3 | ||
|
|
954d31903f | ||
|
|
1587d93b29 | ||
|
|
d364c43916 | ||
|
|
72eec20437 | ||
|
|
4503bde1cc | ||
|
|
1abc228e95 | ||
|
|
991e823039 | ||
|
|
62fa5c1314 | ||
|
|
93b9e47c05 | ||
|
|
bb4d978684 |
41
.github/workflows/docs-locale-sync.yml
vendored
41
.github/workflows/docs-locale-sync.yml
vendored
@@ -59,43 +59,10 @@ jobs:
|
||||
{
|
||||
"permission": {
|
||||
"*": "deny",
|
||||
"read": {
|
||||
"*": "deny",
|
||||
"packages/web/src/content/docs": "allow",
|
||||
"packages/web/src/content/docs/*": "allow",
|
||||
"packages/web/src/content/docs/*.mdx": "allow",
|
||||
"packages/web/src/content/docs/*/*.mdx": "allow",
|
||||
".opencode": "allow",
|
||||
".opencode/agent": "allow",
|
||||
".opencode/glossary": "allow",
|
||||
".opencode/agent/translator.md": "allow",
|
||||
".opencode/glossary/*.md": "allow"
|
||||
},
|
||||
"edit": {
|
||||
"*": "deny",
|
||||
"packages/web/src/content/docs/*/*.mdx": "allow"
|
||||
},
|
||||
"glob": {
|
||||
"*": "deny",
|
||||
"packages/web/src/content/docs*": "allow",
|
||||
".opencode/glossary*": "allow"
|
||||
},
|
||||
"task": {
|
||||
"*": "deny",
|
||||
"translator": "allow"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"translator": {
|
||||
"permission": {
|
||||
"*": "deny",
|
||||
"read": {
|
||||
"*": "deny",
|
||||
".opencode/agent/translator.md": "allow",
|
||||
".opencode/glossary/*.md": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
"read": "allow",
|
||||
"edit": "allow",
|
||||
"glob": "allow",
|
||||
"task": "allow"
|
||||
}
|
||||
}
|
||||
run: |
|
||||
|
||||
128
.github/workflows/publish.yml
vendored
128
.github/workflows/publish.yml
vendored
@@ -99,7 +99,6 @@ jobs:
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: packages/opencode/dist
|
||||
|
||||
outputs:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
|
||||
@@ -240,11 +239,131 @@ jobs:
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
||||
|
||||
build-electron:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
continue-on-error: false
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- host: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
platform_flag: --mac --x64
|
||||
- host: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
platform_flag: --mac --arm64
|
||||
- host: "blacksmith-4vcpu-windows-2025"
|
||||
target: x86_64-pc-windows-msvc
|
||||
platform_flag: --win
|
||||
- host: "blacksmith-4vcpu-ubuntu-2404"
|
||||
target: x86_64-unknown-linux-gnu
|
||||
platform_flag: --linux
|
||||
- host: "blacksmith-4vcpu-ubuntu-2404"
|
||||
target: aarch64-unknown-linux-gnu
|
||||
platform_flag: --linux
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
# if: github.ref_name == 'beta'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: apple-actions/import-codesign-certs@v2
|
||||
if: runner.os == 'macOS'
|
||||
with:
|
||||
keychain: build
|
||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Setup Apple API Key
|
||||
if: runner.os == 'macOS'
|
||||
run: echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Cache apt packages
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/apt-cache
|
||||
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-${{ hashFiles('.github/workflows/publish.yml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
run: |
|
||||
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" rpm
|
||||
sudo chmod -R a+rw ~/apt-cache
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Prepare
|
||||
run: bun ./scripts/prepare.ts
|
||||
working-directory: packages/desktop-electron
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
RUST_TARGET: ${{ matrix.settings.target }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
working-directory: packages/desktop-electron
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
|
||||
- name: Package and publish
|
||||
if: needs.version.outputs.release
|
||||
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish always --config electron-builder.config.ts
|
||||
working-directory: packages/desktop-electron
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
|
||||
- name: Package (no publish)
|
||||
if: ${{ !needs.version.outputs.release }}
|
||||
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts
|
||||
working-directory: packages/desktop-electron
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opencode-electron-${{ matrix.settings.target }}
|
||||
path: packages/desktop-electron/dist/*
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: needs.version.outputs.release
|
||||
with:
|
||||
name: latest-yml-${{ matrix.settings.target }}
|
||||
path: packages/desktop-electron/dist/latest*.yml
|
||||
|
||||
publish:
|
||||
needs:
|
||||
- version
|
||||
- build-cli
|
||||
- build-tauri
|
||||
- build-electron
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -281,6 +400,12 @@ jobs:
|
||||
name: opencode-cli
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
if: needs.version.outputs.release
|
||||
with:
|
||||
pattern: latest-yml-*
|
||||
path: /tmp/latest-yml
|
||||
|
||||
- name: Cache apt packages (AUR)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -308,3 +433,4 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
NPM_CONFIG_PROVENANCE: false
|
||||
LATEST_YML_DIR: /tmp/latest-yml
|
||||
|
||||
38
.opencode/glossary/tr.md
Normal file
38
.opencode/glossary/tr.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# tr Glossary
|
||||
|
||||
## Sources
|
||||
|
||||
- PR #15835: https://github.com/anomalyco/opencode/pull/15835
|
||||
|
||||
## Do Not Translate (Locale Additions)
|
||||
|
||||
- `OpenCode` (preserve casing in prose, docs, and UI copy)
|
||||
- Keep lowercase `opencode` in commands, package names, paths, URLs, and other exact identifiers
|
||||
- `<TAB>` stays the literal key token in code blocks; use `Tab` for the nearby explanatory label in prose
|
||||
- Commands, flags, file paths, and code literals (keep exactly as written)
|
||||
|
||||
## Preferred Terms
|
||||
|
||||
These are PR-backed wording preferences and may evolve.
|
||||
|
||||
| English / Context | Preferred | Notes |
|
||||
| ------------------------- | --------------------------------------- | ------------------------------------------------------------- |
|
||||
| available in beta | `beta olarak mevcut` | Prefer this over `beta olarak kullanılabilir` |
|
||||
| privacy-first | `Gizlilik öncelikli tasarlandı` | Prefer this over `Önce gizlilik için tasarlandı` |
|
||||
| connect your local models | `yerel modellerinizi bağlayabilirsiniz` | Use the fuller, more direct action phrase |
|
||||
| `<TAB>` key label | `Tab` | Use `Tab` in prose; keep `<TAB>` in literal UI or code blocks |
|
||||
| cross-platform | `cross-platform (tüm platformlarda)` | Keep the English term, add a short clarification when helpful |
|
||||
|
||||
## Guidance
|
||||
|
||||
- Prefer natural Turkish phrasing over literal translation
|
||||
- Merge broken sentence fragments into one clear sentence when the source is a single thought
|
||||
- Keep product naming consistent: `OpenCode` in prose, `opencode` only for exact technical identifiers
|
||||
- When an English technical term is intentionally kept, add a short Turkish clarification only if it improves readability
|
||||
|
||||
## Avoid
|
||||
|
||||
- Avoid `beta olarak kullanılabilir` when `beta olarak mevcut` fits
|
||||
- Avoid `Önce gizlilik için tasarlandı`; use the more natural reviewed wording instead
|
||||
- Avoid `Sekme` for the translated key label in prose when referring to `<TAB>`
|
||||
- Avoid changing `opencode` to `OpenCode` inside commands, URLs, package names, or code literals
|
||||
15
AGENTS.md
15
AGENTS.md
@@ -20,6 +20,17 @@
|
||||
|
||||
Prefer single word names for variables and functions. Only use multiple words if necessary.
|
||||
|
||||
### Naming Enforcement (Read This)
|
||||
|
||||
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
|
||||
|
||||
- Use single word names by default for new locals, params, and helper functions.
|
||||
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
|
||||
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
|
||||
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
|
||||
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
|
||||
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
|
||||
|
||||
```ts
|
||||
// Good
|
||||
const foo = 1
|
||||
@@ -111,3 +122,7 @@ const table = sqliteTable("session", {
|
||||
- Avoid mocks as much as possible
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||
|
||||
## Type Checking
|
||||
|
||||
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
|
||||
6
flake.lock
generated
6
flake.lock
generated
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1770812194,
|
||||
"narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
|
||||
"lastModified": 1772091128,
|
||||
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
|
||||
"rev": "3f0336406035444b4a24b942788334af5f906259",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Context as GitHubContext } from "@actions/github/lib/context"
|
||||
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { spawn } from "node:child_process"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
type GitHubAuthor = {
|
||||
login: string
|
||||
@@ -281,7 +282,7 @@ async function assertOpencodeConnected() {
|
||||
connected = true
|
||||
break
|
||||
} catch (e) {}
|
||||
await Bun.sleep(300)
|
||||
await sleep(300)
|
||||
} while (retry++ < 30)
|
||||
|
||||
if (!connected) {
|
||||
|
||||
@@ -118,7 +118,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
|
||||
price: zenLitePrice.id,
|
||||
},
|
||||
})
|
||||
const ZEN_LITE_LIMITS = new sst.Secret("ZEN_LITE_LIMITS")
|
||||
|
||||
const zenBlackProduct = new stripe.Product("ZenBlack", {
|
||||
name: "OpenCode Black",
|
||||
@@ -142,7 +141,6 @@ const ZEN_BLACK_PRICE = new sst.Linkable("ZEN_BLACK_PRICE", {
|
||||
plan20: zenBlackPrice20.id,
|
||||
},
|
||||
})
|
||||
const ZEN_BLACK_LIMITS = new sst.Secret("ZEN_BLACK_LIMITS")
|
||||
|
||||
const ZEN_MODELS = [
|
||||
new sst.Secret("ZEN_MODELS1"),
|
||||
@@ -215,9 +213,8 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
ZEN_BLACK_PRICE,
|
||||
ZEN_BLACK_LIMITS,
|
||||
ZEN_LITE_PRICE,
|
||||
ZEN_LITE_LIMITS,
|
||||
new sst.Secret("ZEN_LIMITS"),
|
||||
new sst.Secret("ZEN_SESSION_SECRET"),
|
||||
...ZEN_MODELS,
|
||||
...($dev
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-dZoLhWe4smBsOF7WczMySLXSAB1YRO1vfhiOCL1rBf0=",
|
||||
"aarch64-linux": "sha256-J7nIz1xuVZEHun5WRZkYRySz29B0A8g5g0RRxnIWTYU=",
|
||||
"aarch64-darwin": "sha256-R2PuhX+EjUBuLE8MF0G0fcUwNaU+5n6V6uVeK89ulzw=",
|
||||
"x86_64-darwin": "sha256-Bvzfz9TsTpYriZNLSLgpNcNb+BgtkgpjoWqdOtF2IBg="
|
||||
"x86_64-linux": "sha256-TnrYykX8Mf/Ugtkix6V",
|
||||
"aarch64-linux": "sha256-TnrYykX8Mf/Ugtkix6V",
|
||||
"aarch64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V",
|
||||
"x86_64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ stdenvNoCC.mkDerivation {
|
||||
../package.json
|
||||
../patches
|
||||
../install # required by desktop build (cli.rs include_str!)
|
||||
../.github/TEAM_MEMBERS # required by @opencode-ai/script
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'",
|
||||
@@ -35,7 +36,7 @@
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/diffs": "1.1.0-beta.13",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
@@ -70,12 +71,13 @@
|
||||
"@actions/artifact": "5.0.1",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"glob": "13.0.5",
|
||||
"husky": "9.1.7",
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "3.18.10",
|
||||
"turbo": "2.5.6"
|
||||
"turbo": "2.8.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.933.0",
|
||||
@@ -98,7 +100,8 @@
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"web-tree-sitter"
|
||||
"web-tree-sitter",
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
"@types/bun": "catalog:",
|
||||
|
||||
515
packages/app/create-effect-simplification-spec.md
Normal file
515
packages/app/create-effect-simplification-spec.md
Normal file
@@ -0,0 +1,515 @@
|
||||
# CreateEffect Simplification Implementation Spec
|
||||
|
||||
Reduce reactive misuse across `packages/app`.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
This work targets `packages/app/src`, which currently has 101 `createEffect` calls across 37 files.
|
||||
|
||||
The biggest clusters are `pages/session.tsx` (19), `pages/layout.tsx` (13), `pages/session/file-tabs.tsx` (6), and several context providers that mirror one store into another.
|
||||
|
||||
Key issues from the audit:
|
||||
|
||||
- Derived state is being written through effects instead of computed directly
|
||||
- Session and file resets are handled by watch-and-clear effects instead of keyed state boundaries
|
||||
- User-driven actions are hidden inside reactive effects
|
||||
- Context layers mirror and hydrate child stores with multiple sync effects
|
||||
- Several areas repeat the same imperative trigger pattern in multiple effects
|
||||
|
||||
Keep the implementation focused on removing unnecessary effects, not on broad UI redesign.
|
||||
|
||||
## Goals
|
||||
|
||||
- Cut high-churn `createEffect` usage in the hottest files first
|
||||
- Replace effect-driven derived state with reactive derivation
|
||||
- Replace reset-on-key effects with keyed ownership boundaries
|
||||
- Move event-driven work to direct actions and write paths
|
||||
- Remove mirrored store hydration where a single source of truth can exist
|
||||
- Leave necessary external sync effects in place, but make them narrower and clearer
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not rewrite unrelated component structure just to reduce the count
|
||||
- Do not change product behavior, navigation flow, or persisted data shape unless required for a cleaner write boundary
|
||||
- Do not remove effects that bridge to DOM, editors, polling, or external APIs unless there is a clearly safer equivalent
|
||||
- Do not attempt a repo-wide cleanup outside `packages/app`
|
||||
|
||||
## Effect Taxonomy And Replacement Rules
|
||||
|
||||
Use these rules during implementation.
|
||||
|
||||
### Prefer `createMemo`
|
||||
|
||||
Use `createMemo` when the target value is pure derived state from other signals or stores.
|
||||
|
||||
Do this when an effect only reads reactive inputs and writes another reactive value that could be computed instead.
|
||||
|
||||
Apply this to:
|
||||
|
||||
- `packages/app/src/pages/session.tsx:141`
|
||||
- `packages/app/src/pages/layout.tsx:557`
|
||||
- `packages/app/src/components/terminal.tsx:261`
|
||||
- `packages/app/src/components/session/session-header.tsx:309`
|
||||
|
||||
Rules:
|
||||
|
||||
- If no external system is touched, do not use `createEffect`
|
||||
- Derive once, then read the memo where needed
|
||||
- If normalization is required, prefer normalizing at the write boundary before falling back to a memo
|
||||
|
||||
### Prefer Keyed Remounts
|
||||
|
||||
Use keyed remounts when local UI state should reset because an identity changed.
|
||||
|
||||
Do this with `sessionKey`, `scope()`, or another stable identity instead of watching the key and manually clearing signals.
|
||||
|
||||
Apply this to:
|
||||
|
||||
- `packages/app/src/pages/session.tsx:325`
|
||||
- `packages/app/src/pages/session.tsx:336`
|
||||
- `packages/app/src/pages/session.tsx:477`
|
||||
- `packages/app/src/pages/session.tsx:869`
|
||||
- `packages/app/src/pages/session.tsx:963`
|
||||
- `packages/app/src/pages/session/message-timeline.tsx:149`
|
||||
- `packages/app/src/context/file.tsx:100`
|
||||
|
||||
Rules:
|
||||
|
||||
- If the desired behavior is "new identity, fresh local state," key the owner subtree
|
||||
- Keep state local to the keyed boundary so teardown and recreation handle the reset naturally
|
||||
|
||||
### Prefer Event Handlers And Actions
|
||||
|
||||
Use direct handlers, store actions, and async command functions when work happens because a user clicked, selected, reloaded, or navigated.
|
||||
|
||||
Do this when an effect is just watching for a flag change, command token, or event-bus signal to trigger imperative logic.
|
||||
|
||||
Apply this to:
|
||||
|
||||
- `packages/app/src/pages/layout.tsx:484`
|
||||
- `packages/app/src/pages/layout.tsx:652`
|
||||
- `packages/app/src/pages/layout.tsx:776`
|
||||
- `packages/app/src/pages/layout.tsx:1489`
|
||||
- `packages/app/src/pages/layout.tsx:1519`
|
||||
- `packages/app/src/components/file-tree.tsx:328`
|
||||
- `packages/app/src/pages/session/terminal-panel.tsx:55`
|
||||
- `packages/app/src/context/global-sync.tsx:148`
|
||||
- Duplicated trigger sets in:
|
||||
- `packages/app/src/pages/session/review-tab.tsx:122`
|
||||
- `packages/app/src/pages/session/review-tab.tsx:130`
|
||||
- `packages/app/src/pages/session/review-tab.tsx:138`
|
||||
- `packages/app/src/pages/session/file-tabs.tsx:367`
|
||||
- `packages/app/src/pages/session/file-tabs.tsx:378`
|
||||
- `packages/app/src/pages/session/file-tabs.tsx:389`
|
||||
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
|
||||
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
|
||||
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
|
||||
|
||||
Rules:
|
||||
|
||||
- If the trigger is user intent, call the action at the source of that intent
|
||||
- If the same imperative work is triggered from multiple places, extract one function and call it directly
|
||||
|
||||
### Prefer `onMount` And `onCleanup`
|
||||
|
||||
Use `onMount` and `onCleanup` for lifecycle-only setup and teardown.
|
||||
|
||||
This is the right fit for subscriptions, one-time wiring, timers, and imperative integration that should not rerun for ordinary reactive changes.
|
||||
|
||||
Use this when:
|
||||
|
||||
- Setup should happen once per owner lifecycle
|
||||
- Cleanup should always pair with teardown
|
||||
- The work is not conceptually derived state
|
||||
|
||||
### Keep `createEffect` When It Is A Real Bridge
|
||||
|
||||
Keep `createEffect` when it synchronizes reactive data to an external imperative sink.
|
||||
|
||||
Examples that should remain, though they may be narrowed or split:
|
||||
|
||||
- DOM/editor sync in `packages/app/src/components/prompt-input.tsx:690`
|
||||
- Scroll sync in `packages/app/src/pages/session.tsx:685`
|
||||
- Scroll/hash sync in `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
|
||||
- External sync in:
|
||||
- `packages/app/src/context/language.tsx:207`
|
||||
- `packages/app/src/context/settings.tsx:110`
|
||||
- `packages/app/src/context/sdk.tsx:26`
|
||||
- Polling in:
|
||||
- `packages/app/src/components/status-popover.tsx:59`
|
||||
- `packages/app/src/components/dialog-select-server.tsx:273`
|
||||
|
||||
Rules:
|
||||
|
||||
- Keep the effect single-purpose
|
||||
- Make dependencies explicit and narrow
|
||||
- Avoid writing back into the same reactive graph unless absolutely required
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 0: Classification Pass
|
||||
|
||||
Before changing code, tag each targeted effect as one of: derive, reset, event, lifecycle, or external bridge.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Every targeted effect in this spec is tagged with a replacement strategy before refactoring starts
|
||||
- Shared helpers to be introduced are identified up front to avoid repeating patterns
|
||||
|
||||
### Phase 1: Derived-State Cleanup
|
||||
|
||||
Tackle highest-value, lowest-risk derived-state cleanup first.
|
||||
|
||||
Priority items:
|
||||
|
||||
- Normalize tabs at write boundaries and remove `packages/app/src/pages/session.tsx:141`
|
||||
- Stop syncing `workspaceOrder` in `packages/app/src/pages/layout.tsx:557`
|
||||
- Make prompt slash filtering reactive so `packages/app/src/components/prompt-input.tsx:652` can be removed
|
||||
- Replace other obvious derived-state effects in terminal and session header
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- No behavior change in tab ordering, prompt filtering, terminal display, or header state
|
||||
- Targeted derived-state effects are deleted, not just moved
|
||||
|
||||
### Phase 2: Keyed Reset Cleanup
|
||||
|
||||
Replace reset-on-key effects with keyed ownership boundaries.
|
||||
|
||||
Priority items:
|
||||
|
||||
- Key session-scoped UI and state by `sessionKey`
|
||||
- Key file-scoped state by `scope()`
|
||||
- Remove manual clear-and-reseed effects in session and file context
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Switching session or file scope recreates the intended local state cleanly
|
||||
- No stale state leaks across session or scope changes
|
||||
- Target reset effects are deleted
|
||||
|
||||
### Phase 3: Event-Driven Work Extraction
|
||||
|
||||
Move event-driven work out of reactive effects.
|
||||
|
||||
Priority items:
|
||||
|
||||
- Replace `globalStore.reload` effect dispatching with direct calls
|
||||
- Split mixed-responsibility effect in `packages/app/src/pages/layout.tsx:1489`
|
||||
- Collapse duplicated imperative trigger triplets into single functions
|
||||
- Move file-tree and terminal-panel imperative work to explicit handlers
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- User-triggered behavior still fires exactly once per intended action
|
||||
- No effect remains whose only job is to notice a command-like state and trigger an imperative function
|
||||
|
||||
### Phase 4: Context Ownership Cleanup
|
||||
|
||||
Remove mirrored child-store hydration patterns.
|
||||
|
||||
Priority items:
|
||||
|
||||
- Remove child-store hydration mirrors in `packages/app/src/context/global-sync/child-store.ts:184`, `:190`, `:193`
|
||||
- Simplify mirror logic in `packages/app/src/context/global-sync.tsx:130`, `:138`
|
||||
- Revisit `packages/app/src/context/layout.tsx:424` if it still mirrors instead of deriving
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- There is one clear source of truth for each synced value
|
||||
- Child stores no longer need effect-based hydration to stay consistent
|
||||
- Initialization and updates both work without manual mirror effects
|
||||
|
||||
### Phase 5: Cleanup And Keeper Review
|
||||
|
||||
Clean up remaining targeted hotspots and narrow the effects that should stay.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Remaining `createEffect` calls in touched files are all true bridges or clearly justified lifecycle sync
|
||||
- Mixed-responsibility effects are split into smaller units where still needed
|
||||
|
||||
## Detailed Work Items By Area
|
||||
|
||||
### 1. Normalize Tab State
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/pages/session.tsx:141`
|
||||
|
||||
Work:
|
||||
|
||||
- Move tab normalization into the functions that create, load, or update tab state
|
||||
- Make readers consume already-normalized tab data
|
||||
- Remove the effect that rewrites derived tab state after the fact
|
||||
|
||||
Rationale:
|
||||
|
||||
- Tabs should become valid when written, not be repaired later
|
||||
- This removes a feedback loop and makes state easier to trust
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The effect at `packages/app/src/pages/session.tsx:141` is removed
|
||||
- Newly created and restored tabs are normalized before they enter local state
|
||||
- Tab rendering still matches current behavior for valid and edge-case inputs
|
||||
|
||||
### 2. Key Session-Owned State
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/pages/session.tsx:325`
|
||||
- `packages/app/src/pages/session.tsx:336`
|
||||
- `packages/app/src/pages/session.tsx:477`
|
||||
- `packages/app/src/pages/session.tsx:869`
|
||||
- `packages/app/src/pages/session.tsx:963`
|
||||
- `packages/app/src/pages/session/message-timeline.tsx:149`
|
||||
|
||||
Work:
|
||||
|
||||
- Identify state that should reset when `sessionKey` changes
|
||||
- Move that state under a keyed subtree or keyed owner boundary
|
||||
- Remove effects that watch `sessionKey` just to clear local state, refs, or temporary UI flags
|
||||
|
||||
Rationale:
|
||||
|
||||
- Session identity already defines the lifetime of this UI state
|
||||
- Keyed ownership makes reset behavior automatic and easier to reason about
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The targeted reset effects are removed
|
||||
- Changing sessions resets only the intended session-local state
|
||||
- Scroll and editor state that should persist are not accidentally reset
|
||||
|
||||
### 3. Derive Workspace Order
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/pages/layout.tsx:557`
|
||||
|
||||
Work:
|
||||
|
||||
- Stop writing `workspaceOrder` from live workspace data in an effect
|
||||
- Represent user overrides separately from live workspace data
|
||||
- Compute effective order from current data plus overrides with a memo or pure helper
|
||||
|
||||
Rationale:
|
||||
|
||||
- Persisted user intent and live source data should not mirror each other through an effect
|
||||
- A computed effective order avoids drift and racey resync behavior
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The effect at `packages/app/src/pages/layout.tsx:557` is removed
|
||||
- Workspace order updates correctly when workspaces appear, disappear, or are reordered by the user
|
||||
- User overrides persist without requiring a sync-back effect
|
||||
|
||||
### 4. Remove Child-Store Mirrors
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/context/global-sync.tsx:130`
|
||||
- `packages/app/src/context/global-sync.tsx:138`
|
||||
- `packages/app/src/context/global-sync.tsx:148`
|
||||
- `packages/app/src/context/global-sync/child-store.ts:184`
|
||||
- `packages/app/src/context/global-sync/child-store.ts:190`
|
||||
- `packages/app/src/context/global-sync/child-store.ts:193`
|
||||
- `packages/app/src/context/layout.tsx:424`
|
||||
|
||||
Work:
|
||||
|
||||
- Trace the actual ownership of global and child store values
|
||||
- Replace hydration and mirror effects with explicit initialization and direct updates
|
||||
- Remove the `globalStore.reload` event-bus pattern and call the needed reload paths directly
|
||||
|
||||
Rationale:
|
||||
|
||||
- Mirrors make it hard to tell which state is authoritative
|
||||
- Event-bus style state toggles hide control flow and create accidental reruns
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Child store hydration no longer depends on effect-based copying
|
||||
- Reload work can be followed from the event source to the handler without a reactive relay
|
||||
- State remains correct on first load, child creation, and subsequent updates
|
||||
|
||||
### 5. Key File-Scoped State
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/context/file.tsx:100`
|
||||
|
||||
Work:
|
||||
|
||||
- Move file-scoped local state under a boundary keyed by `scope()`
|
||||
- Remove any effect that watches `scope()` only to reset file-local state
|
||||
|
||||
Rationale:
|
||||
|
||||
- File scope changes are identity changes
|
||||
- Keyed ownership gives a cleaner reset than manual clear logic
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The effect at `packages/app/src/context/file.tsx:100` is removed
|
||||
- Switching scopes resets only scope-local state
|
||||
- No previous-scope data appears after a scope change
|
||||
|
||||
### 6. Split Layout Side Effects
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/pages/layout.tsx:1489`
|
||||
- Related event-driven effects near `packages/app/src/pages/layout.tsx:484`, `:652`, `:776`, `:1519`
|
||||
|
||||
Work:
|
||||
|
||||
- Break the mixed-responsibility effect at `:1489` into direct actions and smaller bridge effects only where required
|
||||
- Move user-triggered branches into the actual command or handler that causes them
|
||||
- Remove any branch that only exists because one effect is handling unrelated concerns
|
||||
|
||||
Rationale:
|
||||
|
||||
- Mixed effects hide cause and make reruns hard to predict
|
||||
- Smaller units reduce accidental coupling and make future cleanup safer
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The effect at `packages/app/src/pages/layout.tsx:1489` no longer mixes unrelated responsibilities
|
||||
- Event-driven branches execute from direct handlers
|
||||
- Remaining effects in this area each have one clear external sync purpose
|
||||
|
||||
### 7. Remove Duplicate Triggers
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/pages/session/review-tab.tsx:122`
|
||||
- `packages/app/src/pages/session/review-tab.tsx:130`
|
||||
- `packages/app/src/pages/session/review-tab.tsx:138`
|
||||
- `packages/app/src/pages/session/file-tabs.tsx:367`
|
||||
- `packages/app/src/pages/session/file-tabs.tsx:378`
|
||||
- `packages/app/src/pages/session/file-tabs.tsx:389`
|
||||
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
|
||||
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
|
||||
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
|
||||
|
||||
Work:
|
||||
|
||||
- Extract one explicit imperative function per behavior
|
||||
- Call that function from each source event instead of replicating the same effect pattern multiple times
|
||||
- Preserve the scroll-sync effect that is truly syncing with the DOM, but remove duplicate trigger scaffolding around it
|
||||
|
||||
Rationale:
|
||||
|
||||
- Duplicate triggers make it easy to miss a case or fire twice
|
||||
- One named action is easier to test and reason about
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Repeated imperative effect triplets are collapsed into shared functions
|
||||
- Scroll behavior still works, including hash-based navigation
|
||||
- No duplicate firing is introduced
|
||||
|
||||
### 8. Make Prompt Filtering Reactive
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/components/prompt-input.tsx:652`
|
||||
- Keep `packages/app/src/components/prompt-input.tsx:690` as needed
|
||||
|
||||
Work:
|
||||
|
||||
- Convert slash filtering into a pure reactive derivation from the current input and candidate command list
|
||||
- Keep only the editor or DOM bridge effect if it is still needed for imperative syncing
|
||||
|
||||
Rationale:
|
||||
|
||||
- Filtering is classic derived state
|
||||
- It should not need an effect if it can be computed from current inputs
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The effect at `packages/app/src/components/prompt-input.tsx:652` is removed
|
||||
- Filtered slash-command results update correctly as the input changes
|
||||
- The editor sync effect at `:690` still behaves correctly
|
||||
|
||||
### 9. Clean Up Smaller Derived-State Cases
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/components/terminal.tsx:261`
|
||||
- `packages/app/src/components/session/session-header.tsx:309`
|
||||
|
||||
Work:
|
||||
|
||||
- Replace effect-written local state with memos or inline derivation
|
||||
- Remove intermediate setters when the value can be computed directly
|
||||
|
||||
Rationale:
|
||||
|
||||
- These are low-risk wins that reinforce the same pattern
|
||||
- They also help keep follow-up cleanup consistent
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Targeted effects are removed
|
||||
- UI output remains unchanged under the same inputs
|
||||
|
||||
## Verification And Regression Checks
|
||||
|
||||
Run focused checks after each phase, not only at the end.
|
||||
|
||||
### Suggested Verification
|
||||
|
||||
- Switch between sessions rapidly and confirm local session UI resets only where intended
|
||||
- Open, close, and reorder tabs and confirm order and normalization remain stable
|
||||
- Change workspaces, reload workspace data, and verify effective ordering is correct
|
||||
- Change file scope and confirm stale file state does not bleed across scopes
|
||||
- Trigger layout actions that previously depended on effects and confirm they still fire once
|
||||
- Use slash commands in the prompt and verify filtering updates as you type
|
||||
- Test review tab, file tab, and hash-scroll flows for duplicate or missing triggers
|
||||
- Verify global sync initialization, reload, and child-store creation paths
|
||||
|
||||
### Regression Checks
|
||||
|
||||
- No accidental infinite reruns
|
||||
- No double-firing network or command actions
|
||||
- No lost cleanup for listeners, timers, or scroll handlers
|
||||
- No preserved stale state after identity changes
|
||||
- No removed effect that was actually bridging to DOM or an external API
|
||||
|
||||
If available, add or update tests around pure helpers introduced during this cleanup.
|
||||
|
||||
Favor tests for derived ordering, normalization, and action extraction, since those are easiest to lock down.
|
||||
|
||||
## Definition Of Done
|
||||
|
||||
This work is done when all of the following are true:
|
||||
|
||||
- The highest-leverage targets in this spec are implemented
|
||||
- Each removed effect has been replaced by a clearer pattern: memo, keyed boundary, direct action, or lifecycle hook
|
||||
- The "should remain" effects still exist only where they serve a real external sync purpose
|
||||
- Touched files have fewer mixed-responsibility effects and clearer ownership of state
|
||||
- Manual verification covers session switching, file scope changes, workspace ordering, prompt filtering, and reload flows
|
||||
- No behavior regressions are found in the targeted areas
|
||||
|
||||
A reduced raw `createEffect` count is helpful, but it is not the main success metric.
|
||||
|
||||
The main success metric is clearer ownership and fewer effect-driven state repairs.
|
||||
|
||||
## Risks And Rollout Notes
|
||||
|
||||
Main risks:
|
||||
|
||||
- Keyed remounts can reset too much if state boundaries are drawn too high
|
||||
- Store mirror removal can break initialization order if ownership is not mapped first
|
||||
- Moving event work out of effects can accidentally skip triggers that were previously implicit
|
||||
|
||||
Rollout notes:
|
||||
|
||||
- Land in small phases, with each phase keeping the app behaviorally stable
|
||||
- Prefer isolated PRs by phase or by file cluster, especially for context-store changes
|
||||
- Review each remaining effect in touched files and leave it only if it clearly bridges to something external
|
||||
@@ -92,14 +92,19 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
|
||||
|
||||
const created = await createSdk(workspaceDir)
|
||||
.session.create()
|
||||
.then((x) => x.data?.id)
|
||||
if (!created) throw new Error(`Failed to create session for workspace: ${workspaceDir}`)
|
||||
// Create a session by sending a prompt
|
||||
const prompt = page.locator(promptSelector)
|
||||
await expect(prompt).toBeVisible()
|
||||
await prompt.fill("test")
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
// Wait for the URL to update with the new session ID
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
||||
|
||||
const created = sessionIDFromUrl(page.url())
|
||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
||||
sessionID = created
|
||||
|
||||
await page.goto(sessionPath(workspaceDir, created))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
@@ -142,6 +142,17 @@ test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
|
||||
})
|
||||
})
|
||||
|
||||
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||
await expect(button).toBeVisible()
|
||||
await expect(button).toHaveAttribute("aria-pressed", "false")
|
||||
|
||||
await setAutoAccept(page, true)
|
||||
await setAutoAccept(page, false)
|
||||
})
|
||||
|
||||
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock question", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.15",
|
||||
"version": "1.2.17",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -145,6 +145,7 @@ try {
|
||||
Object.assign(process.env, serverEnv)
|
||||
process.env.AGENT = "1"
|
||||
process.env.OPENCODE = "1"
|
||||
process.env.OPENCODE_PID = String(process.pid)
|
||||
|
||||
const log = await import("../../opencode/src/util/log")
|
||||
const install = await import("../../opencode/src/installation")
|
||||
|
||||
50
packages/app/src/api/releases.ts
Normal file
50
packages/app/src/api/releases.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Platform } from "@/context/platform"
|
||||
|
||||
const REPO = "anomalyco/opencode"
|
||||
const GITHUB_API_URL = `https://api.github.com/repos/${REPO}/releases`
|
||||
const PER_PAGE = 30
|
||||
const CACHE_TTL = 1000 * 60 * 30
|
||||
const CACHE_KEY = "opencode.releases"
|
||||
|
||||
type Release = {
|
||||
tag: string
|
||||
body: string
|
||||
date: string
|
||||
}
|
||||
|
||||
function loadCache() {
|
||||
const raw = localStorage.getItem(CACHE_KEY)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}
|
||||
|
||||
function saveCache(data: { releases: Release[]; timestamp: number }) {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(data))
|
||||
}
|
||||
|
||||
export async function fetchReleases(platform: Platform): Promise<{ releases: Release[] }> {
|
||||
const now = Date.now()
|
||||
const cached = loadCache()
|
||||
|
||||
if (cached && now - cached.timestamp < CACHE_TTL) {
|
||||
return { releases: cached.releases }
|
||||
}
|
||||
|
||||
const fetcher = platform.fetch ?? fetch
|
||||
const res = await fetcher(`${GITHUB_API_URL}?per_page=${PER_PAGE}`, {
|
||||
headers: { Accept: "application/vnd.github.v3+json" },
|
||||
}).then((r) => (r.ok ? r.json() : Promise.reject(new Error("Failed to load"))))
|
||||
|
||||
const releases = (Array.isArray(res) ? res : []).map((r) => ({
|
||||
tag: r.tag_name ?? "Unknown",
|
||||
body: (r.body ?? "")
|
||||
.replace(/#(\d+)/g, (_: string, id: string) => `[#${id}](https://github.com/anomalyco/opencode/pull/${id})`)
|
||||
.replace(/@([a-zA-Z0-9_-]+)/g, (_: string, u: string) => `[@${u}](https://github.com/${u})`),
|
||||
date: r.published_at ?? "",
|
||||
}))
|
||||
|
||||
saveCache({ releases, timestamp: now })
|
||||
|
||||
return { releases }
|
||||
}
|
||||
|
||||
export type { Release }
|
||||
@@ -7,8 +7,8 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { Navigate, Route, Router } from "@solidjs/router"
|
||||
import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
|
||||
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
|
||||
import { CommandProvider } from "@/context/command"
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
@@ -28,6 +28,7 @@ import { TerminalProvider } from "@/context/terminal"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
|
||||
const Home = lazy(() => import("@/pages/home"))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
@@ -144,13 +145,15 @@ export function AppInterface(props: {
|
||||
children?: JSX.Element
|
||||
defaultServer: ServerConnection.Key
|
||||
servers?: Array<ServerConnection.Any>
|
||||
router?: Component<BaseRouterProps>
|
||||
}) {
|
||||
return (
|
||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||
<ServerKey>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<Router
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||
>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
@@ -158,7 +161,7 @@ export function AppInterface(props: {
|
||||
<Route path="/" component={SessionIndexRoute} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Router>
|
||||
</Dynamic>
|
||||
</GlobalSyncProvider>
|
||||
</GlobalSDKProvider>
|
||||
</ServerKey>
|
||||
|
||||
150
packages/app/src/components/dialog-changelog.css
Normal file
150
packages/app/src/components/dialog-changelog.css
Normal file
@@ -0,0 +1,150 @@
|
||||
.dialog-changelog {
|
||||
min-height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-changelog [data-slot="dialog-body"] {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dialog-changelog-list {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dialog-changelog-list [data-slot="list-scroll"] {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-weak-base) transparent;
|
||||
}
|
||||
|
||||
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar-thumb {
|
||||
background: var(--border-weak-base);
|
||||
border-radius: 5px;
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-weak-base);
|
||||
}
|
||||
|
||||
.dialog-changelog-header {
|
||||
padding: 8px 12px 8px 8px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
}
|
||||
|
||||
.dialog-changelog-header::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 16px;
|
||||
background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.dialog-changelog-header[data-stuck="true"]::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.dialog-changelog-version {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dialog-changelog-date {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
.dialog-changelog-list [data-slot="list-item"] {
|
||||
margin-bottom: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dialog-changelog-list [data-slot="list-item"]:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dialog-changelog-list [data-slot="list-item"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dialog-changelog-list [data-slot="list-item"]:focus-visible {
|
||||
outline: 2px solid var(--focus-base);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dialog-changelog-content {
|
||||
padding: 0 8px 24px;
|
||||
}
|
||||
|
||||
.dialog-changelog-markdown h2 {
|
||||
border-bottom: 1px solid var(--border-weak-base);
|
||||
padding-bottom: 4px;
|
||||
margin: 32px 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.dialog-changelog-markdown h2:first-child {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.dialog-changelog-markdown a.external-link {
|
||||
color: var(--text-interactive-base);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/pull/"],
|
||||
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/issues/"],
|
||||
.dialog-changelog-markdown a.external-link[href^="https://github.com/"]
|
||||
{
|
||||
border-radius: 3px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/pull/"]:hover,
|
||||
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/issues/"]:hover,
|
||||
.dialog-changelog-markdown a.external-link[href^="https://github.com/"]:hover
|
||||
{
|
||||
background: var(--surface-weak-base);
|
||||
}
|
||||
43
packages/app/src/components/dialog-changelog.tsx
Normal file
43
packages/app/src/components/dialog-changelog.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createResource, Suspense, ErrorBoundary, Show } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { DataProvider } from "@opencode-ai/ui/context"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { fetchReleases } from "@/api/releases"
|
||||
import { ReleaseList } from "@/components/release-list"
|
||||
|
||||
export function DialogChangelog() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [data] = createResource(() => fetchReleases(platform))
|
||||
|
||||
return (
|
||||
<Dialog size="x-large" transition title="Changelog">
|
||||
<DataProvider data={{ session: [], session_status: {}, session_diff: {}, message: {}, part: {} }} directory="">
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
<ErrorBoundary
|
||||
fallback={(e) => (
|
||||
<p class="text-text-weak p-6">
|
||||
{e instanceof Error ? e.message : "Failed to load changelog"}
|
||||
</p>
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={<p class="text-text-weak p-6">{language.t("common.loading")}...</p>}>
|
||||
<Show
|
||||
when={(data()?.releases.length ?? 0) > 0}
|
||||
fallback={<p class="text-text-weak p-6">{language.t("common.noReleasesFound")}</p>}
|
||||
>
|
||||
<ReleaseList
|
||||
releases={data()!.releases}
|
||||
hasMore={false}
|
||||
loadingMore={false}
|
||||
onLoadMore={() => {}}
|
||||
/>
|
||||
</Show>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</DataProvider>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
@@ -447,7 +446,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
>
|
||||
<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 as IconName} class="size-5 shrink-0 icon-strong-base" />
|
||||
<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")}>
|
||||
|
||||
@@ -459,4 +459,4 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
</List>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
@@ -95,7 +94,7 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-3">
|
||||
<ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
|
||||
<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>
|
||||
|
||||
@@ -5,18 +5,12 @@ 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 { iconNames, type IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
|
||||
const CUSTOM_ID = "_custom"
|
||||
|
||||
function icon(id: string): IconName {
|
||||
if (iconNames.includes(id as IconName)) return id as IconName
|
||||
return "synthetic"
|
||||
}
|
||||
|
||||
export const DialogSelectProvider: Component = () => {
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders()
|
||||
@@ -69,7 +63,7 @@ export const DialogSelectProvider: Component = () => {
|
||||
>
|
||||
{(i) => (
|
||||
<div class="px-1.25 w-full flex items-center gap-x-3">
|
||||
<ProviderIcon data-slot="list-item-extra-icon" id={icon(i.id)} />
|
||||
<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>
|
||||
|
||||
@@ -2,16 +2,25 @@ import { Component } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
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 { SettingsArchive } from "./settings-archive"
|
||||
import { DialogChangelog } from "@/components/dialog-changelog"
|
||||
|
||||
export const DialogSettings: Component = () => {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
|
||||
function handleShowChangelog() {
|
||||
dialog.show(() => <DialogChangelog />)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog size="x-large" transition>
|
||||
@@ -47,11 +56,27 @@ export const DialogSettings: Component = () => {
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Tabs.SectionTitle>{language.t("settings.section.data")}</Tabs.SectionTitle>
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<Tabs.Trigger value="archive">
|
||||
<Icon name="archive" />
|
||||
{language.t("settings.archive.title")}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
|
||||
<span>{language.t("app.name.desktop")}</span>
|
||||
<span class="text-11-regular">v{platform.version}</span>
|
||||
<button
|
||||
class="text-11-regular text-text-weak hover:text-text-base self-start"
|
||||
onClick={handleShowChangelog}
|
||||
>
|
||||
Changelog
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
@@ -67,6 +92,9 @@ export const DialogSettings: Component = () => {
|
||||
<Tabs.Content value="models" class="no-scrollbar">
|
||||
<SettingsModels />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="archive" class="no-scrollbar">
|
||||
<SettingsArchive />
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -325,12 +325,6 @@ export default function FileTree(props: {
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const dir = file.tree.state(props.path)
|
||||
if (!shouldListExpanded({ level, dir })) return
|
||||
void file.tree.list(props.path)
|
||||
})
|
||||
|
||||
const nodes = createMemo(() => {
|
||||
const nodes = file.tree.children(props.path)
|
||||
const current = filter()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||
@@ -23,7 +24,6 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
@@ -244,6 +244,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
draggingType: "image" | "@mention" | null
|
||||
mode: "normal" | "shell"
|
||||
applyingHistory: boolean
|
||||
pendingAutoAccept: boolean
|
||||
}>({
|
||||
popover: null,
|
||||
historyIndex: -1,
|
||||
@@ -252,6 +253,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
pendingAutoAccept: false,
|
||||
})
|
||||
|
||||
const buttonsSpring = useSpring(
|
||||
() => (store.mode === "normal" ? 1 : 0),
|
||||
{ visualDuration: 0.2, bounce: 0 },
|
||||
)
|
||||
|
||||
const springFade = (t: number): Record<string, string> => ({
|
||||
opacity: `${t}`,
|
||||
transform: `scale(${0.95 + t * 0.05})`,
|
||||
filter: `blur(${(1 - t) * 2}px)`,
|
||||
"pointer-events": t > 0.5 ? "auto" : "none",
|
||||
})
|
||||
|
||||
const commentCount = createMemo(() => {
|
||||
@@ -302,6 +316,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(sessionKey, () => {
|
||||
setStore("pendingAutoAccept", false)
|
||||
}),
|
||||
)
|
||||
|
||||
const historyComments = () => {
|
||||
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||
return prompt.context.items().flatMap((item) => {
|
||||
@@ -592,7 +612,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setActive: setSlashActive,
|
||||
onInput: slashOnInput,
|
||||
onKeyDown: slashOnKeyDown,
|
||||
refetch: slashRefetch,
|
||||
} = useFilteredList<SlashCommand>({
|
||||
items: slashCommands,
|
||||
key: (x) => x?.id,
|
||||
@@ -649,14 +668,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sync.data.command,
|
||||
() => slashRefetch(),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
// Auto-scroll active command into view when navigating with keyboard
|
||||
createEffect(() => {
|
||||
const activeId = slashActive()
|
||||
@@ -957,10 +968,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
readClipboardImage: platform.readClipboardImage,
|
||||
})
|
||||
|
||||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||
const accepting = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return store.pendingAutoAccept
|
||||
return permission.isAutoAccepting(id, sdk.directory)
|
||||
})
|
||||
|
||||
const { abort, handleSubmit } = createPromptSubmit({
|
||||
info,
|
||||
imageAttachments,
|
||||
commentCount,
|
||||
autoAccept: () => accepting(),
|
||||
mode: () => store.mode,
|
||||
working,
|
||||
editor: () => editorRef,
|
||||
@@ -1125,13 +1144,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||
const accepting = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return permission.isAutoAccepting(id, sdk.directory)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
|
||||
<PromptPopover
|
||||
@@ -1251,11 +1263,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
<div
|
||||
aria-hidden={store.mode !== "normal"}
|
||||
class="flex items-center gap-1 transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
"opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
|
||||
"opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
|
||||
}}
|
||||
class="flex items-center gap-1"
|
||||
style={{ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none" }}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
@@ -1267,6 +1276,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="size-8 p-0"
|
||||
style={springFade(buttonsSpring())}
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
@@ -1304,6 +1314,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
style={springFade(buttonsSpring())}
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -1323,9 +1334,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Button
|
||||
data-action="prompt-permissions"
|
||||
variant="ghost"
|
||||
disabled={!params.id}
|
||||
onClick={() => {
|
||||
if (!params.id) return
|
||||
if (!params.id) {
|
||||
setStore("pendingAutoAccept", (value) => !value)
|
||||
return
|
||||
}
|
||||
permission.toggleAutoAccept(params.id, sdk.directory)
|
||||
}}
|
||||
classList={{
|
||||
@@ -1354,14 +1367,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
||||
<DockTray attach="top">
|
||||
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<Show when={store.mode === "shell"}>
|
||||
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
|
||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||
<div class="size-4 shrink-0" />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
|
||||
<div
|
||||
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
|
||||
style={{ padding: "0 4px 0 8px", ...springFade(1 - buttonsSpring()) }}
|
||||
>
|
||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||
<div class="size-4 shrink-0" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
@@ -1375,7 +1389,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{ height: "28px" }}
|
||||
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
@@ -1393,12 +1407,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular group"
|
||||
style={{ height: "28px" }}
|
||||
style={{ height: "28px", ...springFade(buttonsSpring()) }}
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()!.provider.id as IconName}
|
||||
id={local.model.current()!.provider.id}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
@@ -1422,13 +1436,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: { height: "28px" },
|
||||
style: { height: "28px", ...springFade(buttonsSpring()) },
|
||||
class: "min-w-0 max-w-[320px] text-13-regular group",
|
||||
}}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()!.provider.id as IconName}
|
||||
id={local.model.current()!.provider.id}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
@@ -1454,11 +1468,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{ height: "28px" }}
|
||||
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<RadioGroup
|
||||
|
||||
@@ -5,6 +5,7 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
const createdClients: string[] = []
|
||||
const createdSessions: string[] = []
|
||||
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
|
||||
const sentShell: string[] = []
|
||||
const syncedDirectories: string[] = []
|
||||
|
||||
@@ -69,6 +70,14 @@ beforeAll(async () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/permission", () => ({
|
||||
usePermission: () => ({
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
enabledAutoAccept.push({ sessionID, directory })
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/prompt", () => ({
|
||||
usePrompt: () => ({
|
||||
current: () => promptValue,
|
||||
@@ -145,6 +154,7 @@ beforeAll(async () => {
|
||||
beforeEach(() => {
|
||||
createdClients.length = 0
|
||||
createdSessions.length = 0
|
||||
enabledAutoAccept.length = 0
|
||||
sentShell.length = 0
|
||||
syncedDirectories.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
@@ -156,6 +166,7 @@ describe("prompt submit worktree selection", () => {
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
@@ -181,4 +192,31 @@ describe("prompt submit worktree selection", () => {
|
||||
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
})
|
||||
|
||||
test("applies auto-accept to newly created sessions", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => true,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { errorMessage } from "@/pages/layout/helpers"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import type { Accessor } from "solid-js"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
@@ -27,6 +29,7 @@ type PromptSubmitInput = {
|
||||
info: Accessor<{ id: string } | undefined>
|
||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||
commentCount: Accessor<number>
|
||||
autoAccept: Accessor<boolean>
|
||||
mode: Accessor<"normal" | "shell">
|
||||
working: Accessor<boolean>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
@@ -56,19 +59,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const sync = useSync()
|
||||
const globalSync = useGlobalSync()
|
||||
const local = useLocal()
|
||||
const permission = usePermission()
|
||||
const prompt = usePrompt()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const params = useParams()
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
const data = (err as { data?: { message?: string } }).data
|
||||
if (data?.message) return data.message
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
|
||||
|
||||
const abort = async () => {
|
||||
const sessionID = params.id
|
||||
@@ -140,6 +137,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
const projectDirectory = sdk.directory
|
||||
const isNewSession = !params.id
|
||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
@@ -153,7 +151,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
@@ -192,11 +190,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
if (session) {
|
||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
}
|
||||
@@ -249,7 +248,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
@@ -281,7 +280,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
@@ -327,9 +326,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
messageID,
|
||||
})
|
||||
|
||||
removeCommentItems(commentItems)
|
||||
clearInput()
|
||||
addOptimisticMessage()
|
||||
batch(() => {
|
||||
removeCommentItems(commentItems)
|
||||
clearInput()
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "busy" })
|
||||
}
|
||||
addOptimisticMessage()
|
||||
})
|
||||
|
||||
const waitForWorktree = async () => {
|
||||
const worktree = WorktreeState.get(sessionDirectory)
|
||||
@@ -406,7 +410,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
restoreCommentItems(commentItems)
|
||||
|
||||
61
packages/app/src/components/release-list.tsx
Normal file
61
packages/app/src/components/release-list.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Component } from "solid-js"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Markdown } from "@opencode-ai/ui/markdown"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
|
||||
type Release = {
|
||||
tag: string
|
||||
body: string
|
||||
date: string
|
||||
}
|
||||
|
||||
interface ReleaseListProps {
|
||||
releases: Release[]
|
||||
hasMore: boolean
|
||||
loadingMore: boolean
|
||||
onLoadMore: () => void
|
||||
}
|
||||
|
||||
export const ReleaseList: Component<ReleaseListProps> = (props) => {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<List
|
||||
items={props.releases}
|
||||
key={(x) => x.tag}
|
||||
search={false}
|
||||
emptyMessage="No releases found"
|
||||
loadingMessage={language.t("common.loading")}
|
||||
class="flex-1 min-h-0 overflow-hidden flex flex-col [&_[data-slot=list-scroll]]:session-scroller [&_[data-slot=list-item]]:block [&_[data-slot=list-item]]:p-0 [&_[data-slot=list-item]]:border-0 [&_[data-slot=list-item]]:bg-transparent [&_[data-slot=list-item]]:text-left [&_[data-slot=list-item]]:cursor-default [&_[data-slot=list-item]]:hover:bg-transparent [&_[data-slot=list-item]]:focus:outline-none"
|
||||
add={{
|
||||
render: () =>
|
||||
props.hasMore ? (
|
||||
<div class="p-4 flex justify-center">
|
||||
<Button variant="secondary" size="small" onClick={props.onLoadMore} loading={props.loadingMore}>
|
||||
{language.t("common.loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null,
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<div class="mb-8">
|
||||
<div class="py-2 pr-3 pl-2 flex items-baseline gap-2 sticky top-0 z-10 bg-surface-raised-stronger-non-alpha">
|
||||
<span class="text-[20px] font-semibold">{item.tag}</span>
|
||||
<span class="text-xs text-text-weak">{item.date ? getRelativeTime(item.date, language.t) : ""}</span>
|
||||
{item.tag === props.releases[0]?.tag && <Tag>{language.t("changelog.tag.latest")}</Tag>}
|
||||
</div>
|
||||
<div class="px-2 pb-2">
|
||||
<Markdown
|
||||
text={item.body}
|
||||
class="prose prose-sm max-w-none text-text-base [&_h2]:border-b [&_h2]:border-border-weak-base [&_h2]:pb-1 [&_h2]:mt-8 [&_h2]:mb-3 [&_h2]:text-sm [&_h2]:font-medium [&_h2]:capitalize [&_h2:first-child]:mt-4 [&_a.external-link]:text-text-interactive-base [&_a.external-link]:font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
|
||||
|
||||
function user(id: string): Message {
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
sessionID: "session-1",
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
function assistant(id: string, parentID: string): Message {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
sessionID: "session-1",
|
||||
parentID,
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
describe("findAssistantMessages", () => {
|
||||
test("normal ordering: assistant after user in array → found via forward scan", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("clock skew: assistant before user in array → found via backward scan", () => {
|
||||
// When client clock is ahead, user ID sorts after assistant ID,
|
||||
// so assistant appears earlier in the ID-sorted message array
|
||||
const messages = [assistant("a1", "u1"), user("u1")]
|
||||
const result = findAssistantMessages(messages, 1, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("no assistant messages → returns empty array", () => {
|
||||
const messages = [user("u1"), user("u2")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("multiple assistant messages with matching parentID → all found", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].id).toBe("a1")
|
||||
expect(result[1].id).toBe("a2")
|
||||
})
|
||||
|
||||
test("does not return assistant messages with different parentID", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("stops forward scan at next user message", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("stops backward scan at previous user message", () => {
|
||||
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
|
||||
const result = findAssistantMessages(messages, 3, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("invalid index returns empty array", () => {
|
||||
const messages = [user("u1")]
|
||||
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
|
||||
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -4,8 +4,7 @@ import { useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { same } from "@/utils/same"
|
||||
import { findLast, same } from "@opencode-ai/util/array"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
|
||||
@@ -138,12 +138,12 @@ function useSessionShare(args: {
|
||||
globalSDK: ReturnType<typeof useGlobalSDK>
|
||||
currentSession: () =>
|
||||
| {
|
||||
id: string
|
||||
share?: {
|
||||
url?: string
|
||||
}
|
||||
}
|
||||
| undefined
|
||||
sessionID: () => string | undefined
|
||||
projectDirectory: () => string
|
||||
platform: ReturnType<typeof usePlatform>
|
||||
}) {
|
||||
@@ -167,11 +167,11 @@ function useSessionShare(args: {
|
||||
})
|
||||
|
||||
const shareSession = () => {
|
||||
const session = args.currentSession()
|
||||
if (!session || state.share) return
|
||||
const sessionID = args.sessionID()
|
||||
if (!sessionID || state.share) return
|
||||
setState("share", true)
|
||||
args.globalSDK.client.session
|
||||
.share({ sessionID: session.id, directory: args.projectDirectory() })
|
||||
.share({ sessionID, directory: args.projectDirectory() })
|
||||
.catch((error) => {
|
||||
console.error("Failed to share session", error)
|
||||
})
|
||||
@@ -181,11 +181,11 @@ function useSessionShare(args: {
|
||||
}
|
||||
|
||||
const unshareSession = () => {
|
||||
const session = args.currentSession()
|
||||
if (!session || state.unshare) return
|
||||
const sessionID = args.sessionID()
|
||||
if (!sessionID || state.unshare) return
|
||||
setState("unshare", true)
|
||||
args.globalSDK.client.session
|
||||
.unshare({ sessionID: session.id, directory: args.projectDirectory() })
|
||||
.unshare({ sessionID, directory: args.projectDirectory() })
|
||||
.catch((error) => {
|
||||
console.error("Failed to unshare session", error)
|
||||
})
|
||||
@@ -243,9 +243,9 @@ export function SessionHeader() {
|
||||
})
|
||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||
|
||||
const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
|
||||
const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||
const showShare = createMemo(() => shareEnabled() && !!currentSession())
|
||||
const showShare = createMemo(() => shareEnabled() && !!params.id)
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const os = createMemo(() => detectOS(platform))
|
||||
@@ -306,11 +306,10 @@ export function SessionHeader() {
|
||||
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
||||
const opening = createMemo(() => openRequest.app !== undefined)
|
||||
|
||||
createEffect(() => {
|
||||
const value = prefs.app
|
||||
if (options().some((o) => o.id === value)) return
|
||||
setPrefs("app", options()[0]?.id ?? "finder")
|
||||
})
|
||||
const selectApp = (app: OpenApp) => {
|
||||
if (!options().some((item) => item.id === app)) return
|
||||
setPrefs("app", app)
|
||||
}
|
||||
|
||||
const openDir = (app: OpenApp) => {
|
||||
if (opening() || !canOpen() || !platform.openPath) return
|
||||
@@ -347,6 +346,7 @@ export function SessionHeader() {
|
||||
const share = useSessionShare({
|
||||
globalSDK,
|
||||
currentSession,
|
||||
sessionID: () => params.id,
|
||||
projectDirectory,
|
||||
platform,
|
||||
})
|
||||
@@ -458,7 +458,7 @@ export function SessionHeader() {
|
||||
value={current().id}
|
||||
onChange={(value) => {
|
||||
if (!OPEN_APPS.includes(value as OpenApp)) return
|
||||
setPrefs("app", value as OpenApp)
|
||||
selectApp(value as OpenApp)
|
||||
}}
|
||||
>
|
||||
<For each={options()}>
|
||||
|
||||
188
packages/app/src/components/settings-archive.tsx
Normal file
188
packages/app/src/components/settings-archive.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { RadioGroup } from "@opencode-ai/ui/radio-group"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { SessionSkeleton } from "@/pages/layout/sidebar-items"
|
||||
|
||||
type FilterScope = "all" | "current"
|
||||
|
||||
type ScopeOption = { value: FilterScope; label: "settings.archive.scope.all" | "settings.archive.scope.current" }
|
||||
|
||||
const scopeOptions: ScopeOption[] = [
|
||||
{ value: "all", label: "settings.archive.scope.all" },
|
||||
{ value: "current", label: "settings.archive.scope.current" },
|
||||
]
|
||||
|
||||
export const SettingsArchive: Component = () => {
|
||||
const language = useLanguage()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const layout = useLayout()
|
||||
const params = useParams()
|
||||
const [removedIds, setRemovedIds] = createSignal<Set<string>>(new Set())
|
||||
|
||||
const projects = createMemo(() => globalSync.data.project)
|
||||
const layoutProjects = createMemo(() => layout.projects.list())
|
||||
const hasMultipleProjects = createMemo(() => projects().length > 1)
|
||||
const homedir = createMemo(() => globalSync.data.path.home)
|
||||
|
||||
const defaultScope = () => (hasMultipleProjects() ? "current" : "all")
|
||||
const [filterScope, setFilterScope] = createSignal<FilterScope>(defaultScope())
|
||||
|
||||
const currentDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||
|
||||
const currentProject = createMemo(() => {
|
||||
const dir = currentDirectory()
|
||||
if (!dir) return null
|
||||
return layoutProjects().find((p) => p.worktree === dir || p.sandboxes?.includes(dir)) ?? null
|
||||
})
|
||||
|
||||
const filteredProjects = createMemo(() => {
|
||||
if (filterScope() === "current" && currentProject()) {
|
||||
return [currentProject()!]
|
||||
}
|
||||
return layoutProjects()
|
||||
})
|
||||
|
||||
const getSessionLabel = (session: Session) => {
|
||||
const directory = session.directory
|
||||
const home = homedir()
|
||||
const path = home ? directory.replace(home, "~") : directory
|
||||
|
||||
if (filterScope() === "current" && currentProject()) {
|
||||
const current = currentProject()
|
||||
const kind =
|
||||
current && directory === current.worktree
|
||||
? language.t("workspace.type.local")
|
||||
: language.t("workspace.type.sandbox")
|
||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
||||
const name = store.vcs?.branch ?? getFilename(directory)
|
||||
return `${kind} : ${name || path}`
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
const [archivedSessions] = createResource(
|
||||
() => ({ scope: filterScope(), projects: filteredProjects() }),
|
||||
async ({ projects }) => {
|
||||
const allSessions: Session[] = []
|
||||
for (const project of projects) {
|
||||
const directories = [project.worktree, ...(project.sandboxes ?? [])]
|
||||
for (const directory of directories) {
|
||||
const result = await globalSDK.client.experimental.session.list({ directory, archived: true })
|
||||
const sessions = result.data ?? []
|
||||
for (const session of sessions) {
|
||||
allSessions.push(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
return allSessions.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
|
||||
},
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
const displayedSessions = () => {
|
||||
const sessions = archivedSessions() ?? []
|
||||
const removed = removedIds()
|
||||
return sessions.filter((s) => !removed.has(s.id))
|
||||
}
|
||||
|
||||
const currentScopeOption = () => scopeOptions.find((o) => o.value === filterScope())
|
||||
|
||||
const unarchiveSession = async (session: Session) => {
|
||||
setRemovedIds((prev) => new Set(prev).add(session.id))
|
||||
await globalSDK.client.session.update({
|
||||
directory: session.directory,
|
||||
sessionID: session.id,
|
||||
time: { archived: null as any },
|
||||
})
|
||||
}
|
||||
|
||||
const handleScopeChange = (option: ScopeOption | undefined) => {
|
||||
if (!option) return
|
||||
setRemovedIds(new Set<string>())
|
||||
setFilterScope(option.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.archive.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{language.t("settings.archive.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 max-w-[720px]">
|
||||
<Show when={hasMultipleProjects()}>
|
||||
<RadioGroup
|
||||
options={scopeOptions}
|
||||
current={currentScopeOption() ?? undefined}
|
||||
value={(o) => o.value}
|
||||
size="small"
|
||||
label={(o) => language.t(o.label)}
|
||||
onSelect={handleScopeChange}
|
||||
/>
|
||||
</Show>
|
||||
<Show
|
||||
when={!archivedSessions.loading}
|
||||
fallback={
|
||||
<div class="min-h-[700px]">
|
||||
<SessionSkeleton count={4} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={displayedSessions().length}
|
||||
fallback={
|
||||
<div class="min-h-[700px]">
|
||||
<div class="text-14-regular text-text-weak">{language.t("settings.archive.none")}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="min-h-[700px] flex flex-col gap-2">
|
||||
<For each={displayedSessions()}>
|
||||
{(session) => (
|
||||
<div class="flex items-center justify-between gap-4 px-3 py-1 rounded-md hover:bg-surface-raised-base-hover">
|
||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-strong truncate">{session.title}</span>
|
||||
<span class="text-14-regular text-text-weak truncate">{getSessionLabel(session)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 shrink-0">
|
||||
<Show when={session.time?.updated}>
|
||||
{(updated) => (
|
||||
<span class="text-12-regular text-text-weak whitespace-nowrap">
|
||||
{getRelativeTime(new Date(updated()).toISOString(), language.t)}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<Button
|
||||
size="normal"
|
||||
variant="secondary"
|
||||
onClick={() => unarchiveSession(session)}
|
||||
>
|
||||
{language.t("common.unarchive")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useModels } from "@/context/models"
|
||||
@@ -98,7 +97,7 @@ export const SettingsModels: Component = () => {
|
||||
{(group) => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<ProviderIcon id={group.category as IconName} class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
|
||||
</div>
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { iconNames, type IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -33,11 +32,6 @@ export const SettingsProviders: Component = () => {
|
||||
const globalSync = useGlobalSync()
|
||||
const providers = useProviders()
|
||||
|
||||
const icon = (id: string): IconName => {
|
||||
if (iconNames.includes(id as IconName)) return id as IconName
|
||||
return "synthetic"
|
||||
}
|
||||
|
||||
const connected = createMemo(() => {
|
||||
return providers
|
||||
.connected()
|
||||
@@ -154,7 +148,7 @@ export const SettingsProviders: Component = () => {
|
||||
{(item) => (
|
||||
<div class="group flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<ProviderIcon id={icon(item.id)} class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong truncate">{item.name}</span>
|
||||
<Tag>{type(item)}</Tag>
|
||||
</div>
|
||||
@@ -185,7 +179,7 @@ export const SettingsProviders: Component = () => {
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<ProviderIcon id={icon(item.id)} class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{item.name}</span>
|
||||
<Show when={item.id === "opencode"}>
|
||||
<span class="text-14-regular text-text-weak">
|
||||
@@ -228,7 +222,7 @@ export const SettingsProviders: Component = () => {
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<ProviderIcon id={icon("synthetic")} class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</div>
|
||||
|
||||
@@ -202,29 +202,26 @@ export function StatusPopover() {
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
class:
|
||||
"rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
|
||||
class: "titlebar-icon w-6 h-6 p-0 box-border",
|
||||
"aria-label": language.t("status.popover.trigger"),
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={
|
||||
<div class="flex items-center gap-0.5">
|
||||
<div class="size-4 flex items-center justify-center">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full": true,
|
||||
"bg-icon-success-base": overallHealthy(),
|
||||
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
||||
"bg-border-weak-base": server.healthy() === undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-12-regular text-text-strong">{language.t("status.popover.trigger")}</span>
|
||||
<div class="flex size-4 items-center justify-center">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full": true,
|
||||
"bg-icon-success-base": overallHealthy(),
|
||||
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
||||
"bg-border-weak-base": server.healthy() === undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
shift={-136}
|
||||
shift={-168}
|
||||
>
|
||||
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
||||
<Tabs
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
||||
import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import { SerializeAddon } from "@/addons/serialize"
|
||||
import { matchKeybind, parseKeybind } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -18,7 +18,7 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||
export interface TerminalProps extends ComponentProps<"div"> {
|
||||
pty: LocalPTY
|
||||
onSubmit?: () => void
|
||||
onCleanup?: (pty: LocalPTY) => void
|
||||
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
||||
onConnect?: () => void
|
||||
onConnectError?: (error: unknown) => void
|
||||
}
|
||||
@@ -126,8 +126,8 @@ const persistTerminal = (input: {
|
||||
term: Term | undefined
|
||||
addon: SerializeAddon | undefined
|
||||
cursor: number
|
||||
pty: LocalPTY
|
||||
onCleanup?: (pty: LocalPTY) => void
|
||||
id: string
|
||||
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
||||
}) => {
|
||||
if (!input.addon || !input.onCleanup || !input.term) return
|
||||
const buffer = (() => {
|
||||
@@ -140,7 +140,7 @@ const persistTerminal = (input: {
|
||||
})()
|
||||
|
||||
input.onCleanup({
|
||||
...input.pty,
|
||||
id: input.id,
|
||||
buffer,
|
||||
cursor: input.cursor,
|
||||
rows: input.term.rows,
|
||||
@@ -158,6 +158,19 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const server = useServer()
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
|
||||
const id = local.pty.id
|
||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||
const restoreSize =
|
||||
restore &&
|
||||
typeof local.pty.cols === "number" &&
|
||||
Number.isSafeInteger(local.pty.cols) &&
|
||||
local.pty.cols > 0 &&
|
||||
typeof local.pty.rows === "number" &&
|
||||
Number.isSafeInteger(local.pty.rows) &&
|
||||
local.pty.rows > 0
|
||||
? { cols: local.pty.cols, rows: local.pty.rows }
|
||||
: undefined
|
||||
const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined
|
||||
let ws: WebSocket | undefined
|
||||
let term: Term | undefined
|
||||
let ghostty: Ghostty
|
||||
@@ -190,7 +203,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const pushSize = (cols: number, rows: number) => {
|
||||
return sdk.client.pty
|
||||
.update({
|
||||
ptyID: local.pty.id,
|
||||
ptyID: id,
|
||||
size: { cols, rows },
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -219,7 +232,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
|
||||
const terminalColors = createMemo(getTerminalColors)
|
||||
|
||||
const scheduleFit = () => {
|
||||
if (disposed) return
|
||||
@@ -259,8 +272,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const colors = getTerminalColors()
|
||||
setTerminalColors(colors)
|
||||
const colors = terminalColors()
|
||||
if (!term) return
|
||||
setOptionIfSupported(term, "theme", colors)
|
||||
})
|
||||
@@ -320,18 +332,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const mod = loaded.mod
|
||||
const g = loaded.ghostty
|
||||
|
||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||
const restoreSize =
|
||||
restore &&
|
||||
typeof local.pty.cols === "number" &&
|
||||
Number.isSafeInteger(local.pty.cols) &&
|
||||
local.pty.cols > 0 &&
|
||||
typeof local.pty.rows === "number" &&
|
||||
Number.isSafeInteger(local.pty.rows) &&
|
||||
local.pty.rows > 0
|
||||
? { cols: local.pty.cols, rows: local.pty.rows }
|
||||
: undefined
|
||||
|
||||
const t = new mod.Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
@@ -428,14 +428,14 @@ export const Terminal = (props: TerminalProps) => {
|
||||
await write(restore)
|
||||
fit.fit()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
|
||||
if (scrollY !== undefined) t.scrollToLine(scrollY)
|
||||
startResize()
|
||||
} else {
|
||||
fit.fit()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
if (restore) {
|
||||
await write(restore)
|
||||
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
|
||||
if (scrollY !== undefined) t.scrollToLine(scrollY)
|
||||
}
|
||||
startResize()
|
||||
}
|
||||
@@ -447,9 +447,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const once = { value: false }
|
||||
let closing = false
|
||||
|
||||
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
|
||||
const url = new URL(sdk.url + `/pty/${id}/connect`)
|
||||
url.searchParams.set("directory", sdk.directory)
|
||||
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
|
||||
url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0))
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
url.username = server.current?.http.username ?? ""
|
||||
url.password = server.current?.http.password ?? ""
|
||||
@@ -543,7 +543,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
|
||||
|
||||
const finalize = () => {
|
||||
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
|
||||
persistTerminal({ term, addon: serializeAddon, cursor, id, onCleanup: props.onCleanup })
|
||||
cleanup()
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,7 @@ export function Titlebar() {
|
||||
<header
|
||||
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
||||
style={{ "min-height": minHeight() }}
|
||||
data-tauri-drag-region
|
||||
onMouseDown={drag}
|
||||
onDblClick={maximize}
|
||||
>
|
||||
@@ -265,6 +266,9 @@ export function Titlebar() {
|
||||
</div>
|
||||
</div>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
|
||||
BETA
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||
@@ -276,6 +280,7 @@ export function Titlebar() {
|
||||
"flex items-center min-w-0 justify-end": true,
|
||||
"pr-2": !windows(),
|
||||
}}
|
||||
data-tauri-drag-region
|
||||
onMouseDown={drag}
|
||||
>
|
||||
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { isEditableTarget } from "@/utils/dom"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
@@ -177,14 +178,6 @@ export function formatKeybind(config: string): string {
|
||||
return IS_MAC ? parts.join("") : parts.join("+")
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (target.isContentEditable) return true
|
||||
if (target.closest("[contenteditable='true']")) return true
|
||||
if (target.closest("input, textarea, select")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
|
||||
name: "Command",
|
||||
init: () => {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import {
|
||||
createContext,
|
||||
createEffect,
|
||||
getOwner,
|
||||
Match,
|
||||
onCleanup,
|
||||
@@ -35,7 +34,6 @@ import { trimSessions } from "./global-sync/session-trim"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||
import { sanitizeProject } from "./global-sync/utils"
|
||||
import { usePlatform } from "./platform"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
|
||||
type GlobalStore = {
|
||||
@@ -54,7 +52,6 @@ type GlobalStore = {
|
||||
|
||||
function createGlobalSync() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const owner = getOwner()
|
||||
if (!owner) throw new Error("GlobalSync must be created within owner")
|
||||
@@ -64,7 +61,7 @@ function createGlobalSync() {
|
||||
const sessionLoads = new Map<string, Promise<void>>()
|
||||
const sessionMeta = new Map<string, { limit: number }>()
|
||||
|
||||
const [projectCache, setProjectCache, , projectCacheReady] = persisted(
|
||||
const [projectCache, setProjectCache, projectInit] = persisted(
|
||||
Persist.global("globalSync.project", ["globalSync.project.v1"]),
|
||||
createStore({ value: [] as Project[] }),
|
||||
)
|
||||
@@ -80,6 +77,57 @@ function createGlobalSync() {
|
||||
reload: undefined,
|
||||
})
|
||||
|
||||
let active = true
|
||||
let projectWritten = false
|
||||
|
||||
onCleanup(() => {
|
||||
active = false
|
||||
})
|
||||
|
||||
const cacheProjects = () => {
|
||||
setProjectCache(
|
||||
"value",
|
||||
untrack(() => globalStore.project.map(sanitizeProject)),
|
||||
)
|
||||
}
|
||||
|
||||
const setProjects = (next: Project[] | ((draft: Project[]) => void)) => {
|
||||
projectWritten = true
|
||||
if (typeof next === "function") {
|
||||
setGlobalStore("project", produce(next))
|
||||
cacheProjects()
|
||||
return
|
||||
}
|
||||
setGlobalStore("project", next)
|
||||
cacheProjects()
|
||||
}
|
||||
|
||||
const setBootStore = ((...input: unknown[]) => {
|
||||
if (input[0] === "project" && Array.isArray(input[1])) {
|
||||
setProjects(input[1] as Project[])
|
||||
return input[1]
|
||||
}
|
||||
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||
}) as typeof setGlobalStore
|
||||
|
||||
const set = ((...input: unknown[]) => {
|
||||
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
|
||||
setProjects(input[1] as Project[] | ((draft: Project[]) => void))
|
||||
return input[1]
|
||||
}
|
||||
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||
}) as typeof setGlobalStore
|
||||
|
||||
if (projectInit instanceof Promise) {
|
||||
void projectInit.then(() => {
|
||||
if (!active) return
|
||||
if (projectWritten) return
|
||||
const cached = projectCache.value
|
||||
if (cached.length === 0) return
|
||||
setGlobalStore("project", cached)
|
||||
})
|
||||
}
|
||||
|
||||
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
|
||||
if (!sessionID) return
|
||||
if (!todos) {
|
||||
@@ -127,30 +175,6 @@ function createGlobalSync() {
|
||||
return sdk
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!projectCacheReady()) return
|
||||
if (globalStore.project.length !== 0) return
|
||||
const cached = projectCache.value
|
||||
if (cached.length === 0) return
|
||||
setGlobalStore("project", cached)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!projectCacheReady()) return
|
||||
const projects = globalStore.project
|
||||
if (projects.length === 0) {
|
||||
const cachedLength = untrack(() => projectCache.value.length)
|
||||
if (cachedLength !== 0) return
|
||||
}
|
||||
setProjectCache("value", projects.map(sanitizeProject))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (globalStore.reload !== "complete") return
|
||||
setGlobalStore("reload", undefined)
|
||||
queue.refresh()
|
||||
})
|
||||
|
||||
async function loadSessions(directory: string) {
|
||||
const pending = sessionLoads.get(directory)
|
||||
if (pending) return pending
|
||||
@@ -259,13 +283,7 @@ function createGlobalSync() {
|
||||
event,
|
||||
project: globalStore.project,
|
||||
refresh: queue.refresh,
|
||||
setGlobalProject(next) {
|
||||
if (typeof next === "function") {
|
||||
setGlobalStore("project", produce(next))
|
||||
return
|
||||
}
|
||||
setGlobalStore("project", next)
|
||||
},
|
||||
setGlobalProject: setProjects,
|
||||
})
|
||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||
for (const directory of Object.keys(children.children)) {
|
||||
@@ -316,7 +334,7 @@ function createGlobalSync() {
|
||||
unknownError: language.t("error.chain.unknown"),
|
||||
invalidConfigurationError: language.t("error.server.invalidConfiguration"),
|
||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||
setGlobalStore,
|
||||
setGlobalStore: setBootStore,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -340,7 +358,8 @@ function createGlobalSync() {
|
||||
.update({ config })
|
||||
.then(bootstrap)
|
||||
.then(() => {
|
||||
setGlobalStore("reload", "complete")
|
||||
setGlobalStore("reload", undefined)
|
||||
queue.refresh()
|
||||
})
|
||||
.catch((error) => {
|
||||
setGlobalStore("reload", undefined)
|
||||
@@ -350,7 +369,7 @@ function createGlobalSync() {
|
||||
|
||||
return {
|
||||
data: globalStore,
|
||||
set: setGlobalStore,
|
||||
set,
|
||||
get ready() {
|
||||
return globalStore.ready
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
|
||||
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -131,8 +131,7 @@ export function createChildStoreManager(input: {
|
||||
)
|
||||
if (!vcs) throw new Error("Failed to create persisted cache")
|
||||
const vcsStore = vcs[0]
|
||||
const vcsReady = vcs[3]
|
||||
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
|
||||
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
|
||||
|
||||
const meta = runWithOwner(input.owner, () =>
|
||||
persisted(
|
||||
@@ -154,10 +153,12 @@ export function createChildStoreManager(input: {
|
||||
|
||||
const init = () =>
|
||||
createRoot((dispose) => {
|
||||
const initialMeta = meta[0].value
|
||||
const initialIcon = icon[0].value
|
||||
const child = createStore<State>({
|
||||
project: "",
|
||||
projectMeta: meta[0].value,
|
||||
icon: icon[0].value,
|
||||
projectMeta: initialMeta,
|
||||
icon: initialIcon,
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
@@ -181,16 +182,27 @@ export function createChildStoreManager(input: {
|
||||
children[directory] = child
|
||||
disposers.set(directory, dispose)
|
||||
|
||||
createEffect(() => {
|
||||
if (!vcsReady()) return
|
||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||
if (!(init instanceof Promise)) return
|
||||
void init.then(() => {
|
||||
if (children[directory] !== child) return
|
||||
run()
|
||||
})
|
||||
}
|
||||
|
||||
onPersistedInit(vcs[2], () => {
|
||||
const cached = vcsStore.value
|
||||
if (!cached?.branch) return
|
||||
child[1]("vcs", (value) => value ?? cached)
|
||||
})
|
||||
createEffect(() => {
|
||||
|
||||
onPersistedInit(meta[2], () => {
|
||||
if (child[0].projectMeta !== initialMeta) return
|
||||
child[1]("projectMeta", meta[0].value)
|
||||
})
|
||||
createEffect(() => {
|
||||
|
||||
onPersistedInit(icon[2], () => {
|
||||
if (child[0].icon !== initialIcon) return
|
||||
child[1]("icon", icon[0].value)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,8 +7,10 @@ import { useServer } from "./server"
|
||||
import { usePlatform } from "./platform"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { same } from "@/utils/same"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { same } from "@opencode-ai/util/array"
|
||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||
import { createPathHelpers } from "./file/path"
|
||||
|
||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||
const DEFAULT_PANEL_WIDTH = 344
|
||||
@@ -96,6 +98,38 @@ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string):
|
||||
return { all, active: tab }
|
||||
}
|
||||
|
||||
const sessionPath = (key: string) => {
|
||||
const dir = key.split("/")[0]
|
||||
if (!dir) return
|
||||
const root = decode64(dir)
|
||||
if (!root) return
|
||||
return createPathHelpers(() => root)
|
||||
}
|
||||
|
||||
const normalizeSessionTab = (path: ReturnType<typeof createPathHelpers> | undefined, tab: string) => {
|
||||
if (!tab.startsWith("file://")) return tab
|
||||
if (!path) return tab
|
||||
return path.tab(tab)
|
||||
}
|
||||
|
||||
const normalizeSessionTabList = (path: ReturnType<typeof createPathHelpers> | undefined, all: string[]) => {
|
||||
const seen = new Set<string>()
|
||||
return all.flatMap((tab) => {
|
||||
const value = normalizeSessionTab(path, tab)
|
||||
if (seen.has(value)) return []
|
||||
seen.add(value)
|
||||
return [value]
|
||||
})
|
||||
}
|
||||
|
||||
const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
|
||||
const path = sessionPath(key)
|
||||
return {
|
||||
all: normalizeSessionTabList(path, tabs.all),
|
||||
active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
|
||||
}
|
||||
}
|
||||
|
||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||
name: "Layout",
|
||||
init: () => {
|
||||
@@ -147,12 +181,46 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
}
|
||||
})()
|
||||
|
||||
if (migratedSidebar === sidebar && migratedReview === review && migratedFileTree === fileTree) return value
|
||||
const sessionTabs = value.sessionTabs
|
||||
const migratedSessionTabs = (() => {
|
||||
if (!isRecord(sessionTabs)) return sessionTabs
|
||||
|
||||
let changed = false
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(sessionTabs).map(([key, tabs]) => {
|
||||
if (!isRecord(tabs) || !Array.isArray(tabs.all)) return [key, tabs]
|
||||
|
||||
const current = {
|
||||
all: tabs.all.filter((tab): tab is string => typeof tab === "string"),
|
||||
active: typeof tabs.active === "string" ? tabs.active : undefined,
|
||||
}
|
||||
const normalized = normalizeStoredSessionTabs(key, current)
|
||||
if (current.all.length !== tabs.all.length) changed = true
|
||||
if (!same(current.all, normalized.all) || current.active !== normalized.active) changed = true
|
||||
if (tabs.active !== undefined && typeof tabs.active !== "string") changed = true
|
||||
return [key, normalized]
|
||||
}),
|
||||
)
|
||||
|
||||
if (!changed) return sessionTabs
|
||||
return next
|
||||
})()
|
||||
|
||||
if (
|
||||
migratedSidebar === sidebar &&
|
||||
migratedReview === review &&
|
||||
migratedFileTree === fileTree &&
|
||||
migratedSessionTabs === sessionTabs
|
||||
) {
|
||||
return value
|
||||
}
|
||||
|
||||
return {
|
||||
...value,
|
||||
sidebar: migratedSidebar,
|
||||
review: migratedReview,
|
||||
fileTree: migratedFileTree,
|
||||
sessionTabs: migratedSessionTabs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,22 +813,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
tabs(sessionKey: string | Accessor<string>) {
|
||||
const key = createSessionKeyReader(sessionKey, ensureKey)
|
||||
const path = createMemo(() => sessionPath(key()))
|
||||
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
|
||||
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
|
||||
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
|
||||
return {
|
||||
tabs,
|
||||
active: createMemo(() => tabs().active),
|
||||
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
|
||||
setActive(tab: string | undefined) {
|
||||
const session = key()
|
||||
const next = tab ? normalize(tab) : tab
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: [], active: tab })
|
||||
setStore("sessionTabs", session, { all: [], active: next })
|
||||
} else {
|
||||
setStore("sessionTabs", session, "active", tab)
|
||||
setStore("sessionTabs", session, "active", next)
|
||||
}
|
||||
},
|
||||
setAll(all: string[]) {
|
||||
const session = key()
|
||||
const next = all.filter((tab) => tab !== "review")
|
||||
const next = normalizeAll(all).filter((tab) => tab !== "review")
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: next, active: undefined })
|
||||
} else {
|
||||
@@ -769,7 +841,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
async open(tab: string) {
|
||||
const session = key()
|
||||
const next = nextSessionTabsForOpen(store.sessionTabs[session], tab)
|
||||
const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
|
||||
setStore("sessionTabs", session, next)
|
||||
},
|
||||
close(tab: string) {
|
||||
|
||||
@@ -31,13 +31,13 @@ describe("autoRespondsPermission", () => {
|
||||
expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true)
|
||||
})
|
||||
|
||||
test("defaults to auto-accept when no lineage override exists", () => {
|
||||
test("defaults to requiring approval when no lineage override exists", () => {
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
|
||||
const autoAccept = {
|
||||
other: true,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(true)
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(false)
|
||||
})
|
||||
|
||||
test("inherits a parent session's false override", () => {
|
||||
|
||||
@@ -37,5 +37,5 @@ export function autoRespondsPermission(
|
||||
const value = sessionLineage(session, permission.sessionID)
|
||||
.map((id) => accepted(autoAccept, id, directory))
|
||||
.find((item): item is boolean => item !== undefined)
|
||||
return value ?? true
|
||||
return value ?? false
|
||||
}
|
||||
|
||||
@@ -43,12 +43,11 @@ type OptimisticRemoveInput = {
|
||||
|
||||
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
|
||||
const messages = draft.message[input.sessionID]
|
||||
if (!messages) {
|
||||
draft.message[input.sessionID] = [input.message]
|
||||
}
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
||||
messages.splice(result.index, 0, input.message)
|
||||
} else {
|
||||
draft.message[input.sessionID] = [input.message]
|
||||
}
|
||||
draft.part[input.message.id] = sortParts(input.parts)
|
||||
}
|
||||
@@ -105,7 +104,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
return globalSync.child(directory)
|
||||
}
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const messagePageSize = 400
|
||||
const messagePageSize = 200
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
@@ -122,20 +121,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
return undefined
|
||||
}
|
||||
|
||||
const limitFor = (count: number) => {
|
||||
if (count <= messagePageSize) return messagePageSize
|
||||
return Math.ceil(count / messagePageSize) * messagePageSize
|
||||
}
|
||||
|
||||
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
|
||||
const messages = await retry(() =>
|
||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
|
||||
)
|
||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||
const session = items
|
||||
.map((x) => x.info)
|
||||
.filter((m) => !!m?.id)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
|
||||
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
||||
return {
|
||||
session,
|
||||
@@ -159,8 +150,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
.then((next) => {
|
||||
batch(() => {
|
||||
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
||||
for (const message of next.part) {
|
||||
input.setStore("part", message.id, reconcile(message.part, { key: "id" }))
|
||||
for (const p of next.part) {
|
||||
input.setStore("part", p.id, p.part)
|
||||
}
|
||||
setMeta("limit", key, input.limit)
|
||||
setMeta("complete", key, next.complete)
|
||||
@@ -229,17 +220,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const hasSession = (() => {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
return match.found
|
||||
})()
|
||||
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||
|
||||
const hasMessages = store.message[sessionID] !== undefined
|
||||
const hydrated = meta.limit[key] !== undefined
|
||||
if (hasSession && hasMessages && hydrated) return
|
||||
|
||||
const count = store.message[sessionID]?.length ?? 0
|
||||
const limit = hydrated ? (meta.limit[key] ?? messagePageSize) : limitFor(count)
|
||||
const limit = meta.limit[key] ?? messagePageSize
|
||||
|
||||
const sessionReq = hasSession
|
||||
? Promise.resolve()
|
||||
@@ -259,16 +242,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
)
|
||||
})
|
||||
|
||||
const messagesReq =
|
||||
hasMessages && hydrated
|
||||
? Promise.resolve()
|
||||
: loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit,
|
||||
})
|
||||
const messagesReq = loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit,
|
||||
})
|
||||
|
||||
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
|
||||
},
|
||||
@@ -290,14 +270,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
const existing = store.todo[sessionID]
|
||||
const cached = globalSync.data.session_todo[sessionID]
|
||||
if (existing !== undefined) {
|
||||
if (globalSync.data.session_todo[sessionID] === undefined) {
|
||||
if (cached === undefined) {
|
||||
globalSync.todo.set(sessionID, existing)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const cached = globalSync.data.session_todo[sessionID]
|
||||
if (cached !== undefined) {
|
||||
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
|
||||
}
|
||||
@@ -324,11 +304,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const key = keyFor(sdk.directory, sessionID)
|
||||
return meta.loading[key] ?? false
|
||||
},
|
||||
async loadMore(sessionID: string, count = messagePageSize) {
|
||||
async loadMore(sessionID: string, count?: number) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const step = count ?? messagePageSize
|
||||
if (meta.loading[key]) return
|
||||
if (meta.complete[key]) return
|
||||
|
||||
@@ -338,7 +319,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit: currentLimit + count,
|
||||
limit: currentLimit + step,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
@@ -506,6 +506,10 @@ export const dict = {
|
||||
"common.close": "إغلاق",
|
||||
"common.edit": "تحرير",
|
||||
"common.loadMore": "تحميل المزيد",
|
||||
"common.changelog": "التغييرات",
|
||||
"common.noReleasesFound": "لم يتم العثور على إصدارات",
|
||||
"changelog.tag.latest": "الأحدث",
|
||||
|
||||
"common.key.esc": "ESC",
|
||||
"sidebar.menu.toggle": "تبديل القائمة",
|
||||
"sidebar.nav.projectsAndSessions": "المشاريع والجلسات",
|
||||
@@ -734,6 +738,11 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "ستتم أرشفة جلسة واحدة.",
|
||||
"workspace.reset.archived.many": "ستتم أرشفة {{count}} جلسات.",
|
||||
"workspace.reset.note": "سيؤدي هذا إلى إعادة تعيين مساحة العمل لتتطابق مع الفرع الافتراضي.",
|
||||
"settings.archive.title": "الجلسات المؤرشفة",
|
||||
"settings.archive.description": "استعادة الجلسات المؤرشفة لجعلها مرئية في الشريط الجانبي.",
|
||||
"settings.archive.none": "لا توجد جلسات مؤرشفة.",
|
||||
"settings.archive.scope.all": "جميع المشاريع",
|
||||
"settings.archive.scope.current": "المشروع الحالي",
|
||||
"common.open": "فتح",
|
||||
"dialog.releaseNotes.action.getStarted": "البدء",
|
||||
"dialog.releaseNotes.action.next": "التالي",
|
||||
@@ -748,4 +757,4 @@ export const dict = {
|
||||
"common.time.daysAgo.short": "قبل {{count}} ي",
|
||||
"settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك",
|
||||
"settings.providers.custom.description": "أضف مزود متوافق مع OpenAI بواسطة عنوان URL الأساسي.",
|
||||
}
|
||||
}
|
||||
@@ -512,6 +512,9 @@ export const dict = {
|
||||
"common.close": "Fechar",
|
||||
"common.edit": "Editar",
|
||||
"common.loadMore": "Carregar mais",
|
||||
"common.changelog": "Novidades",
|
||||
"common.noReleasesFound": "Nenhuma release encontrada",
|
||||
"changelog.tag.latest": "Mais recente",
|
||||
"common.key.esc": "ESC",
|
||||
"sidebar.menu.toggle": "Alternar menu",
|
||||
"sidebar.nav.projectsAndSessions": "Projetos e sessões",
|
||||
@@ -742,6 +745,11 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 sessão será arquivada.",
|
||||
"workspace.reset.archived.many": "{{count}} sessões serão arquivadas.",
|
||||
"workspace.reset.note": "Isso redefinirá o espaço de trabalho para corresponder ao branch padrão.",
|
||||
"settings.archive.title": "Sessões arquivadas",
|
||||
"settings.archive.description": "Restaure sessões arquivadas para torná-las visíveis na barra lateral.",
|
||||
"settings.archive.none": "Nenhuma sessão arquivada.",
|
||||
"settings.archive.scope.all": "Todos os projetos",
|
||||
"settings.archive.scope.current": "Projeto atual",
|
||||
"common.open": "Abrir",
|
||||
"dialog.releaseNotes.action.getStarted": "Começar",
|
||||
"dialog.releaseNotes.action.next": "Próximo",
|
||||
|
||||
@@ -572,6 +572,9 @@ export const dict = {
|
||||
"common.close": "Zatvori",
|
||||
"common.edit": "Uredi",
|
||||
"common.loadMore": "Učitaj još",
|
||||
"common.changelog": "Novosti",
|
||||
"common.noReleasesFound": "Nema pronađenih verzija",
|
||||
"changelog.tag.latest": "Najnovije",
|
||||
"common.key.esc": "ESC",
|
||||
|
||||
"sidebar.menu.toggle": "Prikaži/sakrij meni",
|
||||
@@ -819,6 +822,11 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 sesija će biti arhivirana.",
|
||||
"workspace.reset.archived.many": "Biće arhivirano {{count}} sesija.",
|
||||
"workspace.reset.note": "Ovo će resetovati radni prostor da odgovara podrazumijevanoj grani.",
|
||||
"settings.archive.title": "Arhivirane sesije",
|
||||
"settings.archive.description": "Vrati arhivirane sesije da bi bile vidljive u bočnoj traci.",
|
||||
"settings.archive.none": "Nema arhiviranih sesija.",
|
||||
"settings.archive.scope.all": "Svi projekti",
|
||||
"settings.archive.scope.current": "Trenutni projekt",
|
||||
"common.open": "Otvori",
|
||||
"dialog.releaseNotes.action.getStarted": "Započni",
|
||||
"dialog.releaseNotes.action.next": "Sljedeće",
|
||||
|
||||
@@ -568,6 +568,9 @@ export const dict = {
|
||||
"common.close": "Luk",
|
||||
"common.edit": "Rediger",
|
||||
"common.loadMore": "Indlæs flere",
|
||||
"common.changelog": "Nyheder",
|
||||
"common.noReleasesFound": "Ingen versioner fundet",
|
||||
"changelog.tag.latest": "Seneste",
|
||||
|
||||
"common.key.esc": "ESC",
|
||||
"sidebar.menu.toggle": "Skift menu",
|
||||
@@ -813,6 +816,11 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 session vil blive arkiveret.",
|
||||
"workspace.reset.archived.many": "{{count}} sessioner vil blive arkiveret.",
|
||||
"workspace.reset.note": "Dette vil nulstille arbejdsområdet til at matche hovedgrenen.",
|
||||
"settings.archive.title": "Arkiverede sessioner",
|
||||
"settings.archive.description": "Gendan arkiverede sessioner for at gøre dem synlige i sidebjælken.",
|
||||
"settings.archive.none": "Ingen arkiverede sessioner.",
|
||||
"settings.archive.scope.all": "Alle projekter",
|
||||
"settings.archive.scope.current": "Nuværende projekt",
|
||||
"common.open": "Åbn",
|
||||
"dialog.releaseNotes.action.getStarted": "Kom i gang",
|
||||
"dialog.releaseNotes.action.next": "Næste",
|
||||
|
||||
@@ -520,6 +520,10 @@ export const dict = {
|
||||
"common.close": "Schließen",
|
||||
"common.edit": "Bearbeiten",
|
||||
"common.loadMore": "Mehr laden",
|
||||
"common.changelog": "Neuerungen",
|
||||
"common.noReleasesFound": "Keine Versionen gefunden",
|
||||
"changelog.tag.latest": "Neueste",
|
||||
|
||||
"common.key.esc": "ESC",
|
||||
"sidebar.menu.toggle": "Menü umschalten",
|
||||
"sidebar.nav.projectsAndSessions": "Projekte und Sitzungen",
|
||||
@@ -751,6 +755,12 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 Sitzung wird archiviert.",
|
||||
"workspace.reset.archived.many": "{{count}} Sitzungen werden archiviert.",
|
||||
"workspace.reset.note": "Dadurch wird der Arbeitsbereich auf den Standard-Branch zurückgesetzt.",
|
||||
|
||||
"settings.archive.title": "Archivierte Sitzungen",
|
||||
"settings.archive.description": "Archivierte Sitzungen wiederherstellen, um sie in der Seitenleiste anzuzeigen.",
|
||||
"settings.archive.none": "Keine archivierten Sitzungen.",
|
||||
"settings.archive.scope.all": "Alle Projekte",
|
||||
"settings.archive.scope.current": "Aktuelles Projekt",
|
||||
"common.open": "Öffnen",
|
||||
"dialog.releaseNotes.action.getStarted": "Loslegen",
|
||||
"dialog.releaseNotes.action.next": "Weiter",
|
||||
|
||||
@@ -585,16 +585,19 @@ export const dict = {
|
||||
"common.rename": "Rename",
|
||||
"common.reset": "Reset",
|
||||
"common.archive": "Archive",
|
||||
"common.unarchive": "Unarchive",
|
||||
"common.delete": "Delete",
|
||||
"common.close": "Close",
|
||||
"common.edit": "Edit",
|
||||
"common.loadMore": "Load more",
|
||||
"common.key.esc": "ESC",
|
||||
|
||||
"common.changelog": "Changelog",
|
||||
"common.noReleasesFound": "No releases found",
|
||||
"common.time.justNow": "Just now",
|
||||
"common.time.minutesAgo.short": "{{count}}m ago",
|
||||
"common.time.hoursAgo.short": "{{count}}h ago",
|
||||
"common.time.daysAgo.short": "{{count}}d ago",
|
||||
"changelog.tag.latest": "Latest",
|
||||
"common.key.esc": "ESC",
|
||||
|
||||
"sidebar.menu.toggle": "Toggle menu",
|
||||
"sidebar.nav.projectsAndSessions": "Projects and sessions",
|
||||
@@ -613,6 +616,7 @@ export const dict = {
|
||||
|
||||
"settings.section.desktop": "Desktop",
|
||||
"settings.section.server": "Server",
|
||||
"settings.section.data": "Data",
|
||||
"settings.tab.general": "General",
|
||||
"settings.tab.shortcuts": "Shortcuts",
|
||||
"settings.desktop.section.wsl": "WSL",
|
||||
@@ -844,4 +848,10 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 session will be archived.",
|
||||
"workspace.reset.archived.many": "{{count}} sessions will be archived.",
|
||||
"workspace.reset.note": "This will reset the workspace to match the default branch.",
|
||||
|
||||
"settings.archive.title": "Archived Sessions",
|
||||
"settings.archive.description": "Restore archived sessions to make them visible in the sidebar.",
|
||||
"settings.archive.none": "No archived sessions.",
|
||||
"settings.archive.scope.all": "All projects",
|
||||
"settings.archive.scope.current": "Current project",
|
||||
}
|
||||
|
||||
@@ -575,6 +575,10 @@ export const dict = {
|
||||
"common.close": "Cerrar",
|
||||
"common.edit": "Editar",
|
||||
"common.loadMore": "Cargar más",
|
||||
"common.changelog": "Novedades",
|
||||
"common.noReleasesFound": "No se encontraron versiones",
|
||||
"changelog.tag.latest": "Último",
|
||||
|
||||
"common.key.esc": "ESC",
|
||||
|
||||
"sidebar.menu.toggle": "Alternar menú",
|
||||
@@ -825,6 +829,12 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 sesión será archivada.",
|
||||
"workspace.reset.archived.many": "{{count}} sesiones serán archivadas.",
|
||||
"workspace.reset.note": "Esto restablecerá el espacio de trabajo para coincidir con la rama predeterminada.",
|
||||
|
||||
"settings.archive.title": "Sesiones archivadas",
|
||||
"settings.archive.description": "Restaura las sesiones archivadas para hacerlas visibles en la barra lateral.",
|
||||
"settings.archive.none": "No hay sesiones archivadas.",
|
||||
"settings.archive.scope.all": "Todos los proyectos",
|
||||
"settings.archive.scope.current": "Proyecto actual",
|
||||
"common.open": "Abrir",
|
||||
"dialog.releaseNotes.action.getStarted": "Comenzar",
|
||||
"dialog.releaseNotes.action.next": "Siguiente",
|
||||
|
||||
@@ -516,6 +516,10 @@ export const dict = {
|
||||
"common.close": "Fermer",
|
||||
"common.edit": "Modifier",
|
||||
"common.loadMore": "Charger plus",
|
||||
"common.changelog": "Nouveautés",
|
||||
"common.noReleasesFound": "Aucune version trouvée",
|
||||
"changelog.tag.latest": "Dernier",
|
||||
|
||||
"common.key.esc": "ESC",
|
||||
"sidebar.menu.toggle": "Basculer le menu",
|
||||
"sidebar.nav.projectsAndSessions": "Projets et sessions",
|
||||
@@ -749,6 +753,11 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 session sera archivée.",
|
||||
"workspace.reset.archived.many": "{{count}} sessions seront archivées.",
|
||||
"workspace.reset.note": "Cela réinitialisera l'espace de travail pour correspondre à la branche par défaut.",
|
||||
"settings.archive.title": "Sessions archivées",
|
||||
"settings.archive.description": "Restaurez les sessions archivées pour les rendre visibles dans la barre latérale.",
|
||||
"settings.archive.none": "Aucune session archivée.",
|
||||
"settings.archive.scope.all": "Tous les Projets",
|
||||
"settings.archive.scope.current": "Projet actuel",
|
||||
"common.open": "Ouvrir",
|
||||
"dialog.releaseNotes.action.getStarted": "Commencer",
|
||||
"dialog.releaseNotes.action.next": "Suivant",
|
||||
|
||||
@@ -510,6 +510,10 @@ export const dict = {
|
||||
"common.close": "閉じる",
|
||||
"common.edit": "編集",
|
||||
"common.loadMore": "さらに読み込む",
|
||||
"common.changelog": "更新履歴",
|
||||
"common.noReleasesFound": "バージョンが見つかりません",
|
||||
"changelog.tag.latest": "最新",
|
||||
|
||||
"common.key.esc": "ESC",
|
||||
"sidebar.menu.toggle": "メニューを切り替え",
|
||||
"sidebar.nav.projectsAndSessions": "プロジェクトとセッション",
|
||||
@@ -738,6 +742,12 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1つのセッションがアーカイブされます。",
|
||||
"workspace.reset.archived.many": "{{count}}個のセッションがアーカイブされます。",
|
||||
"workspace.reset.note": "これにより、ワークスペースはデフォルトブランチと一致するようにリセットされます。",
|
||||
|
||||
"settings.archive.title": "アーカイブされたセッション",
|
||||
"settings.archive.description": "アーカイブされたセッションを復元してサイドバーに表示します。",
|
||||
"settings.archive.none": "アーカイブされたセッションはありません。",
|
||||
"settings.archive.scope.all": "すべてのプロジェクト",
|
||||
"settings.archive.scope.current": "現在のプロジェクト",
|
||||
"common.open": "開く",
|
||||
"dialog.releaseNotes.action.getStarted": "始める",
|
||||
"dialog.releaseNotes.action.next": "次へ",
|
||||
|
||||
@@ -511,6 +511,10 @@ export const dict = {
|
||||
"common.close": "닫기",
|
||||
"common.edit": "편집",
|
||||
"common.loadMore": "더 불러오기",
|
||||
"common.changelog": "새로운 기능",
|
||||
"common.noReleasesFound": "버전을 찾을 수 없음",
|
||||
"changelog.tag.latest": "최신",
|
||||
|
||||
"common.key.esc": "ESC",
|
||||
"sidebar.menu.toggle": "메뉴 토글",
|
||||
"sidebar.nav.projectsAndSessions": "프로젝트 및 세션",
|
||||
@@ -738,6 +742,12 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1개의 세션이 보관됩니다.",
|
||||
"workspace.reset.archived.many": "{{count}}개의 세션이 보관됩니다.",
|
||||
"workspace.reset.note": "이 작업은 작업 공간을 기본 브랜치와 일치하도록 재설정합니다.",
|
||||
|
||||
"settings.archive.title": "보관된 세션",
|
||||
"settings.archive.description": "보관된 세션을 복원하여 사이드바에 표시합니다.",
|
||||
"settings.archive.none": "보관된 세션이 없습니다.",
|
||||
"settings.archive.scope.all": "모든 프로젝트",
|
||||
"settings.archive.scope.current": "현재 프로젝트",
|
||||
"common.open": "열기",
|
||||
"dialog.releaseNotes.action.getStarted": "시작하기",
|
||||
"dialog.releaseNotes.action.next": "다음",
|
||||
|
||||
@@ -575,6 +575,9 @@ export const dict = {
|
||||
"common.close": "Lukk",
|
||||
"common.edit": "Rediger",
|
||||
"common.loadMore": "Last flere",
|
||||
"common.changelog": "Nyheter",
|
||||
"common.noReleasesFound": "Ingen versjoner funnet",
|
||||
"changelog.tag.latest": "Siste",
|
||||
"common.key.esc": "ESC",
|
||||
|
||||
"sidebar.menu.toggle": "Veksle meny",
|
||||
@@ -821,6 +824,12 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 sesjon vil bli arkivert.",
|
||||
"workspace.reset.archived.many": "{{count}} sesjoner vil bli arkivert.",
|
||||
"workspace.reset.note": "Dette vil tilbakestille arbeidsområdet til å samsvare med standardgrenen.",
|
||||
|
||||
"settings.archive.title": "Arkiverte økter",
|
||||
"settings.archive.description": "Gjenopprett arkiverte økter for å gjøre dem synlige i sidefeltet.",
|
||||
"settings.archive.none": "Ingen arkiverte økter.",
|
||||
"settings.archive.scope.all": "Alle prosjekter",
|
||||
"settings.archive.scope.current": "Nåværende prosjekt",
|
||||
"common.open": "Åpne",
|
||||
"dialog.releaseNotes.action.getStarted": "Kom i gang",
|
||||
"dialog.releaseNotes.action.next": "Neste",
|
||||
|
||||
@@ -511,6 +511,9 @@ export const dict = {
|
||||
"common.close": "Zamknij",
|
||||
"common.edit": "Edytuj",
|
||||
"common.loadMore": "Załaduj więcej",
|
||||
"common.changelog": "Nowości",
|
||||
"common.noReleasesFound": "Nie znaleziono wersji",
|
||||
"changelog.tag.latest": "Najnowszy",
|
||||
"common.key.esc": "ESC",
|
||||
"sidebar.menu.toggle": "Przełącz menu",
|
||||
"sidebar.nav.projectsAndSessions": "Projekty i sesje",
|
||||
@@ -740,6 +743,11 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 sesja zostanie zarchiwizowana.",
|
||||
"workspace.reset.archived.many": "{{count}} sesji zostanie zarchiwizowanych.",
|
||||
"workspace.reset.note": "To zresetuje przestrzeń roboczą, aby odpowiadała domyślnej gałęzi.",
|
||||
"settings.archive.title": "Zarchiwizowane sesje",
|
||||
"settings.archive.description": "Przywróć zarchiwizowane sesje, aby były widoczne na pasku bocznym.",
|
||||
"settings.archive.none": "Brak zarchiwizowanych sesji.",
|
||||
"settings.archive.scope.all": "Wszystkie projekty",
|
||||
"settings.archive.scope.current": "Bieżący projekt",
|
||||
"common.open": "Otwórz",
|
||||
"dialog.releaseNotes.action.getStarted": "Rozpocznij",
|
||||
"dialog.releaseNotes.action.next": "Dalej",
|
||||
|
||||
@@ -573,6 +573,9 @@ export const dict = {
|
||||
"common.close": "Закрыть",
|
||||
"common.edit": "Редактировать",
|
||||
"common.loadMore": "Загрузить ещё",
|
||||
"common.changelog": "Что нового",
|
||||
"common.noReleasesFound": "Версии не найдены",
|
||||
"changelog.tag.latest": "Последний",
|
||||
"common.key.esc": "ESC",
|
||||
|
||||
"sidebar.menu.toggle": "Переключить меню",
|
||||
@@ -821,6 +824,11 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 сессия будет архивирована.",
|
||||
"workspace.reset.archived.many": "{{count}} сессий будет архивировано.",
|
||||
"workspace.reset.note": "Рабочее пространство будет сброшено в соответствие с веткой по умолчанию.",
|
||||
"settings.archive.title": "Архивированные сессии",
|
||||
"settings.archive.description": "Восстановите архивированные сессии, чтобы они отображались на боковой панели.",
|
||||
"settings.archive.none": "Нет архивированных сессий.",
|
||||
"settings.archive.scope.all": "Все проекты",
|
||||
"settings.archive.scope.current": "Текущий проект",
|
||||
"common.open": "Открыть",
|
||||
"dialog.releaseNotes.action.getStarted": "Начать",
|
||||
"dialog.releaseNotes.action.next": "Далее",
|
||||
|
||||
@@ -567,6 +567,9 @@ export const dict = {
|
||||
"common.close": "ปิด",
|
||||
"common.edit": "แก้ไข",
|
||||
"common.loadMore": "โหลดเพิ่มเติม",
|
||||
"common.changelog": "อัปเดต",
|
||||
"common.noReleasesFound": "ไม่พบเวอร์ชัน",
|
||||
"changelog.tag.latest": "ล่าสุด",
|
||||
"common.key.esc": "ESC",
|
||||
|
||||
"sidebar.menu.toggle": "สลับเมนู",
|
||||
@@ -811,6 +814,12 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 เซสชันจะถูกจัดเก็บ",
|
||||
"workspace.reset.archived.many": "{{count}} เซสชันจะถูกจัดเก็บ",
|
||||
"workspace.reset.note": "สิ่งนี้จะรีเซ็ตพื้นที่ทำงานให้ตรงกับสาขาเริ่มต้น",
|
||||
|
||||
"settings.archive.title": "เซสชันที่จัดเก็บ",
|
||||
"settings.archive.description": "กู้คืนเซสชันที่จัดเก็บเพื่อให้แสดงในแถบด้านข้าง",
|
||||
"settings.archive.none": "ไม่มีเซสชันที่จัดเก็บ",
|
||||
"settings.archive.scope.all": "โปรเจกต์ทั้งหมด",
|
||||
"settings.archive.scope.current": "โปรเจกต์ปัจจุบัน",
|
||||
"common.open": "เปิด",
|
||||
"dialog.releaseNotes.action.getStarted": "เริ่มต้น",
|
||||
"dialog.releaseNotes.action.next": "ถัดไป",
|
||||
|
||||
@@ -566,6 +566,10 @@ export const dict = {
|
||||
"common.close": "关闭",
|
||||
"common.edit": "编辑",
|
||||
"common.loadMore": "加载更多",
|
||||
"common.changelog": "更新日志",
|
||||
"common.noReleasesFound": "未找到版本",
|
||||
"changelog.tag.latest": "最新",
|
||||
|
||||
"common.key.esc": "ESC",
|
||||
|
||||
"sidebar.menu.toggle": "切换菜单",
|
||||
@@ -809,6 +813,12 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "将归档 1 个会话。",
|
||||
"workspace.reset.archived.many": "将归档 {{count}} 个会话。",
|
||||
"workspace.reset.note": "这将把工作区重置为与默认分支一致。",
|
||||
|
||||
"settings.archive.title": "归档会话",
|
||||
"settings.archive.description": "恢复归档会话以使其在侧边栏中可见。",
|
||||
"settings.archive.none": "没有归档会话。",
|
||||
"settings.archive.scope.all": "所有项目",
|
||||
"settings.archive.scope.current": "当前项目",
|
||||
"common.open": "打开",
|
||||
"dialog.releaseNotes.action.getStarted": "开始",
|
||||
"dialog.releaseNotes.action.next": "下一步",
|
||||
|
||||
@@ -563,6 +563,9 @@ export const dict = {
|
||||
"common.close": "關閉",
|
||||
"common.edit": "編輯",
|
||||
"common.loadMore": "載入更多",
|
||||
"common.changelog": "更新日誌",
|
||||
"common.noReleasesFound": "未找到版本",
|
||||
"changelog.tag.latest": "最新",
|
||||
|
||||
"common.key.esc": "ESC",
|
||||
"sidebar.menu.toggle": "切換選單",
|
||||
@@ -804,6 +807,12 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "將封存 1 個工作階段。",
|
||||
"workspace.reset.archived.many": "將封存 {{count}} 個工作階段。",
|
||||
"workspace.reset.note": "這將把工作區重設為與預設分支一致。",
|
||||
|
||||
"settings.archive.title": "封存工作階段",
|
||||
"settings.archive.description": "恢復封存的工作階段以使其在側邊欄中可見。",
|
||||
"settings.archive.none": "沒有封存的工作階段。",
|
||||
"settings.archive.scope.all": "所有專案",
|
||||
"settings.archive.scope.current": "目前專案",
|
||||
"common.open": "打開",
|
||||
"dialog.releaseNotes.action.getStarted": "開始",
|
||||
"dialog.releaseNotes.action.next": "下一步",
|
||||
|
||||
@@ -9,11 +9,13 @@ import { DataProvider } from "@opencode-ai/ui/context"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
|
||||
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const sync = useSync()
|
||||
const platform = usePlatform()
|
||||
|
||||
return (
|
||||
<DataProvider
|
||||
@@ -21,6 +23,34 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
directory={props.directory}
|
||||
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
|
||||
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
|
||||
onOpenFilePath={async (input) => {
|
||||
const file = input.path.replace(/^[\\/]+/, "")
|
||||
const separator = props.directory.includes("\\") ? "\\" : "/"
|
||||
const path = props.directory.endsWith(separator) ? props.directory + file : props.directory + separator + file
|
||||
|
||||
if (platform.platform === "desktop" && platform.openPath) {
|
||||
await platform.openPath(path).catch((error) => {
|
||||
const description = error instanceof Error ? error.message : String(error)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Open failed",
|
||||
description,
|
||||
})
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("opencode:open-file-path", {
|
||||
detail: input,
|
||||
}),
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("opencode:open-file-path", {
|
||||
detail: input,
|
||||
}),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
|
||||
@@ -42,7 +42,9 @@ import { Binary } from "@opencode-ai/util/binary"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { playSound, soundSrc } from "@/utils/sound"
|
||||
import { createAim } from "@/utils/aim"
|
||||
import { setNavigate } from "@/utils/notification-click"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
@@ -59,14 +61,19 @@ import { useLanguage, type Locale } from "@/context/language"
|
||||
import {
|
||||
childMapByParent,
|
||||
displayName,
|
||||
effectiveWorkspaceOrder,
|
||||
errorMessage,
|
||||
getDraggableId,
|
||||
latestRootSession,
|
||||
sortedRootSessions,
|
||||
syncWorkspaceOrder,
|
||||
workspaceKey,
|
||||
} from "./layout/helpers"
|
||||
import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links"
|
||||
import {
|
||||
collectNewSessionDeepLinks,
|
||||
collectOpenProjectDeepLinks,
|
||||
deepLinkEvent,
|
||||
drainPendingDeepLinks,
|
||||
} from "./layout/deep-links"
|
||||
import { createInlineEditorController } from "./layout/inline-editor"
|
||||
import {
|
||||
LocalWorkspace,
|
||||
@@ -107,6 +114,7 @@ export default function Layout(props: ParentProps) {
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
const navigate = useNavigate()
|
||||
setNavigate(navigate)
|
||||
const providers = useProviders()
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
@@ -481,21 +489,6 @@ export default function Layout(props: ParentProps) {
|
||||
return projects.find((p) => p.worktree === root)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ ready: pageReady(), project: currentProject() }),
|
||||
(value) => {
|
||||
if (!value.ready) return
|
||||
const project = value.project
|
||||
if (!project) return
|
||||
const last = server.projects.last()
|
||||
if (last === project.worktree) return
|
||||
server.projects.touch(project.worktree)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
|
||||
@@ -554,29 +547,17 @@ export default function Layout(props: ParentProps) {
|
||||
return layout.sidebar.workspaces(project.worktree)()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!pageReady()) return
|
||||
if (!layoutReady()) return
|
||||
const visibleSessionDirs = createMemo(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return
|
||||
if (!project) return [] as string[]
|
||||
if (!workspaceSetting()) return [project.worktree]
|
||||
|
||||
const local = project.worktree
|
||||
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
||||
const existing = store.workspaceOrder[project.worktree]
|
||||
const merged = syncWorkspaceOrder(local, dirs, existing)
|
||||
if (!existing) {
|
||||
setStore("workspaceOrder", project.worktree, merged)
|
||||
return
|
||||
}
|
||||
|
||||
if (merged.length !== existing.length) {
|
||||
setStore("workspaceOrder", project.worktree, merged)
|
||||
return
|
||||
}
|
||||
|
||||
if (merged.some((d, i) => d !== existing[i])) {
|
||||
setStore("workspaceOrder", project.worktree, merged)
|
||||
}
|
||||
const activeDir = currentDir()
|
||||
return workspaceIds(project).filter((directory) => {
|
||||
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
||||
const active = directory === activeDir
|
||||
return expanded || active
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -593,25 +574,17 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
const currentSessions = createMemo(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return [] as Session[]
|
||||
const now = Date.now()
|
||||
if (workspaceSetting()) {
|
||||
const dirs = workspaceIds(project)
|
||||
const activeDir = currentDir()
|
||||
const result: Session[] = []
|
||||
for (const dir of dirs) {
|
||||
const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree
|
||||
const active = dir === activeDir
|
||||
if (!expanded && !active) continue
|
||||
const [dirStore] = globalSync.child(dir, { bootstrap: true })
|
||||
const dirSessions = sortedRootSessions(dirStore, now)
|
||||
result.push(...dirSessions)
|
||||
}
|
||||
return result
|
||||
const dirs = visibleSessionDirs()
|
||||
if (dirs.length === 0) return [] as Session[]
|
||||
|
||||
const result: Session[] = []
|
||||
for (const dir of dirs) {
|
||||
const [dirStore] = globalSync.child(dir, { bootstrap: true })
|
||||
const dirSessions = sortedRootSessions(dirStore, now)
|
||||
result.push(...dirSessions)
|
||||
}
|
||||
const [projectStore] = globalSync.child(project.worktree)
|
||||
return sortedRootSessions(projectStore, now)
|
||||
return result
|
||||
})
|
||||
|
||||
type PrefetchQueue = {
|
||||
@@ -826,7 +799,6 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
navigateToSession(session)
|
||||
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
|
||||
}
|
||||
|
||||
function navigateSessionByUnseen(offset: number) {
|
||||
@@ -861,7 +833,6 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
navigateToSession(session)
|
||||
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1094,34 +1065,90 @@ export default function Layout(props: ParentProps) {
|
||||
return meta?.worktree ?? directory
|
||||
}
|
||||
|
||||
function activeProjectRoot(directory: string) {
|
||||
return currentProject()?.worktree ?? projectRoot(directory)
|
||||
}
|
||||
|
||||
function touchProjectRoute() {
|
||||
const root = currentProject()?.worktree
|
||||
if (!root) return
|
||||
if (server.projects.last() !== root) server.projects.touch(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
|
||||
setStore("lastProjectSession", root, { directory, id, at: Date.now() })
|
||||
return root
|
||||
}
|
||||
|
||||
function clearLastProjectSession(root: string) {
|
||||
if (!store.lastProjectSession[root]) return
|
||||
setStore(
|
||||
"lastProjectSession",
|
||||
produce((draft) => {
|
||||
delete draft[root]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function syncSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
|
||||
rememberSessionRoute(directory, id, root)
|
||||
notification.session.markViewed(id)
|
||||
const expanded = untrack(() => store.workspaceExpanded[directory])
|
||||
if (expanded === false) {
|
||||
setStore("workspaceExpanded", directory, true)
|
||||
}
|
||||
requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
|
||||
return root
|
||||
}
|
||||
|
||||
async function navigateToProject(directory: string | undefined) {
|
||||
if (!directory) return
|
||||
const root = projectRoot(directory)
|
||||
server.projects.touch(root)
|
||||
const project = layout.projects.list().find((item) => item.worktree === root)
|
||||
const dirs = Array.from(new Set([root, ...(store.workspaceOrder[root] ?? []), ...(project?.sandboxes ?? [])]))
|
||||
let dirs = project
|
||||
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
|
||||
: [root]
|
||||
const canOpen = (value: string | undefined) => {
|
||||
if (!value) return false
|
||||
return dirs.some((item) => workspaceKey(item) === workspaceKey(value))
|
||||
}
|
||||
const refreshDirs = async (target?: string) => {
|
||||
if (!target || target === root || canOpen(target)) return canOpen(target)
|
||||
const listed = await globalSDK.client.worktree
|
||||
.list({ directory: root })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [] as string[])
|
||||
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
|
||||
return canOpen(target)
|
||||
}
|
||||
const openSession = async (target: { directory: string; id: string }) => {
|
||||
if (!canOpen(target.directory)) return false
|
||||
const resolved = await globalSDK.client.session
|
||||
.get({ sessionID: target.id })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
const next = resolved?.directory ? resolved : target
|
||||
setStore("lastProjectSession", root, { directory: next.directory, id: next.id, at: Date.now() })
|
||||
navigateWithSidebarReset(`/${base64Encode(next.directory)}/session/${next.id}`)
|
||||
if (!resolved?.directory) return false
|
||||
if (!canOpen(resolved.directory)) return false
|
||||
setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() })
|
||||
navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
|
||||
return true
|
||||
}
|
||||
|
||||
const projectSession = store.lastProjectSession[root]
|
||||
if (projectSession?.id) {
|
||||
await openSession(projectSession)
|
||||
return
|
||||
await refreshDirs(projectSession.directory)
|
||||
const opened = await openSession(projectSession)
|
||||
if (opened) return
|
||||
clearLastProjectSession(root)
|
||||
}
|
||||
|
||||
const latest = latestRootSession(
|
||||
dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
|
||||
Date.now(),
|
||||
)
|
||||
if (latest) {
|
||||
await openSession(latest)
|
||||
if (latest && (await openSession(latest))) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1137,8 +1164,7 @@ export default function Layout(props: ParentProps) {
|
||||
),
|
||||
Date.now(),
|
||||
)
|
||||
if (fetched) {
|
||||
await openSession(fetched)
|
||||
if (fetched && (await openSession(fetched))) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1157,9 +1183,20 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const handleDeepLinks = (urls: string[]) => {
|
||||
if (!server.isLocal()) return
|
||||
|
||||
for (const directory of collectOpenProjectDeepLinks(urls)) {
|
||||
openProject(directory)
|
||||
}
|
||||
|
||||
for (const link of collectNewSessionDeepLinks(urls)) {
|
||||
openProject(link.directory, false)
|
||||
const slug = base64Encode(link.directory)
|
||||
if (link.prompt) {
|
||||
setSessionHandoff(slug, { prompt: link.prompt })
|
||||
}
|
||||
const href = link.prompt ? `/${slug}/session?prompt=${encodeURIComponent(link.prompt)}` : `/${slug}/session`
|
||||
navigateWithSidebarReset(href)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -1195,11 +1232,28 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
function closeProject(directory: string) {
|
||||
const index = layout.projects.list().findIndex((x) => x.worktree === directory)
|
||||
const next = layout.projects.list()[index + 1]
|
||||
const list = layout.projects.list()
|
||||
const index = list.findIndex((x) => x.worktree === directory)
|
||||
const active = currentProject()?.worktree === directory
|
||||
if (index === -1) return
|
||||
const next = list[index + 1]
|
||||
|
||||
if (!active) {
|
||||
layout.projects.close(directory)
|
||||
return
|
||||
}
|
||||
|
||||
if (!next) {
|
||||
layout.projects.close(directory)
|
||||
navigate("/")
|
||||
return
|
||||
}
|
||||
|
||||
navigateWithSidebarReset(`/${base64Encode(next.worktree)}/session`)
|
||||
layout.projects.close(directory)
|
||||
if (next) navigateToProject(next.worktree)
|
||||
else navigate("/")
|
||||
queueMicrotask(() => {
|
||||
void navigateToProject(next.worktree)
|
||||
})
|
||||
}
|
||||
|
||||
function toggleProjectWorkspaces(project: LocalProject) {
|
||||
@@ -1240,9 +1294,17 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const deleteWorkspace = async (root: string, directory: string) => {
|
||||
const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
|
||||
if (directory === root) return
|
||||
|
||||
const current = currentDir()
|
||||
const currentKey = workspaceKey(current)
|
||||
const deletedKey = workspaceKey(directory)
|
||||
const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey)
|
||||
if (!leaveDeletedWorkspace && shouldLeave) {
|
||||
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
|
||||
}
|
||||
|
||||
setBusy(directory, true)
|
||||
|
||||
const result = await globalSDK.client.worktree
|
||||
@@ -1260,6 +1322,10 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
if (!result) return
|
||||
|
||||
if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) {
|
||||
clearLastProjectSession(root)
|
||||
}
|
||||
|
||||
globalSync.set(
|
||||
"project",
|
||||
produce((draft) => {
|
||||
@@ -1273,8 +1339,18 @@ export default function Layout(props: ParentProps) {
|
||||
layout.projects.close(directory)
|
||||
layout.projects.open(root)
|
||||
|
||||
if (params.dir && currentDir() === directory) {
|
||||
navigateToProject(root)
|
||||
if (shouldLeave) return
|
||||
|
||||
const nextCurrent = currentDir()
|
||||
const nextKey = workspaceKey(nextCurrent)
|
||||
const project = layout.projects.list().find((item) => item.worktree === root)
|
||||
const dirs = project
|
||||
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
|
||||
: [root]
|
||||
const valid = dirs.some((item) => workspaceKey(item) === nextKey)
|
||||
|
||||
if (params.dir && projectRoot(nextCurrent) === root && !valid) {
|
||||
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1377,8 +1453,12 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
const handleDelete = () => {
|
||||
const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory)
|
||||
if (leaveDeletedWorkspace) {
|
||||
navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
|
||||
}
|
||||
dialog.close()
|
||||
void deleteWorkspace(props.root, props.directory)
|
||||
void deleteWorkspace(props.root, props.directory, leaveDeletedWorkspace)
|
||||
}
|
||||
|
||||
const description = () => {
|
||||
@@ -1486,26 +1566,42 @@ export default function Layout(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const activeRoute = {
|
||||
session: "",
|
||||
sessionProject: "",
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ ready: pageReady(), dir: params.dir, id: params.id }),
|
||||
(value) => {
|
||||
if (!value.ready) return
|
||||
const dir = value.dir
|
||||
const id = value.id
|
||||
if (!dir || !id) return
|
||||
() => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const,
|
||||
([ready, dir, id]) => {
|
||||
if (!ready || !dir) {
|
||||
activeRoute.session = ""
|
||||
activeRoute.sessionProject = ""
|
||||
return
|
||||
}
|
||||
|
||||
const directory = decode64(dir)
|
||||
if (!directory) return
|
||||
const at = Date.now()
|
||||
setStore("lastProjectSession", projectRoot(directory), { directory, id, at })
|
||||
notification.session.markViewed(id)
|
||||
const expanded = untrack(() => store.workspaceExpanded[directory])
|
||||
if (expanded === false) {
|
||||
setStore("workspaceExpanded", directory, true)
|
||||
|
||||
const root = touchProjectRoute() ?? activeProjectRoot(directory)
|
||||
|
||||
if (!id) {
|
||||
activeRoute.session = ""
|
||||
activeRoute.sessionProject = ""
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
|
||||
|
||||
const session = `${dir}/${id}`
|
||||
if (session !== activeRoute.session) {
|
||||
activeRoute.session = session
|
||||
activeRoute.sessionProject = syncSessionRoute(directory, id, root)
|
||||
return
|
||||
}
|
||||
|
||||
if (root === activeRoute.sessionProject) return
|
||||
activeRoute.sessionProject = rememberSessionRoute(directory, id, root)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1516,40 +1612,29 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const loadedSessionDirs = new Set<string>()
|
||||
|
||||
createEffect(() => {
|
||||
const project = currentProject()
|
||||
const workspaces = workspaceSetting()
|
||||
const next = new Set<string>()
|
||||
if (!project) {
|
||||
loadedSessionDirs.clear()
|
||||
return
|
||||
}
|
||||
createEffect(
|
||||
on(
|
||||
visibleSessionDirs,
|
||||
(dirs) => {
|
||||
if (dirs.length === 0) {
|
||||
loadedSessionDirs.clear()
|
||||
return
|
||||
}
|
||||
|
||||
if (workspaces) {
|
||||
const activeDir = currentDir()
|
||||
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
||||
for (const directory of dirs) {
|
||||
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
||||
const active = directory === activeDir
|
||||
if (!expanded && !active) continue
|
||||
next.add(directory)
|
||||
}
|
||||
}
|
||||
const next = new Set(dirs)
|
||||
for (const directory of next) {
|
||||
if (loadedSessionDirs.has(directory)) continue
|
||||
globalSync.project.loadSessions(directory)
|
||||
}
|
||||
|
||||
if (!workspaces) {
|
||||
next.add(project.worktree)
|
||||
}
|
||||
|
||||
for (const directory of next) {
|
||||
if (loadedSessionDirs.has(directory)) continue
|
||||
globalSync.project.loadSessions(directory)
|
||||
}
|
||||
|
||||
loadedSessionDirs.clear()
|
||||
for (const directory of next) {
|
||||
loadedSessionDirs.add(directory)
|
||||
}
|
||||
})
|
||||
loadedSessionDirs.clear()
|
||||
for (const directory of next) {
|
||||
loadedSessionDirs.add(directory)
|
||||
}
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
function handleDragStart(event: unknown) {
|
||||
const id = getDraggableId(event)
|
||||
@@ -1583,14 +1668,11 @@ export default function Layout(props: ParentProps) {
|
||||
const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
|
||||
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
|
||||
|
||||
const existing = store.workspaceOrder[project.worktree]
|
||||
if (!existing) return extra ? [...dirs, extra] : dirs
|
||||
|
||||
const merged = syncWorkspaceOrder(local, dirs, existing)
|
||||
if (pending && extra) return [local, extra, ...merged.filter((directory) => directory !== local)]
|
||||
if (!extra) return merged
|
||||
if (pending) return merged
|
||||
return [...merged, extra]
|
||||
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
|
||||
if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
|
||||
if (!extra) return ordered
|
||||
if (pending) return ordered
|
||||
return [...ordered, extra]
|
||||
}
|
||||
|
||||
const sidebarProject = createMemo(() => {
|
||||
@@ -1623,7 +1705,11 @@ export default function Layout(props: ParentProps) {
|
||||
const [item] = result.splice(fromIndex, 1)
|
||||
if (!item) return
|
||||
result.splice(toIndex, 0, item)
|
||||
setStore("workspaceOrder", project.worktree, result)
|
||||
setStore(
|
||||
"workspaceOrder",
|
||||
project.worktree,
|
||||
result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)),
|
||||
)
|
||||
}
|
||||
|
||||
function handleWorkspaceDragEnd() {
|
||||
@@ -1661,10 +1747,9 @@ export default function Layout(props: ParentProps) {
|
||||
const existing = prev ?? []
|
||||
const next = existing.filter((item) => {
|
||||
const id = workspaceKey(item)
|
||||
if (id === root) return false
|
||||
return id !== key
|
||||
return id !== root && id !== key
|
||||
})
|
||||
return [local, created.directory, ...next]
|
||||
return [created.directory, ...next]
|
||||
})
|
||||
|
||||
globalSync.child(created.directory)
|
||||
@@ -1851,45 +1936,34 @@ export default function Layout(props: ParentProps) {
|
||||
when={workspacesEnabled()}
|
||||
fallback={
|
||||
<>
|
||||
<div class="shrink-0 py-4 px-3">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
size="large"
|
||||
icon="plus-small"
|
||||
class="w-full"
|
||||
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<LocalWorkspace
|
||||
ctx={workspaceSidebarCtx}
|
||||
project={p()}
|
||||
sortNow={sortNow}
|
||||
mobile={panelProps.mobile}
|
||||
header={
|
||||
<TooltipKeybind
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
size="large"
|
||||
icon="plus-small"
|
||||
class="w-full"
|
||||
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<div class="shrink-0 py-4 px-3">
|
||||
<TooltipKeybind
|
||||
title={language.t("workspace.new")}
|
||||
keybind={command.keybind("workspace.new")}
|
||||
placement="top"
|
||||
>
|
||||
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
|
||||
{language.t("workspace.new")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div class="relative flex-1 min-h-0">
|
||||
<DragDropProvider
|
||||
onDragStart={handleWorkspaceDragStart}
|
||||
@@ -1903,21 +1977,41 @@ export default function Layout(props: ParentProps) {
|
||||
ref={(el) => {
|
||||
if (!panelProps.mobile) scrollContainerRef = el
|
||||
}}
|
||||
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
||||
class="size-full flex flex-col overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
||||
>
|
||||
<SortableProvider ids={workspaces()}>
|
||||
<For each={workspaces()}>
|
||||
{(directory) => (
|
||||
<SortableWorkspace
|
||||
ctx={workspaceSidebarCtx}
|
||||
directory={directory}
|
||||
project={p()}
|
||||
sortNow={sortNow}
|
||||
mobile={panelProps.mobile}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
<div class="sticky top-0 z-20 pointer-events-none bg-[linear-gradient(to_bottom,var(--background-stronger)_calc(100%_-_24px),transparent)] pt-4 pb-6 px-3">
|
||||
<div class="pointer-events-auto">
|
||||
<TooltipKeybind
|
||||
title={language.t("workspace.new")}
|
||||
keybind={command.keybind("workspace.new")}
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
size="large"
|
||||
icon="plus-small"
|
||||
class="w-full"
|
||||
onClick={() => createWorkspace(p())}
|
||||
>
|
||||
{language.t("workspace.new")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<SortableProvider ids={workspaces()}>
|
||||
<For each={workspaces()}>
|
||||
{(directory) => (
|
||||
<SortableWorkspace
|
||||
ctx={workspaceSidebarCtx}
|
||||
directory={directory}
|
||||
project={p()}
|
||||
sortNow={sortNow}
|
||||
mobile={panelProps.mobile}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
</div>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
<WorkspaceDragOverlay
|
||||
@@ -2015,7 +2109,11 @@ export default function Layout(props: ParentProps) {
|
||||
onOpenSettings={openSettings}
|
||||
helpLabel={() => language.t("sidebar.help")}
|
||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
renderPanel={() => <SidebarPanel project={currentProject()} />}
|
||||
renderPanel={() => (
|
||||
<Show when={currentProject()} keyed>
|
||||
{(project) => <SidebarPanel project={project} />}
|
||||
</Show>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
export const deepLinkEvent = "opencode:deep-link"
|
||||
|
||||
export const parseDeepLink = (input: string) => {
|
||||
const parseUrl = (input: string) => {
|
||||
if (!input.startsWith("opencode://")) return
|
||||
if (typeof URL.canParse === "function" && !URL.canParse(input)) return
|
||||
const url = (() => {
|
||||
try {
|
||||
return new URL(input)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
try {
|
||||
return new URL(input)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export const parseDeepLink = (input: string) => {
|
||||
const url = parseUrl(input)
|
||||
if (!url) return
|
||||
if (url.hostname !== "open-project") return
|
||||
const directory = url.searchParams.get("directory")
|
||||
@@ -17,9 +19,23 @@ export const parseDeepLink = (input: string) => {
|
||||
return directory
|
||||
}
|
||||
|
||||
export const parseNewSessionDeepLink = (input: string) => {
|
||||
const url = parseUrl(input)
|
||||
if (!url) return
|
||||
if (url.hostname !== "new-session") return
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (!directory) return
|
||||
const prompt = url.searchParams.get("prompt") || undefined
|
||||
if (!prompt) return { directory }
|
||||
return { directory, prompt }
|
||||
}
|
||||
|
||||
export const collectOpenProjectDeepLinks = (urls: string[]) =>
|
||||
urls.map(parseDeepLink).filter((directory): directory is string => !!directory)
|
||||
|
||||
export const collectNewSessionDeepLinks = (urls: string[]) =>
|
||||
urls.map(parseNewSessionDeepLink).filter((link): link is { directory: string; prompt?: string } => !!link)
|
||||
|
||||
type OpenCodeWindow = Window & {
|
||||
__OPENCODE__?: {
|
||||
deepLinks?: string[]
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links"
|
||||
import {
|
||||
displayName,
|
||||
errorMessage,
|
||||
getDraggableId,
|
||||
hasProjectPermissions,
|
||||
latestRootSession,
|
||||
syncWorkspaceOrder,
|
||||
workspaceKey,
|
||||
} from "./helpers"
|
||||
collectNewSessionDeepLinks,
|
||||
collectOpenProjectDeepLinks,
|
||||
drainPendingDeepLinks,
|
||||
parseDeepLink,
|
||||
parseNewSessionDeepLink,
|
||||
} from "./deep-links"
|
||||
import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { hasProjectPermissions, latestRootSession } from "./helpers"
|
||||
|
||||
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
|
||||
({
|
||||
@@ -62,6 +61,28 @@ describe("layout deep links", () => {
|
||||
expect(result).toEqual(["/a", "/c"])
|
||||
})
|
||||
|
||||
test("parses new-session deep links with optional prompt", () => {
|
||||
expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo")).toEqual({ directory: "/tmp/demo" })
|
||||
expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo&prompt=hello%20world")).toEqual({
|
||||
directory: "/tmp/demo",
|
||||
prompt: "hello world",
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores new-session deep links without directory", () => {
|
||||
expect(parseNewSessionDeepLink("opencode://new-session")).toBeUndefined()
|
||||
expect(parseNewSessionDeepLink("opencode://new-session?directory=")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("collects only valid new-session deep links", () => {
|
||||
const result = collectNewSessionDeepLinks([
|
||||
"opencode://new-session?directory=/a",
|
||||
"opencode://open-project?directory=/b",
|
||||
"opencode://new-session?directory=/c&prompt=ship%20it",
|
||||
])
|
||||
expect(result).toEqual([{ directory: "/a" }, { directory: "/c", prompt: "ship it" }])
|
||||
})
|
||||
|
||||
test("drains global deep links once", () => {
|
||||
const target = {
|
||||
__OPENCODE__: {
|
||||
|
||||
@@ -74,9 +74,29 @@ export const errorMessage = (err: unknown, fallback: string) => {
|
||||
return fallback
|
||||
}
|
||||
|
||||
export const syncWorkspaceOrder = (local: string, dirs: string[], existing?: string[]) => {
|
||||
if (!existing) return dirs
|
||||
const keep = existing.filter((d) => d !== local && dirs.includes(d))
|
||||
const missing = dirs.filter((d) => d !== local && !existing.includes(d))
|
||||
return [local, ...missing, ...keep]
|
||||
export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => {
|
||||
const root = workspaceKey(local)
|
||||
const live = new Map<string, string>()
|
||||
|
||||
for (const dir of dirs) {
|
||||
const key = workspaceKey(dir)
|
||||
if (key === root) continue
|
||||
if (!live.has(key)) live.set(key, dir)
|
||||
}
|
||||
|
||||
if (!persisted?.length) return [local, ...live.values()]
|
||||
|
||||
const result = [local]
|
||||
for (const dir of persisted) {
|
||||
const key = workspaceKey(dir)
|
||||
if (key === root) continue
|
||||
const match = live.get(key)
|
||||
if (!match) continue
|
||||
result.push(match)
|
||||
live.delete(key)
|
||||
}
|
||||
|
||||
return [...result, ...live.values()]
|
||||
}
|
||||
|
||||
export const syncWorkspaceOrder = effectiveWorkspaceOrder
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { HoverCard } from "@opencode-ai/ui/hover-card"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -137,13 +136,6 @@ const SessionRow = (props: {
|
||||
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
|
||||
{props.session.title}
|
||||
</span>
|
||||
<Show when={props.session.summary}>
|
||||
{(summary) => (
|
||||
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
|
||||
<DiffChanges changes={summary()} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</A>
|
||||
)
|
||||
@@ -283,7 +275,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
data-session-id={props.session.id}
|
||||
class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
|
||||
class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 scroll-mt-24
|
||||
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
||||
>
|
||||
<Show
|
||||
|
||||
@@ -467,6 +467,7 @@ export const LocalWorkspace = (props: {
|
||||
project: LocalProject
|
||||
sortNow: Accessor<number>
|
||||
mobile?: boolean
|
||||
header?: JSX.Element
|
||||
}): JSX.Element => {
|
||||
const globalSync = useGlobalSync()
|
||||
const language = useLanguage()
|
||||
@@ -488,9 +489,14 @@ export const LocalWorkspace = (props: {
|
||||
return (
|
||||
<div
|
||||
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
|
||||
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
||||
class="size-full flex flex-col overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
||||
>
|
||||
<nav class="flex flex-col gap-1 px-2">
|
||||
<Show when={props.header}>
|
||||
<div class="sticky top-0 z-20 pointer-events-none bg-[linear-gradient(to_bottom,var(--background-stronger)_calc(100%_-_24px),transparent)] pt-4 pb-6 px-3">
|
||||
<div class="pointer-events-auto">{props.header}</div>
|
||||
</div>
|
||||
</Show>
|
||||
<nav class="flex flex-col gap-1 px-2 pb-2" classList={{ "pt-2": !props.header }}>
|
||||
<Show when={loading()}>
|
||||
<SessionSkeleton />
|
||||
</Show>
|
||||
|
||||
@@ -1,37 +1,259 @@
|
||||
import { onCleanup, Show, Match, Switch, createMemo, createEffect, on, onMount } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { checksum, base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import {
|
||||
createComputed,
|
||||
createEffect,
|
||||
createMemo,
|
||||
Match,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
Switch,
|
||||
untrack,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { SessionHeader, NewSessionView } from "@/components/session"
|
||||
import { same } from "@/utils/same"
|
||||
import { type FileSelection, type SelectedLineRange, selectionFromLines, useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
||||
import { same } from "@opencode-ai/util/array"
|
||||
import { isEditableTarget } from "@/utils/dom"
|
||||
import { createOpenReviewFile } from "@/pages/session/helpers"
|
||||
import { createScrollSpy } from "@/pages/session/scroll-spy"
|
||||
import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { createScrollSpy } from "@/pages/session/scroll-spy"
|
||||
import { AnimationDebugPanel } from "@opencode-ai/ui/animation-debug-panel"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
|
||||
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
sessionID: () => string | undefined
|
||||
messagesReady: () => boolean
|
||||
visibleUserMessages: () => UserMessage[]
|
||||
historyMore: () => boolean
|
||||
historyLoading: () => boolean
|
||||
loadMore: (sessionID: string) => Promise<void>
|
||||
userScrolled: () => boolean
|
||||
scroller: () => HTMLDivElement | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintains the rendered history window for a session timeline.
|
||||
*
|
||||
* It keeps initial paint bounded to recent turns, reveals cached turns in
|
||||
* small batches while scrolling upward, and prefetches older history near top.
|
||||
*/
|
||||
function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
const turnInit = 10
|
||||
const turnBatch = 8
|
||||
const turnScrollThreshold = 200
|
||||
const turnPrefetchBuffer = 16
|
||||
const prefetchCooldownMs = 400
|
||||
const prefetchNoGrowthLimit = 2
|
||||
|
||||
const [state, setState] = createStore({
|
||||
turnID: undefined as string | undefined,
|
||||
turnStart: 0,
|
||||
prefetchUntil: 0,
|
||||
prefetchNoGrowth: 0,
|
||||
})
|
||||
|
||||
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
|
||||
|
||||
const turnStart = createMemo(() => {
|
||||
const id = input.sessionID()
|
||||
const len = input.visibleUserMessages().length
|
||||
if (!id || len <= 0) return 0
|
||||
if (state.turnID !== id) return initialTurnStart(len)
|
||||
if (state.turnStart <= 0) return 0
|
||||
if (state.turnStart >= len) return initialTurnStart(len)
|
||||
return state.turnStart
|
||||
})
|
||||
|
||||
const setTurnStart = (start: number) => {
|
||||
const id = input.sessionID()
|
||||
const next = start > 0 ? start : 0
|
||||
if (!id) {
|
||||
setState({ turnID: undefined, turnStart: next })
|
||||
return
|
||||
}
|
||||
setState({ turnID: id, turnStart: next })
|
||||
}
|
||||
|
||||
const renderedUserMessages = createMemo(
|
||||
() => {
|
||||
const msgs = input.visibleUserMessages()
|
||||
const start = turnStart()
|
||||
if (start <= 0) return msgs
|
||||
return msgs.slice(start)
|
||||
},
|
||||
emptyUserMessages,
|
||||
{
|
||||
equals: same,
|
||||
},
|
||||
)
|
||||
|
||||
const preserveScroll = (fn: () => void) => {
|
||||
const el = input.scroller()
|
||||
if (!el) {
|
||||
fn()
|
||||
return
|
||||
}
|
||||
const beforeTop = el.scrollTop
|
||||
fn()
|
||||
// SolidJS updates the DOM synchronously. Force reflow so the browser
|
||||
// processes the new layout, then restore scrollTop before paint.
|
||||
// With column-reverse + overflow-anchor:none the same scrollTop value
|
||||
// keeps the same distance from the bottom — no delta math needed.
|
||||
void el.scrollHeight
|
||||
el.scrollTop = beforeTop
|
||||
}
|
||||
|
||||
const backfillTurns = () => {
|
||||
const start = turnStart()
|
||||
if (start <= 0) return
|
||||
|
||||
const next = start - turnBatch
|
||||
const nextStart = next > 0 ? next : 0
|
||||
|
||||
preserveScroll(() => setTurnStart(nextStart))
|
||||
}
|
||||
|
||||
/** Button path: reveal all cached turns, fetch older history, reveal one batch. */
|
||||
const loadAndReveal = async () => {
|
||||
const id = input.sessionID()
|
||||
if (!id) return
|
||||
|
||||
const start = turnStart()
|
||||
const beforeVisible = input.visibleUserMessages().length
|
||||
|
||||
if (start > 0) setTurnStart(0)
|
||||
|
||||
if (!input.historyMore() || input.historyLoading()) return
|
||||
|
||||
await input.loadMore(id)
|
||||
if (input.sessionID() !== id) return
|
||||
|
||||
const afterVisible = input.visibleUserMessages().length
|
||||
const growth = afterVisible - beforeVisible
|
||||
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
|
||||
if (growth <= 0) return
|
||||
if (turnStart() !== 0) return
|
||||
|
||||
const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch)
|
||||
const nextStart = Math.max(0, afterVisible - target)
|
||||
preserveScroll(() => setTurnStart(nextStart))
|
||||
}
|
||||
|
||||
/** Scroll/prefetch path: fetch older history from server. */
|
||||
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
|
||||
const id = input.sessionID()
|
||||
if (!id) return
|
||||
if (!input.historyMore() || input.historyLoading()) return
|
||||
|
||||
if (opts?.prefetch) {
|
||||
const now = Date.now()
|
||||
if (state.prefetchUntil > now) return
|
||||
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
|
||||
setState("prefetchUntil", now + prefetchCooldownMs)
|
||||
}
|
||||
|
||||
const start = turnStart()
|
||||
const beforeVisible = input.visibleUserMessages().length
|
||||
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
|
||||
|
||||
await input.loadMore(id)
|
||||
if (input.sessionID() !== id) return
|
||||
|
||||
const afterVisible = input.visibleUserMessages().length
|
||||
const growth = afterVisible - beforeVisible
|
||||
|
||||
if (opts?.prefetch) {
|
||||
setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
|
||||
} else if (growth > 0 && state.prefetchNoGrowth) {
|
||||
setState("prefetchNoGrowth", 0)
|
||||
}
|
||||
|
||||
if (growth <= 0) return
|
||||
if (turnStart() !== start) return
|
||||
|
||||
const reveal = !opts?.prefetch
|
||||
const currentRendered = renderedUserMessages().length
|
||||
const base = Math.max(beforeRendered, currentRendered)
|
||||
const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
|
||||
const nextStart = Math.max(0, afterVisible - target)
|
||||
preserveScroll(() => setTurnStart(nextStart))
|
||||
}
|
||||
|
||||
const onScrollerScroll = () => {
|
||||
if (!input.userScrolled()) return
|
||||
const el = input.scroller()
|
||||
if (!el) return
|
||||
// With column-reverse, distance from top = scrollHeight - clientHeight + scrollTop
|
||||
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
|
||||
|
||||
const start = turnStart()
|
||||
if (start > 0) {
|
||||
if (start <= turnPrefetchBuffer) {
|
||||
void fetchOlderMessages({ prefetch: true })
|
||||
}
|
||||
backfillTurns()
|
||||
return
|
||||
}
|
||||
|
||||
void fetchOlderMessages()
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
input.sessionID,
|
||||
() => {
|
||||
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [input.sessionID(), input.messagesReady()] as const,
|
||||
([id, ready]) => {
|
||||
if (!id || !ready) return
|
||||
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
turnStart,
|
||||
setTurnStart,
|
||||
renderedUserMessages,
|
||||
loadAndReveal,
|
||||
onScrollerScroll,
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
@@ -44,6 +266,19 @@ export default function Page() {
|
||||
const sdk = useSDK()
|
||||
const prompt = usePrompt()
|
||||
const comments = useComments()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
|
||||
createEffect(() => {
|
||||
if (!untrack(() => prompt.ready())) return
|
||||
prompt.ready()
|
||||
untrack(() => {
|
||||
if (params.id || !prompt.ready()) return
|
||||
const text = searchParams.prompt
|
||||
if (!text) return
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||
setSearchParams({ ...searchParams, prompt: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
const [ui, setUi] = createStore({
|
||||
pendingMessage: undefined as string | undefined,
|
||||
@@ -53,7 +288,6 @@ export default function Page() {
|
||||
bottom: true,
|
||||
},
|
||||
})
|
||||
|
||||
const composer = createSessionComposerState()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
@@ -138,24 +372,6 @@ export default function Page() {
|
||||
if (path) file.load(path)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const current = tabs().all()
|
||||
if (current.length === 0) return
|
||||
|
||||
const next = normalizeTabs(current)
|
||||
if (same(current, next)) return
|
||||
|
||||
tabs().setAll(next)
|
||||
|
||||
const active = tabs().active()
|
||||
if (!active) return
|
||||
if (!active.startsWith("file://")) return
|
||||
|
||||
const normalized = normalizeTab(active)
|
||||
if (active === normalized) return
|
||||
tabs().setActive(normalized)
|
||||
})
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
@@ -178,7 +394,6 @@ export default function Page() {
|
||||
return sync.session.history.loading(id)
|
||||
})
|
||||
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
emptyUserMessages,
|
||||
@@ -211,29 +426,38 @@ export default function Page() {
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
messageId: undefined as string | undefined,
|
||||
turnStart: 0,
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
changes: "session" as "session" | "turn",
|
||||
newSessionWorktree: "main",
|
||||
deferRender: false,
|
||||
})
|
||||
|
||||
let deferFrame: number | undefined
|
||||
let deferTimer: ReturnType<typeof setTimeout> | undefined
|
||||
createComputed((prev) => {
|
||||
const key = sessionKey()
|
||||
if (key !== prev) {
|
||||
if (deferFrame !== undefined) cancelAnimationFrame(deferFrame)
|
||||
if (deferTimer !== undefined) clearTimeout(deferTimer)
|
||||
setStore("deferRender", true)
|
||||
deferFrame = requestAnimationFrame(() => {
|
||||
deferFrame = undefined
|
||||
deferTimer = setTimeout(() => {
|
||||
deferTimer = undefined
|
||||
setStore("deferRender", false)
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
return key
|
||||
}, sessionKey())
|
||||
onCleanup(() => {
|
||||
if (deferFrame !== undefined) cancelAnimationFrame(deferFrame)
|
||||
if (deferTimer !== undefined) clearTimeout(deferTimer)
|
||||
})
|
||||
|
||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
||||
|
||||
const renderedUserMessages = createMemo(
|
||||
() => {
|
||||
const msgs = visibleUserMessages()
|
||||
const start = store.turnStart
|
||||
if (start <= 0) return msgs
|
||||
if (start >= msgs.length) return emptyUserMessages
|
||||
return msgs.slice(start)
|
||||
},
|
||||
emptyUserMessages,
|
||||
{
|
||||
equals: same,
|
||||
},
|
||||
)
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.newSessionWorktree === "create") return "create"
|
||||
const project = sync.project
|
||||
@@ -241,11 +465,6 @@ export default function Page() {
|
||||
return "main"
|
||||
})
|
||||
|
||||
const activeMessage = createMemo(() => {
|
||||
if (!store.messageId) return lastUserMessage()
|
||||
const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
|
||||
return found ?? lastUserMessage()
|
||||
})
|
||||
const setActiveMessage = (message: UserMessage | undefined) => {
|
||||
setStore("messageId", message?.id)
|
||||
}
|
||||
@@ -302,13 +521,15 @@ export default function Page() {
|
||||
|
||||
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
||||
|
||||
createEffect(() => {
|
||||
sdk.directory
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
void sync.session.sync(id)
|
||||
void sync.session.todo(id)
|
||||
})
|
||||
createEffect(
|
||||
on([() => sdk.directory, () => params.id] as const, ([, id]) => {
|
||||
if (!id) return
|
||||
untrack(() => {
|
||||
void sync.session.sync(id)
|
||||
void sync.session.todo(id)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
@@ -405,11 +626,6 @@ export default function Page() {
|
||||
saveLabel: language.t("common.save"),
|
||||
}))
|
||||
|
||||
const isEditableTarget = (target: EventTarget | null | undefined) => {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
|
||||
}
|
||||
|
||||
const deepActiveElement = () => {
|
||||
let current: Element | null = document.activeElement
|
||||
while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
|
||||
@@ -478,7 +694,11 @@ export default function Page() {
|
||||
on(
|
||||
sessionKey,
|
||||
() => {
|
||||
setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined })
|
||||
setTree({
|
||||
reviewScroll: undefined,
|
||||
pendingDiff: undefined,
|
||||
activeDiff: undefined,
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -499,11 +719,26 @@ export default function Page() {
|
||||
|
||||
const openReviewFile = createOpenReviewFile({
|
||||
showAllFiles,
|
||||
openReviewPanel,
|
||||
tabForPath: file.tab,
|
||||
openTab: tabs().open,
|
||||
setActive: tabs().setActive,
|
||||
setSelectedLines: file.setSelectedLines,
|
||||
loadFile: file.load,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const open = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ path?: string; line?: number }>).detail
|
||||
const path = detail?.path
|
||||
if (!path) return
|
||||
openReviewFile(path, detail?.line)
|
||||
}
|
||||
|
||||
window.addEventListener("opencode:open-file-path", open)
|
||||
onCleanup(() => window.removeEventListener("opencode:open-file-path", open))
|
||||
})
|
||||
|
||||
const changesOptions = ["session", "turn"] as const
|
||||
const changesOptionsList = [...changesOptions]
|
||||
|
||||
@@ -535,35 +770,12 @@ export default function Page() {
|
||||
loadingClass: string
|
||||
emptyClass: string
|
||||
}) => (
|
||||
<Switch>
|
||||
<Match when={store.changes === "turn" && !!params.id}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={emptyTurn()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
|
||||
>
|
||||
<Show when={!store.deferRender}>
|
||||
<Switch>
|
||||
<Match when={store.changes === "turn" && !!params.id}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={emptyTurn()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
@@ -580,39 +792,64 @@ export default function Page() {
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={
|
||||
store.changes === "turn" ? (
|
||||
emptyTurn()
|
||||
) : (
|
||||
<div class={input.emptyClass}>
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
|
||||
>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={
|
||||
store.changes === "turn" ? (
|
||||
emptyTurn()
|
||||
) : (
|
||||
<div class={input.emptyClass}>
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
)
|
||||
|
||||
const reviewPanel = () => (
|
||||
@@ -823,7 +1060,10 @@ export default function Page() {
|
||||
const updateScrollState = (el: HTMLDivElement) => {
|
||||
const max = el.scrollHeight - el.clientHeight
|
||||
const overflow = max > 1
|
||||
const bottom = !overflow || el.scrollTop >= max - 2
|
||||
// If auto-scroll is tracking the bottom, always report bottom: true
|
||||
// to prevent the scroll-down arrow from flashing during height animations
|
||||
// With column-reverse, scrollTop=0 is at the bottom
|
||||
const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
|
||||
|
||||
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
|
||||
setUi("scroll", { overflow, bottom })
|
||||
@@ -846,7 +1086,7 @@ export default function Page() {
|
||||
|
||||
const resumeScroll = () => {
|
||||
setStore("messageId", undefined)
|
||||
autoScroll.forceScrollToBottom()
|
||||
autoScroll.smoothScrollToBottom()
|
||||
clearMessageHash()
|
||||
|
||||
const el = scroller
|
||||
@@ -894,88 +1134,16 @@ export default function Page() {
|
||||
},
|
||||
)
|
||||
|
||||
const turnInit = 20
|
||||
const turnBatch = 20
|
||||
let turnHandle: number | undefined
|
||||
let turnIdle = false
|
||||
|
||||
function cancelTurnBackfill() {
|
||||
const handle = turnHandle
|
||||
if (handle === undefined) return
|
||||
turnHandle = undefined
|
||||
|
||||
if (turnIdle && window.cancelIdleCallback) {
|
||||
window.cancelIdleCallback(handle)
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(handle)
|
||||
}
|
||||
|
||||
function scheduleTurnBackfill() {
|
||||
if (turnHandle !== undefined) return
|
||||
if (store.turnStart <= 0) return
|
||||
|
||||
if (window.requestIdleCallback) {
|
||||
turnIdle = true
|
||||
turnHandle = window.requestIdleCallback(() => {
|
||||
turnHandle = undefined
|
||||
backfillTurns()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
turnIdle = false
|
||||
turnHandle = window.setTimeout(() => {
|
||||
turnHandle = undefined
|
||||
backfillTurns()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function backfillTurns() {
|
||||
const start = store.turnStart
|
||||
if (start <= 0) return
|
||||
|
||||
const next = start - turnBatch
|
||||
const nextStart = next > 0 ? next : 0
|
||||
|
||||
const el = scroller
|
||||
if (!el) {
|
||||
setStore("turnStart", nextStart)
|
||||
scheduleTurnBackfill()
|
||||
return
|
||||
}
|
||||
|
||||
const beforeTop = el.scrollTop
|
||||
const beforeHeight = el.scrollHeight
|
||||
|
||||
setStore("turnStart", nextStart)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const delta = el.scrollHeight - beforeHeight
|
||||
if (!delta) return
|
||||
el.scrollTop = beforeTop + delta
|
||||
})
|
||||
|
||||
scheduleTurnBackfill()
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [params.id, messagesReady()] as const,
|
||||
([id, ready]) => {
|
||||
cancelTurnBackfill()
|
||||
setStore("turnStart", 0)
|
||||
if (!id || !ready) return
|
||||
|
||||
const len = visibleUserMessages().length
|
||||
const start = len > turnInit ? len - turnInit : 0
|
||||
setStore("turnStart", start)
|
||||
scheduleTurnBackfill()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const historyWindow = createSessionHistoryWindow({
|
||||
sessionID: () => params.id,
|
||||
messagesReady,
|
||||
visibleUserMessages,
|
||||
historyMore,
|
||||
historyLoading,
|
||||
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
||||
userScrolled: autoScroll.userScrolled,
|
||||
scroller: () => scroller,
|
||||
})
|
||||
|
||||
createResizeObserver(
|
||||
() => promptDock,
|
||||
@@ -986,7 +1154,8 @@ export default function Page() {
|
||||
|
||||
const el = scroller
|
||||
const delta = next - dockHeight
|
||||
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) : false
|
||||
// With column-reverse, near bottom = scrollTop near 0
|
||||
const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
|
||||
|
||||
dockHeight = next
|
||||
|
||||
@@ -1002,13 +1171,12 @@ export default function Page() {
|
||||
sessionID: () => params.id,
|
||||
messagesReady,
|
||||
visibleUserMessages,
|
||||
turnStart: () => store.turnStart,
|
||||
turnStart: historyWindow.turnStart,
|
||||
currentMessageId: () => store.messageId,
|
||||
pendingMessage: () => ui.pendingMessage,
|
||||
setPendingMessage: (value) => setUi("pendingMessage", value),
|
||||
setActiveMessage,
|
||||
setTurnStart: (value) => setStore("turnStart", value),
|
||||
scheduleTurnBackfill,
|
||||
setTurnStart: historyWindow.setTurnStart,
|
||||
autoScroll,
|
||||
scroller: () => scroller,
|
||||
anchor,
|
||||
@@ -1021,7 +1189,6 @@ export default function Page() {
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
cancelTurnBackfill()
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
scrollSpy.destroy()
|
||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||
@@ -1029,6 +1196,7 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
||||
{import.meta.env.DEV && <AnimationDebugPanel />}
|
||||
<SessionHeader />
|
||||
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
|
||||
<SessionMobileTabs
|
||||
@@ -1054,54 +1222,49 @@ export default function Page() {
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id}>
|
||||
<Show when={activeMessage()}>
|
||||
<MessageTimeline
|
||||
mobileChanges={mobileChanges()}
|
||||
mobileFallback={reviewContent({
|
||||
diffStyle: "unified",
|
||||
classes: {
|
||||
root: "pb-8",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
isDesktop={isDesktop()}
|
||||
onScrollSpyScroll={scrollSpy.onScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
<MessageTimeline
|
||||
mobileChanges={mobileChanges()}
|
||||
mobileFallback={reviewContent({
|
||||
diffStyle: "unified",
|
||||
classes: {
|
||||
root: "pb-8",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
isDesktop={isDesktop()}
|
||||
onScrollSpyScroll={scrollSpy.onScroll}
|
||||
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
onPreserveScrollAnchor={autoScroll.preserve}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
turnStart={store.turnStart}
|
||||
onRenderEarlier={() => setStore("turnStart", 0)}
|
||||
historyMore={historyMore()}
|
||||
historyLoading={historyLoading()}
|
||||
onLoadEarlier={() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
setStore("turnStart", 0)
|
||||
sync.session.history.loadMore(id)
|
||||
}}
|
||||
renderedUserMessages={renderedUserMessages()}
|
||||
anchor={anchor}
|
||||
onRegisterMessage={scrollSpy.register}
|
||||
onUnregisterMessage={scrollSpy.unregister}
|
||||
lastUserMessageID={lastUserMessage()?.id}
|
||||
/>
|
||||
</Show>
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
turnStart={historyWindow.turnStart()}
|
||||
historyMore={historyMore()}
|
||||
historyLoading={historyLoading()}
|
||||
onLoadEarlier={() => {
|
||||
void historyWindow.loadAndReveal()
|
||||
}}
|
||||
renderedUserMessages={historyWindow.renderedUserMessages()}
|
||||
anchor={anchor}
|
||||
onRegisterMessage={scrollSpy.register}
|
||||
onUnregisterMessage={scrollSpy.unregister}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<NewSessionView
|
||||
@@ -1127,6 +1290,7 @@ export default function Page() {
|
||||
|
||||
<SessionComposerRegion
|
||||
state={composer}
|
||||
ready={!store.deferRender && messagesReady()}
|
||||
centered={centered()}
|
||||
inputRef={(el) => {
|
||||
inputRef = el
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Show, createEffect, createMemo } from "solid-js"
|
||||
import { Show, createMemo, createSignal, createEffect } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { useElementHeight } from "@opencode-ai/ui/hooks"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
@@ -7,7 +9,9 @@ import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
||||
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
||||
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
|
||||
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
||||
import { SessionTodoDock, COLLAPSED_HEIGHT } from "@/pages/session/composer/session-todo-dock"
|
||||
|
||||
const DOCK_SPRING = { visualDuration: 0.3, bounce: 0 }
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
state: SessionComposerState
|
||||
@@ -43,6 +47,16 @@ export function SessionComposerRegion(props: {
|
||||
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
||||
})
|
||||
|
||||
const open = createMemo(() => props.state.dock() && !props.state.closing())
|
||||
const progress = useSpring(
|
||||
() => (open() ? 1 : 0),
|
||||
DOCK_SPRING,
|
||||
)
|
||||
const dock = createMemo(() => props.state.dock() || progress() > 0.001)
|
||||
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
|
||||
const height = useElementHeight(contentRef, 320)
|
||||
const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height()))
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={props.setPromptDockRef}
|
||||
@@ -87,30 +101,33 @@ export function SessionComposerRegion(props: {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={props.state.dock()}>
|
||||
<Show when={dock()}>
|
||||
<div
|
||||
classList={{
|
||||
"transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
|
||||
"max-h-[320px]": !props.state.closing(),
|
||||
"max-h-0 pointer-events-none": props.state.closing(),
|
||||
"opacity-0 translate-y-9": props.state.closing() || props.state.opening(),
|
||||
"opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(),
|
||||
"overflow-hidden": true,
|
||||
"pointer-events-none": progress() < 0.98,
|
||||
}}
|
||||
style={{
|
||||
"max-height": `${full() * progress()}px`,
|
||||
}}
|
||||
>
|
||||
<SessionTodoDock
|
||||
todos={props.state.todos()}
|
||||
title={language.t("session.todo.title")}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
/>
|
||||
<div ref={setContentRef}>
|
||||
<SessionTodoDock
|
||||
todos={props.state.todos()}
|
||||
title={language.t("session.todo.title")}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
dockProgress={progress()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"relative z-10": true,
|
||||
"transition-[margin] duration-[400ms] ease-out": true,
|
||||
"-mt-9": props.state.dock() && !props.state.closing(),
|
||||
"mt-0": !props.state.dock() || props.state.closing(),
|
||||
}}
|
||||
style={{
|
||||
"margin-top": `${-36 * progress()}px`,
|
||||
}}
|
||||
>
|
||||
<PromptInput
|
||||
|
||||
@@ -29,7 +29,11 @@ export function createSessionComposerBlocked() {
|
||||
})
|
||||
}
|
||||
|
||||
export function createSessionComposerState() {
|
||||
export function createSessionComposerState(
|
||||
options?: {
|
||||
closeMs?: number | (() => number)
|
||||
},
|
||||
) {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
@@ -96,12 +100,19 @@ export function createSessionComposerState() {
|
||||
let timer: number | undefined
|
||||
let raf: number | undefined
|
||||
|
||||
const closeMs = () => {
|
||||
const value = options?.closeMs
|
||||
if (typeof value === "function") return Math.max(0, value())
|
||||
if (typeof value === "number") return Math.max(0, value)
|
||||
return 400
|
||||
}
|
||||
|
||||
const scheduleClose = () => {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = window.setTimeout(() => {
|
||||
setStore({ dock: false, closing: false })
|
||||
timer = undefined
|
||||
}, 400)
|
||||
}, closeMs())
|
||||
}
|
||||
|
||||
createEffect(
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
|
||||
import { Checkbox } from "@opencode-ai/ui/checkbox"
|
||||
import { DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
||||
import { useElementHeight } from "@opencode-ai/ui/hooks"
|
||||
import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
const COLLAPSE_SPRING = { visualDuration: 0.3, bounce: 0 }
|
||||
export const COLLAPSED_HEIGHT = 78
|
||||
const SUBTITLE = { duration: 600, travel: 25, edge: 17 }
|
||||
const COUNT = { duration: 600, mask: 18, maskHeight: 0, widthDuration: 560 }
|
||||
|
||||
function dot(status: Todo["status"]) {
|
||||
if (status !== "in_progress") return undefined
|
||||
return (
|
||||
@@ -30,19 +40,22 @@ function dot(status: Todo["status"]) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
|
||||
export function SessionTodoDock(props: {
|
||||
todos: Todo[]
|
||||
title: string
|
||||
collapseLabel: string
|
||||
expandLabel: string
|
||||
dockProgress?: number
|
||||
}) {
|
||||
const [store, setStore] = createStore({
|
||||
collapsed: false,
|
||||
})
|
||||
|
||||
const toggle = () => setStore("collapsed", (value) => !value)
|
||||
|
||||
const summary = createMemo(() => {
|
||||
const total = props.todos.length
|
||||
if (total === 0) return ""
|
||||
const completed = props.todos.filter((todo) => todo.status === "completed").length
|
||||
return `${completed} of ${total} ${props.title.toLowerCase()} completed`
|
||||
})
|
||||
const total = createMemo(() => props.todos.length)
|
||||
const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
|
||||
const label = createMemo(() => `${done()} of ${total()} ${props.title.toLowerCase()} completed`)
|
||||
|
||||
const active = createMemo(
|
||||
() =>
|
||||
@@ -53,56 +66,108 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
|
||||
)
|
||||
|
||||
const preview = createMemo(() => active()?.content ?? "")
|
||||
const collapse = useSpring(() => (store.collapsed ? 1 : 0), COLLAPSE_SPRING)
|
||||
const shut = createMemo(() => 1 - (props.dockProgress ?? 1))
|
||||
const hide = createMemo(() => Math.max(collapse(), shut()))
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
const height = useElementHeight(() => contentRef, 320)
|
||||
const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height()))
|
||||
|
||||
return (
|
||||
<DockTray
|
||||
data-component="session-todo-dock"
|
||||
classList={{
|
||||
"h-[78px]": store.collapsed,
|
||||
style={{
|
||||
"overflow-x": "visible",
|
||||
"overflow-y": "hidden",
|
||||
"max-height": `${Math.max(COLLAPSED_HEIGHT, full() - collapse() * (full() - COLLAPSED_HEIGHT))}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-action="session-todo-toggle"
|
||||
class="pl-3 pr-2 py-2 flex items-center gap-2"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
toggle()
|
||||
}}
|
||||
>
|
||||
<span class="text-14-regular text-text-strong cursor-default">{summary()}</span>
|
||||
<Show when={store.collapsed}>
|
||||
<div class="ml-1 flex-1 min-w-0">
|
||||
<Show when={preview()}>
|
||||
<div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div>
|
||||
</Show>
|
||||
<div ref={contentRef}>
|
||||
<div
|
||||
data-action="session-todo-toggle"
|
||||
class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
toggle()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible"
|
||||
aria-label={label()}
|
||||
style={{
|
||||
"--tool-motion-odometer-ms": `${COUNT.duration}ms`,
|
||||
"--tool-motion-mask": `${COUNT.mask}%`,
|
||||
"--tool-motion-mask-height": `${COUNT.maskHeight}px`,
|
||||
"--tool-motion-spring-ms": `${COUNT.widthDuration}ms`,
|
||||
opacity: `${1 - shut()}`,
|
||||
filter: shut() > 0.01 ? `blur(${shut() * 2}px)` : "none",
|
||||
}}
|
||||
>
|
||||
<AnimatedNumber value={done()} />
|
||||
<span class="mx-1">of</span>
|
||||
<AnimatedNumber value={total()} />
|
||||
<span> {props.title.toLowerCase()} completed</span>
|
||||
</span>
|
||||
<div
|
||||
data-slot="session-todo-preview"
|
||||
class="ml-1 min-w-0 overflow-hidden"
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
"max-width": "100%",
|
||||
}}
|
||||
>
|
||||
<TextReveal
|
||||
class="text-14-regular text-text-base cursor-default"
|
||||
text={store.collapsed ? preview() : undefined}
|
||||
duration={SUBTITLE.duration}
|
||||
travel={SUBTITLE.travel}
|
||||
edge={SUBTITLE.edge}
|
||||
spring="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
growOnly
|
||||
truncate
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<IconButton
|
||||
data-action="session-todo-toggle-button"
|
||||
data-collapsed={store.collapsed ? "true" : "false"}
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
style={{ transform: `rotate(${collapse() * 180}deg)` }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
toggle()
|
||||
}}
|
||||
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
|
||||
<IconButton
|
||||
data-action="session-todo-toggle-button"
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
classList={{ "rotate-180": store.collapsed }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
toggle()
|
||||
}}
|
||||
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-slot="session-todo-list" hidden={store.collapsed}>
|
||||
<TodoList todos={props.todos} open={!store.collapsed} />
|
||||
<div
|
||||
data-slot="session-todo-list"
|
||||
class="pb-2"
|
||||
aria-hidden={store.collapsed}
|
||||
classList={{
|
||||
"pointer-events-none": hide() > 0.1,
|
||||
}}
|
||||
style={{
|
||||
opacity: `${1 - hide()}`,
|
||||
filter: hide() > 0.01 ? `blur(${hide() * 2}px)` : "none",
|
||||
visibility: hide() > 0.98 ? "hidden" : "visible",
|
||||
}}
|
||||
>
|
||||
<TodoList todos={props.todos} open={!store.collapsed} />
|
||||
</div>
|
||||
</div>
|
||||
</DockTray>
|
||||
)
|
||||
@@ -171,33 +236,39 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
|
||||
}, 250)
|
||||
}}
|
||||
>
|
||||
<For each={props.todos}>
|
||||
<Index each={props.todos}>
|
||||
{(todo) => (
|
||||
<Checkbox
|
||||
readOnly
|
||||
checked={todo.status === "completed"}
|
||||
indeterminate={todo.status === "in_progress"}
|
||||
data-in-progress={todo.status === "in_progress" ? "" : undefined}
|
||||
icon={dot(todo.status)}
|
||||
style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }}
|
||||
checked={todo().status === "completed"}
|
||||
indeterminate={todo().status === "in_progress"}
|
||||
data-in-progress={todo().status === "in_progress" ? "" : undefined}
|
||||
data-state={todo().status}
|
||||
icon={dot(todo().status)}
|
||||
style={{
|
||||
"--checkbox-align": "flex-start",
|
||||
"--checkbox-offset": "1px",
|
||||
transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
opacity: todo().status === "pending" ? "0.5" : "1",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
<TextStrikethrough
|
||||
active={todo().status === "completed" || todo().status === "cancelled"}
|
||||
text={todo().content}
|
||||
class="text-14-regular min-w-0 break-words"
|
||||
classList={{
|
||||
"text-text-weak": todo.status === "completed" || todo.status === "cancelled",
|
||||
"text-text-strong": todo.status !== "completed" && todo.status !== "cancelled",
|
||||
}}
|
||||
style={{
|
||||
"line-height": "var(--line-height-normal)",
|
||||
"text-decoration":
|
||||
todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
|
||||
transition:
|
||||
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
color:
|
||||
todo().status === "completed" || todo().status === "cancelled"
|
||||
? "var(--text-weak)"
|
||||
: "var(--text-strong)",
|
||||
}}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
/>
|
||||
</Checkbox>
|
||||
)}
|
||||
</For>
|
||||
</Index>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
|
||||
|
||||
@@ -67,6 +67,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let scrollFrame: number | undefined
|
||||
let restoreFrame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
let codeScroll: HTMLElement[] = []
|
||||
let find: FileSearchHandle | null = null
|
||||
@@ -349,6 +350,15 @@ export function FileTabContent(props: { tab: string }) {
|
||||
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
||||
}
|
||||
|
||||
const queueRestore = () => {
|
||||
if (restoreFrame !== undefined) return
|
||||
|
||||
restoreFrame = requestAnimationFrame(() => {
|
||||
restoreFrame = undefined
|
||||
restoreScroll()
|
||||
})
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
if (codeScroll.length === 0) syncCodeScroll()
|
||||
|
||||
@@ -364,46 +374,29 @@ export function FileTabContent(props: { tab: string }) {
|
||||
setNote("commenting", null)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => state()?.loaded,
|
||||
(loaded) => {
|
||||
if (!loaded) return
|
||||
requestAnimationFrame(restoreScroll)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
let prev = {
|
||||
loaded: false,
|
||||
ready: false,
|
||||
active: false,
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => file.ready(),
|
||||
(ready) => {
|
||||
if (!ready) return
|
||||
requestAnimationFrame(restoreScroll)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => tabs().active() === props.tab,
|
||||
(active) => {
|
||||
if (!active) return
|
||||
if (!state()?.loaded) return
|
||||
requestAnimationFrame(restoreScroll)
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(() => {
|
||||
const loaded = !!state()?.loaded
|
||||
const ready = file.ready()
|
||||
const active = tabs().active() === props.tab
|
||||
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
|
||||
prev = { loaded, ready, active }
|
||||
if (!restore) return
|
||||
queueRestore()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
for (const item of codeScroll) {
|
||||
item.removeEventListener("scroll", handleCodeScroll)
|
||||
}
|
||||
|
||||
if (scrollFrame === undefined) return
|
||||
cancelAnimationFrame(scrollFrame)
|
||||
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
|
||||
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
||||
})
|
||||
|
||||
const renderFile = (source: string) => (
|
||||
@@ -421,7 +414,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
selectedLines={activeSelection()}
|
||||
commentedLines={commentedLines()}
|
||||
onRendered={() => {
|
||||
requestAnimationFrame(restoreScroll)
|
||||
queueRestore()
|
||||
}}
|
||||
annotations={commentsUi.annotations()}
|
||||
renderAnnotation={commentsUi.renderAnnotation}
|
||||
@@ -440,7 +433,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
mode: "auto",
|
||||
path: path(),
|
||||
current: state()?.content,
|
||||
onLoad: () => requestAnimationFrame(restoreScroll),
|
||||
onLoad: queueRestore,
|
||||
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
||||
if (args.kind !== "svg") return
|
||||
showToast({
|
||||
|
||||
@@ -6,17 +6,45 @@ describe("createOpenReviewFile", () => {
|
||||
const calls: string[] = []
|
||||
const openReviewFile = createOpenReviewFile({
|
||||
showAllFiles: () => calls.push("show"),
|
||||
openReviewPanel: () => calls.push("review"),
|
||||
tabForPath: (path) => {
|
||||
calls.push(`tab:${path}`)
|
||||
return `file://${path}`
|
||||
},
|
||||
openTab: (tab) => calls.push(`open:${tab}`),
|
||||
setActive: (tab) => calls.push(`active:${tab}`),
|
||||
setSelectedLines: (path, range) => calls.push(`select:${path}:${range ? `${range.start}-${range.end}` : "none"}`),
|
||||
loadFile: (path) => calls.push(`load:${path}`),
|
||||
})
|
||||
|
||||
openReviewFile("src/a.ts")
|
||||
|
||||
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts"])
|
||||
expect(calls).toEqual([
|
||||
"tab:src/a.ts",
|
||||
"show",
|
||||
"review",
|
||||
"load:src/a.ts",
|
||||
"open:file://src/a.ts",
|
||||
"active:file://src/a.ts",
|
||||
"select:src/a.ts:none",
|
||||
])
|
||||
})
|
||||
|
||||
test("selects the requested line when provided", () => {
|
||||
const calls: string[] = []
|
||||
const openReviewFile = createOpenReviewFile({
|
||||
showAllFiles: () => calls.push("show"),
|
||||
openReviewPanel: () => calls.push("review"),
|
||||
tabForPath: (path) => `file://${path}`,
|
||||
openTab: () => calls.push("open"),
|
||||
setActive: () => calls.push("active"),
|
||||
setSelectedLines: (_path, range) => calls.push(`select:${range?.start}-${range?.end}`),
|
||||
loadFile: () => calls.push("load"),
|
||||
})
|
||||
|
||||
openReviewFile("src/a.ts", 12)
|
||||
|
||||
expect(calls).toContain("select:12-12")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -24,13 +24,22 @@ export const createOpenReviewFile = (input: {
|
||||
showAllFiles: () => void
|
||||
tabForPath: (path: string) => string
|
||||
openTab: (tab: string) => void
|
||||
setActive: (tab: string) => void
|
||||
openReviewPanel: () => void
|
||||
setSelectedLines: (path: string, range: { start: number; end: number } | null) => void
|
||||
loadFile: (path: string) => any | Promise<void>
|
||||
}) => {
|
||||
return (path: string) => {
|
||||
return (path: string, line?: number) => {
|
||||
const tab = input.tabForPath(path)
|
||||
batch(() => {
|
||||
input.showAllFiles()
|
||||
input.openReviewPanel()
|
||||
const maybePromise = input.loadFile(path)
|
||||
const openTab = () => input.openTab(input.tabForPath(path))
|
||||
const openTab = () => {
|
||||
input.openTab(tab)
|
||||
input.setActive(tab)
|
||||
input.setSelectedLines(path, line ? { start: line, end: line } : null)
|
||||
}
|
||||
if (maybePromise instanceof Promise) maybePromise.then(openTab)
|
||||
else openTab()
|
||||
})
|
||||
|
||||
@@ -49,11 +49,11 @@ describe("shouldMarkBoundaryGesture", () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("does not mark when nested scroller can consume movement", () => {
|
||||
test("does not mark when scroller can consume movement", () => {
|
||||
expect(
|
||||
shouldMarkBoundaryGesture({
|
||||
delta: 20,
|
||||
scrollTop: 200,
|
||||
scrollTop: 300,
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 400,
|
||||
}),
|
||||
|
||||
@@ -14,8 +14,8 @@ export const shouldMarkBoundaryGesture = (input: {
|
||||
if (max <= 1) return true
|
||||
if (!input.delta) return false
|
||||
|
||||
if (input.delta < 0) return input.scrollTop + input.delta <= 0
|
||||
|
||||
const remaining = max - input.scrollTop
|
||||
return input.delta > remaining
|
||||
const top = Math.max(0, Math.min(max, input.scrollTop))
|
||||
if (input.delta < 0) return -input.delta > top
|
||||
const bottom = max - top
|
||||
return input.delta > bottom
|
||||
}
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { For, createEffect, createMemo, on, onCleanup, Show, type JSX } from "solid-js"
|
||||
import {
|
||||
For,
|
||||
Index,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
on,
|
||||
onCleanup,
|
||||
Show,
|
||||
startTransition,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -10,10 +21,13 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
|
||||
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
import { errorMessage } from "@/pages/layout/helpers"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -31,6 +45,11 @@ type MessageComment = {
|
||||
}
|
||||
}
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
|
||||
const isDefaultSessionTitle = (title?: string) =>
|
||||
!!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
|
||||
|
||||
const messageComments = (parts: Part[]): MessageComment[] =>
|
||||
parts.flatMap((part) => {
|
||||
if (part.type !== "text" || !(part as TextPart).synthetic) return []
|
||||
@@ -81,6 +100,132 @@ const markBoundaryGesture = (input: {
|
||||
}
|
||||
}
|
||||
|
||||
type StageConfig = {
|
||||
init: number
|
||||
batch: number
|
||||
}
|
||||
|
||||
type TimelineStageInput = {
|
||||
sessionKey: () => string
|
||||
turnStart: () => number
|
||||
messages: () => UserMessage[]
|
||||
config: StageConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Defer-mounts small timeline windows so revealing older turns does not
|
||||
* block first paint with a large DOM mount.
|
||||
*
|
||||
* Once staging completes for a session it never re-stages — backfill and
|
||||
* new messages render immediately.
|
||||
*/
|
||||
function createTimelineStaging(input: TimelineStageInput) {
|
||||
const [state, setState] = createStore({
|
||||
activeSession: "",
|
||||
completedSession: "",
|
||||
count: 0,
|
||||
})
|
||||
const [readySession, setReadySession] = createSignal("")
|
||||
let active = ""
|
||||
|
||||
const stagedCount = createMemo(() => {
|
||||
const total = input.messages().length
|
||||
if (input.turnStart() <= 0) return total
|
||||
if (state.completedSession === input.sessionKey()) return total
|
||||
const init = Math.min(total, input.config.init)
|
||||
if (state.count <= init) return init
|
||||
if (state.count >= total) return total
|
||||
return state.count
|
||||
})
|
||||
|
||||
const stagedUserMessages = createMemo(() => {
|
||||
const list = input.messages()
|
||||
const count = stagedCount()
|
||||
if (count >= list.length) return list
|
||||
return list.slice(Math.max(0, list.length - count))
|
||||
})
|
||||
|
||||
let frame: number | undefined
|
||||
const cancel = () => {
|
||||
if (frame === undefined) return
|
||||
cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
}
|
||||
const scheduleReady = (sessionKey: string) => {
|
||||
if (input.sessionKey() !== sessionKey) return
|
||||
if (readySession() === sessionKey) return
|
||||
setReadySession(sessionKey)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
|
||||
([sessionKey, isWindowed, total]) => {
|
||||
const switched = active !== sessionKey
|
||||
if (switched) {
|
||||
active = sessionKey
|
||||
setReadySession("")
|
||||
}
|
||||
|
||||
const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey
|
||||
const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey
|
||||
|
||||
if (staging && !switched && shouldStage && frame !== undefined) return
|
||||
|
||||
cancel()
|
||||
|
||||
if (shouldStage) setReadySession("")
|
||||
if (!shouldStage) {
|
||||
setState({
|
||||
activeSession: "",
|
||||
completedSession: isWindowed ? sessionKey : state.completedSession,
|
||||
count: total,
|
||||
})
|
||||
if (total <= 0) {
|
||||
setReadySession("")
|
||||
return
|
||||
}
|
||||
if (readySession() !== sessionKey) scheduleReady(sessionKey)
|
||||
return
|
||||
}
|
||||
|
||||
let count = Math.min(total, input.config.init)
|
||||
if (staging) count = Math.min(total, Math.max(count, state.count))
|
||||
setState({ activeSession: sessionKey, count })
|
||||
|
||||
const step = () => {
|
||||
if (input.sessionKey() !== sessionKey) {
|
||||
frame = undefined
|
||||
return
|
||||
}
|
||||
const currentTotal = input.messages().length
|
||||
count = Math.min(currentTotal, count + input.config.batch)
|
||||
startTransition(() => setState("count", count))
|
||||
if (count >= currentTotal) {
|
||||
setState({ completedSession: sessionKey, activeSession: "" })
|
||||
frame = undefined
|
||||
scheduleReady(sessionKey)
|
||||
return
|
||||
}
|
||||
frame = requestAnimationFrame(step)
|
||||
}
|
||||
frame = requestAnimationFrame(step)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const isStaging = createMemo(() => {
|
||||
const key = input.sessionKey()
|
||||
return state.activeSession === key && state.completedSession !== key
|
||||
})
|
||||
const ready = createMemo(() => readySession() === input.sessionKey())
|
||||
|
||||
onCleanup(() => {
|
||||
cancel()
|
||||
})
|
||||
return { messages: stagedUserMessages, isStaging, ready }
|
||||
}
|
||||
|
||||
export function MessageTimeline(props: {
|
||||
mobileChanges: boolean
|
||||
mobileFallback: JSX.Element
|
||||
@@ -93,11 +238,12 @@ export function MessageTimeline(props: {
|
||||
hasScrollGesture: () => boolean
|
||||
isDesktop: boolean
|
||||
onScrollSpyScroll: () => void
|
||||
onTurnBackfillScroll: () => void
|
||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||
onPreserveScrollAnchor: (target: HTMLElement) => void
|
||||
centered: boolean
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
turnStart: number
|
||||
onRenderEarlier: () => void
|
||||
historyMore: boolean
|
||||
historyLoading: boolean
|
||||
onLoadEarlier: () => void
|
||||
@@ -105,7 +251,6 @@ export function MessageTimeline(props: {
|
||||
anchor: (id: string) => string
|
||||
onRegisterMessage: (el: HTMLDivElement, id: string) => void
|
||||
onUnregisterMessage: (id: string) => void
|
||||
lastUserMessageID?: string
|
||||
}) {
|
||||
let touchGesture: number | undefined
|
||||
|
||||
@@ -117,16 +262,69 @@ export function MessageTimeline(props: {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
const trigger = (target: EventTarget | null) => {
|
||||
const next =
|
||||
target instanceof Element
|
||||
? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"]')
|
||||
: undefined
|
||||
if (!(next instanceof HTMLElement)) return
|
||||
return next
|
||||
}
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const sessionID = createMemo(() => params.id)
|
||||
const sessionMessages = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return emptyMessages
|
||||
return sync.data.message[id] ?? emptyMessages
|
||||
})
|
||||
const pending = createMemo(() =>
|
||||
sessionMessages().findLast(
|
||||
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
|
||||
),
|
||||
)
|
||||
const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle")
|
||||
const activeMessageID = createMemo(() => {
|
||||
const messages = sessionMessages()
|
||||
const message = pending()
|
||||
if (message?.parentID) {
|
||||
const result = Binary.search(messages, message.parentID, (item) => item.id)
|
||||
const parent = result.found ? messages[result.index] : messages.find((item) => item.id === message.parentID)
|
||||
if (parent?.role === "user") return parent.id
|
||||
}
|
||||
|
||||
if (sessionStatus() === "idle") return undefined
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === "user") return messages[i].id
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
const info = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
return sync.session.get(id)
|
||||
})
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleValue = createMemo(() => {
|
||||
const title = info()?.title
|
||||
if (!title) return
|
||||
if (isDefaultSessionTitle(title)) return language.t("command.session.new")
|
||||
return title
|
||||
})
|
||||
const defaultTitle = createMemo(() => isDefaultSessionTitle(info()?.title))
|
||||
const headerTitle = createMemo(
|
||||
() => titleValue() ?? (props.renderedUserMessages.length ? language.t("command.session.new") : undefined),
|
||||
)
|
||||
const placeholderTitle = createMemo(() => defaultTitle() || (!info()?.title && props.renderedUserMessages.length > 0))
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||
const showHeader = createMemo(() => !!(headerTitle() || parentID()))
|
||||
const stageCfg = { init: 1, batch: 3 }
|
||||
const staging = createTimelineStaging({
|
||||
sessionKey,
|
||||
turnStart: () => props.turnStart,
|
||||
messages: () => props.renderedUserMessages,
|
||||
config: stageCfg,
|
||||
})
|
||||
const rendered = createMemo(() => staging.messages().map((message) => message.id))
|
||||
|
||||
const [title, setTitle] = createStore({
|
||||
draft: "",
|
||||
@@ -135,17 +333,168 @@ export function MessageTimeline(props: {
|
||||
menuOpen: false,
|
||||
pendingRename: false,
|
||||
})
|
||||
const [headerText, setHeaderText] = createStore({
|
||||
session: sessionKey(),
|
||||
value: headerTitle(),
|
||||
prev: undefined as string | undefined,
|
||||
muted: placeholderTitle(),
|
||||
prevMuted: false,
|
||||
})
|
||||
let titleFrame: number | undefined
|
||||
let headerAnim: AnimationPlaybackControls | undefined
|
||||
let enterAnim: AnimationPlaybackControls | undefined
|
||||
let leaveAnim: AnimationPlaybackControls | undefined
|
||||
let titleRef: HTMLInputElement | undefined
|
||||
let headerRef: HTMLDivElement | undefined
|
||||
let enterRef: HTMLSpanElement | undefined
|
||||
let leaveRef: HTMLSpanElement | undefined
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
const data = (err as { data?: { message?: string } }).data
|
||||
if (data?.message) return data.message
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return language.t("common.requestFailed")
|
||||
const clearHeaderAnim = () => {
|
||||
headerAnim?.stop()
|
||||
headerAnim = undefined
|
||||
}
|
||||
|
||||
const animateHeader = () => {
|
||||
const el = headerRef
|
||||
if (!el) return
|
||||
|
||||
clearHeaderAnim()
|
||||
// Only animate for new sessions (placeholder title); snap for existing sessions
|
||||
if (headerText.muted) {
|
||||
headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 })
|
||||
headerAnim.finished.then(() => {
|
||||
if (headerRef !== el) return
|
||||
clearFadeStyles(el)
|
||||
})
|
||||
} else {
|
||||
el.style.opacity = "1"
|
||||
}
|
||||
}
|
||||
|
||||
const clearTitleAnims = () => {
|
||||
if (titleFrame !== undefined) {
|
||||
cancelAnimationFrame(titleFrame)
|
||||
titleFrame = undefined
|
||||
}
|
||||
enterAnim?.stop()
|
||||
enterAnim = undefined
|
||||
leaveAnim?.stop()
|
||||
leaveAnim = undefined
|
||||
}
|
||||
|
||||
const settleTitleEnter = () => {
|
||||
if (enterRef) clearFadeStyles(enterRef)
|
||||
}
|
||||
|
||||
const hideLeave = () => {
|
||||
if (!leaveRef) return
|
||||
leaveRef.style.opacity = "0"
|
||||
leaveRef.style.filter = ""
|
||||
leaveRef.style.transform = ""
|
||||
}
|
||||
|
||||
const animateEnterSpan = () => {
|
||||
if (!enterRef) return
|
||||
enterAnim = animate(
|
||||
enterRef,
|
||||
{ opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] },
|
||||
FAST_SPRING,
|
||||
)
|
||||
enterAnim.finished.then(() => settleTitleEnter())
|
||||
}
|
||||
|
||||
const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => {
|
||||
clearTitleAnims()
|
||||
|
||||
// snapshot old text into leave span before updating store
|
||||
setHeaderText({ prev: headerText.value, prevMuted: headerText.muted })
|
||||
|
||||
// update to new text
|
||||
setHeaderText({ value: nextTitle, muted: nextMuted })
|
||||
|
||||
// fade out leave span
|
||||
if (leaveRef) {
|
||||
leaveAnim = animate(
|
||||
leaveRef,
|
||||
{ opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] },
|
||||
FAST_SPRING,
|
||||
)
|
||||
leaveAnim.finished.then(() => {
|
||||
setHeaderText({ prev: undefined, prevMuted: false })
|
||||
hideLeave()
|
||||
})
|
||||
}
|
||||
|
||||
animateEnterSpan()
|
||||
}
|
||||
|
||||
const fadeInTitle = (nextTitle: string, nextMuted: boolean) => {
|
||||
clearTitleAnims()
|
||||
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
|
||||
animateEnterSpan()
|
||||
}
|
||||
|
||||
const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => {
|
||||
clearTitleAnims()
|
||||
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
|
||||
settleTitleEnter()
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(showHeader, (show, prev) => {
|
||||
if (!show) {
|
||||
clearHeaderAnim()
|
||||
return
|
||||
}
|
||||
|
||||
if (show === prev) return
|
||||
animateHeader()
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [sessionKey(), headerTitle(), placeholderTitle()] as const,
|
||||
([nextSession, nextTitle, nextMuted]) => {
|
||||
// new session — snap immediately
|
||||
if (nextSession !== headerText.session) {
|
||||
setHeaderText("session", nextSession)
|
||||
if (nextTitle && nextMuted) {
|
||||
fadeInTitle(nextTitle, nextMuted)
|
||||
} else {
|
||||
snapTitle(nextTitle, nextMuted)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (nextTitle === headerText.value && nextMuted === headerText.muted) {
|
||||
return
|
||||
}
|
||||
if (!nextTitle) {
|
||||
snapTitle(undefined, false)
|
||||
return
|
||||
}
|
||||
// first title appearing
|
||||
if (!headerText.value) {
|
||||
fadeInTitle(nextTitle, nextMuted)
|
||||
return
|
||||
}
|
||||
// manual rename — snap
|
||||
if (title.saving || title.editing) {
|
||||
snapTitle(nextTitle, nextMuted)
|
||||
return
|
||||
}
|
||||
// normal swap — crossfade
|
||||
crossfadeTitle(nextTitle, nextMuted)
|
||||
},
|
||||
),
|
||||
)
|
||||
onCleanup(() => {
|
||||
clearHeaderAnim()
|
||||
clearTitleAnims()
|
||||
})
|
||||
|
||||
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
sessionKey,
|
||||
@@ -195,7 +544,7 @@ export function MessageTimeline(props: {
|
||||
setTitle("saving", false)
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -235,7 +584,7 @@ export function MessageTimeline(props: {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -254,7 +603,7 @@ export function MessageTimeline(props: {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("session.delete.failed.title"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
return false
|
||||
})
|
||||
@@ -343,8 +692,10 @@ export function MessageTimeline(props: {
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && !props.scroll.bottom,
|
||||
"opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || props.scroll.bottom,
|
||||
"opacity-100 translate-y-0 scale-100":
|
||||
props.scroll.overflow && !props.scroll.bottom && !staging.isStaging(),
|
||||
"opacity-0 translate-y-2 scale-95 pointer-events-none":
|
||||
!props.scroll.overflow || props.scroll.bottom || staging.isStaging(),
|
||||
}}
|
||||
>
|
||||
<button
|
||||
@@ -354,88 +705,54 @@ export function MessageTimeline(props: {
|
||||
<Icon name="arrow-down-to-line" />
|
||||
</button>
|
||||
</div>
|
||||
<ScrollView
|
||||
viewportRef={props.setScrollRef}
|
||||
onWheel={(e) => {
|
||||
const root = e.currentTarget
|
||||
const delta = normalizeWheelDelta({
|
||||
deltaY: e.deltaY,
|
||||
deltaMode: e.deltaMode,
|
||||
rootHeight: root.clientHeight,
|
||||
})
|
||||
if (!delta) return
|
||||
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
touchGesture = e.touches[0]?.clientY
|
||||
}}
|
||||
onTouchMove={(e) => {
|
||||
const next = e.touches[0]?.clientY
|
||||
const prev = touchGesture
|
||||
touchGesture = next
|
||||
if (next === undefined || prev === undefined) return
|
||||
|
||||
const delta = prev - next
|
||||
if (!delta) return
|
||||
|
||||
const root = e.currentTarget
|
||||
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
|
||||
}}
|
||||
onTouchEnd={() => {
|
||||
touchGesture = undefined
|
||||
}}
|
||||
onTouchCancel={() => {
|
||||
touchGesture = undefined
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
props.onMarkScrollGesture(e.currentTarget)
|
||||
}}
|
||||
onScroll={(e) => {
|
||||
props.onScheduleScrollState(e.currentTarget)
|
||||
if (!props.hasScrollGesture()) return
|
||||
props.onAutoScrollHandleScroll()
|
||||
props.onMarkScrollGesture(e.currentTarget)
|
||||
if (props.isDesktop) props.onScrollSpyScroll()
|
||||
}}
|
||||
onClick={props.onAutoScrollInteraction}
|
||||
class="relative min-w-0 w-full h-full"
|
||||
style={{
|
||||
"--session-title-height": showHeader() ? "40px" : "0px",
|
||||
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
||||
}}
|
||||
>
|
||||
<Show when={showHeader()}>
|
||||
<Show when={showHeader()}>
|
||||
<div
|
||||
data-session-title
|
||||
ref={(el) => {
|
||||
headerRef = el
|
||||
el.style.opacity = "0"
|
||||
}}
|
||||
class="pointer-events-none absolute inset-x-0 top-0 z-30"
|
||||
>
|
||||
<div
|
||||
data-session-title
|
||||
classList={{
|
||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||
"bg-[linear-gradient(to_bottom,var(--background-stronger)_38px,transparent)]": true,
|
||||
"w-full": true,
|
||||
"pb-4": true,
|
||||
"pb-10": true,
|
||||
"pl-2 pr-3 md:pl-4 md:pr-3": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="pointer-events-auto h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
||||
<Show when={parentID()}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={navigateParent}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
<div>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={navigateParent}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={titleValue() || title.editing}>
|
||||
<Show when={!!headerText.value || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{titleValue()}
|
||||
<h1 class="text-14-medium text-text-strong grow-1 min-w-0 pl-2" onDblClick={openTitleEditor}>
|
||||
<span class="grid min-w-0" style={{ overflow: "clip" }}>
|
||||
<span ref={enterRef} class="col-start-1 row-start-1 min-w-0 truncate">
|
||||
<span classList={{ "opacity-60": headerText.muted }}>{headerText.value}</span>
|
||||
</span>
|
||||
<span
|
||||
ref={leaveRef}
|
||||
class="col-start-1 row-start-1 min-w-0 truncate pointer-events-none"
|
||||
style={{ opacity: "0" }}
|
||||
>
|
||||
<span classList={{ "opacity-60": headerText.prevMuted }}>{headerText.prev}</span>
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
@@ -517,104 +834,194 @@ export function MessageTimeline(props: {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<ScrollView
|
||||
viewportRef={props.setScrollRef}
|
||||
onWheel={(e) => {
|
||||
const root = e.currentTarget
|
||||
const delta = normalizeWheelDelta({
|
||||
deltaY: e.deltaY,
|
||||
deltaMode: e.deltaMode,
|
||||
rootHeight: root.clientHeight,
|
||||
})
|
||||
if (!delta) return
|
||||
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
touchGesture = e.touches[0]?.clientY
|
||||
}}
|
||||
onTouchMove={(e) => {
|
||||
const next = e.touches[0]?.clientY
|
||||
const prev = touchGesture
|
||||
touchGesture = next
|
||||
if (next === undefined || prev === undefined) return
|
||||
|
||||
<div
|
||||
ref={props.setContentRef}
|
||||
role="log"
|
||||
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
"mt-0.5": props.centered,
|
||||
"mt-0": !props.centered,
|
||||
}}
|
||||
>
|
||||
<Show when={props.turnStart > 0}>
|
||||
<div class="w-full flex justify-center">
|
||||
<Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}>
|
||||
{language.t("session.messages.renderEarlier")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.historyMore}>
|
||||
<div class="w-full flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
class="text-12-medium opacity-50"
|
||||
disabled={props.historyLoading}
|
||||
onClick={props.onLoadEarlier}
|
||||
>
|
||||
{props.historyLoading
|
||||
? language.t("session.messages.loadingEarlier")
|
||||
: language.t("session.messages.loadEarlier")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={props.renderedUserMessages}>
|
||||
{(message) => {
|
||||
const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? []))
|
||||
return (
|
||||
<div
|
||||
id={props.anchor(message.id)}
|
||||
data-message-id={message.id}
|
||||
ref={(el) => {
|
||||
props.onRegisterMessage(el, message.id)
|
||||
onCleanup(() => props.onUnregisterMessage(message.id))
|
||||
}}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
const delta = prev - next
|
||||
if (!delta) return
|
||||
|
||||
const root = e.currentTarget
|
||||
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
|
||||
}}
|
||||
onTouchEnd={() => {
|
||||
touchGesture = undefined
|
||||
}}
|
||||
onTouchCancel={() => {
|
||||
touchGesture = undefined
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
const next = trigger(e.target)
|
||||
if (next) props.onPreserveScrollAnchor(next)
|
||||
|
||||
if (e.target !== e.currentTarget) return
|
||||
props.onMarkScrollGesture(e.currentTarget)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter" && e.key !== " ") return
|
||||
const next = trigger(e.target)
|
||||
if (!next) return
|
||||
props.onPreserveScrollAnchor(next)
|
||||
}}
|
||||
onScroll={(e) => {
|
||||
props.onScheduleScrollState(e.currentTarget)
|
||||
props.onTurnBackfillScroll()
|
||||
if (!props.hasScrollGesture()) return
|
||||
props.onAutoScrollHandleScroll()
|
||||
props.onMarkScrollGesture(e.currentTarget)
|
||||
if (props.isDesktop) props.onScrollSpyScroll()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const next = trigger(e.target)
|
||||
if (next) props.onPreserveScrollAnchor(next)
|
||||
props.onAutoScrollInteraction(e)
|
||||
}}
|
||||
class="relative min-w-0 w-full h-full"
|
||||
style={{
|
||||
"--session-title-height": showHeader() ? "40px" : "0px",
|
||||
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
ref={props.setContentRef}
|
||||
role="log"
|
||||
class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]"
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
"mt-0.5": props.centered,
|
||||
"mt-0": !props.centered,
|
||||
}}
|
||||
>
|
||||
<Show when={props.turnStart > 0 || props.historyMore}>
|
||||
<div class="w-full flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
class="text-12-medium opacity-50"
|
||||
disabled={props.historyLoading}
|
||||
onClick={props.onLoadEarlier}
|
||||
>
|
||||
<Show when={comments().length > 0}>
|
||||
<div class="w-full px-4 md:px-5 pb-2">
|
||||
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
<For each={comments()}>
|
||||
{(comment) => (
|
||||
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
||||
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
||||
<FileIcon node={{ path: comment.path, type: "file" }} class="size-3.5 shrink-0" />
|
||||
<span class="truncate">{getFilename(comment.path)}</span>
|
||||
<Show when={comment.selection}>
|
||||
{(selection) => (
|
||||
<span class="shrink-0 text-text-weak">
|
||||
{selection().startLine === selection().endLine
|
||||
? `:${selection().startLine}`
|
||||
: `:${selection().startLine}-${selection().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
|
||||
{comment.comment}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
{props.historyLoading
|
||||
? language.t("session.messages.loadingEarlier")
|
||||
: language.t("session.messages.loadEarlier")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={rendered()}>
|
||||
{(messageID) => {
|
||||
// Capture at creation time: animate only messages added after the
|
||||
// timeline finishes its initial backfill staging, plus the first
|
||||
// turn while a brand new session is still using its default title.
|
||||
const isNew =
|
||||
staging.ready() ||
|
||||
(defaultTitle() &&
|
||||
sessionStatus() !== "idle" &&
|
||||
props.renderedUserMessages.length === 1 &&
|
||||
messageID === props.renderedUserMessages[0]?.id)
|
||||
const active = createMemo(() => activeMessageID() === messageID)
|
||||
const queued = createMemo(() => {
|
||||
if (active()) return false
|
||||
const activeID = activeMessageID()
|
||||
if (activeID) return messageID > activeID
|
||||
return false
|
||||
})
|
||||
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
|
||||
equals: (a, b) => {
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((x, i) => x.path === b[i].path && x.comment === b[i].comment)
|
||||
},
|
||||
})
|
||||
const commentCount = createMemo(() => comments().length)
|
||||
return (
|
||||
<div
|
||||
id={props.anchor(messageID)}
|
||||
data-message-id={messageID}
|
||||
ref={(el) => {
|
||||
props.onRegisterMessage(el, messageID)
|
||||
onCleanup(() => props.onUnregisterMessage(messageID))
|
||||
}}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<Show when={commentCount() > 0}>
|
||||
<div class="w-full px-4 md:px-5 pb-2">
|
||||
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
<Index each={comments()}>
|
||||
{(commentAccessor: () => MessageComment) => {
|
||||
const comment = createMemo(() => commentAccessor())
|
||||
return (
|
||||
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
||||
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
||||
<FileIcon
|
||||
node={{ path: comment().path, type: "file" }}
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{getFilename(comment().path)}</span>
|
||||
<Show when={comment().selection}>
|
||||
{(selection) => (
|
||||
<span class="shrink-0 text-text-weak">
|
||||
{selection().startLine === selection().endLine
|
||||
? `:${selection().startLine}`
|
||||
: `:${selection().startLine}-${selection().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
|
||||
{comment().comment}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<SessionTurn
|
||||
sessionID={sessionID() ?? ""}
|
||||
messageID={message.id}
|
||||
lastUserMessageID={props.lastUserMessageID}
|
||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
|
||||
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
container: "w-full px-4 md:px-5",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
<SessionTurn
|
||||
sessionID={sessionID() ?? ""}
|
||||
messageID={messageID}
|
||||
active={active()}
|
||||
queued={queued()}
|
||||
animate={isNew || active()}
|
||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
|
||||
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
container: "w-full px-4 md:px-5",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, on, onCleanup, type JSX } from "solid-js"
|
||||
import { createEffect, onCleanup, type JSX } from "solid-js"
|
||||
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||
import type {
|
||||
@@ -119,32 +119,12 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.diffs().length,
|
||||
() => queueRestore(),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.diffStyle,
|
||||
() => queueRestore(),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => layout.ready(),
|
||||
(ready) => {
|
||||
if (!ready) return
|
||||
queueRestore()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
createEffect(() => {
|
||||
props.diffs().length
|
||||
props.diffStyle
|
||||
if (!layout.ready()) return
|
||||
queueRestore()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
||||
@@ -176,7 +156,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
open={props.view().review.open()}
|
||||
onOpenChange={props.view().review.setOpen}
|
||||
classes={{
|
||||
root: props.classes?.root ?? "pb-6 pr-3",
|
||||
root: props.classes?.root ?? "pr-3",
|
||||
header: props.classes?.header ?? "px-3",
|
||||
container: props.classes?.container ?? "pl-3",
|
||||
}}
|
||||
|
||||
@@ -56,9 +56,9 @@ export function TerminalPanel() {
|
||||
on(
|
||||
() => terminal.all().length,
|
||||
(count, prevCount) => {
|
||||
if (prevCount !== undefined && prevCount > 0 && count === 0) {
|
||||
if (opened()) view().terminal.toggle()
|
||||
}
|
||||
if (prevCount === undefined || prevCount <= 0 || count !== 0) return
|
||||
if (!opened()) return
|
||||
close()
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -102,7 +102,7 @@ export function TerminalPanel() {
|
||||
|
||||
const all = createMemo(() => terminal.all())
|
||||
const ids = createMemo(() => all().map((pty) => pty.id))
|
||||
const byId = createMemo(() => new Map(all().map((pty) => [pty.id, pty])))
|
||||
const byId = createMemo(() => new Map(all().map((pty) => [pty.id, { ...pty }])))
|
||||
|
||||
const handleTerminalDragStart = (event: unknown) => {
|
||||
const id = getDraggableId(event)
|
||||
@@ -189,7 +189,13 @@ export function TerminalPanel() {
|
||||
>
|
||||
<Tabs.List class="h-10">
|
||||
<SortableProvider ids={ids()}>
|
||||
<For each={all()}>{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}</For>
|
||||
<For each={ids()}>
|
||||
{(id) => (
|
||||
<Show when={byId().get(id)}>
|
||||
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<TooltipKeybind
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export const messageIdFromHash = (hash: string) => {
|
||||
@@ -19,8 +19,7 @@ export const useSessionHashScroll = (input: {
|
||||
setPendingMessage: (value: string | undefined) => void
|
||||
setActiveMessage: (message: UserMessage | undefined) => void
|
||||
setTurnStart: (value: number) => void
|
||||
scheduleTurnBackfill: () => void
|
||||
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
|
||||
autoScroll: { pause: () => void; snapToBottom: () => void }
|
||||
scroller: () => HTMLDivElement | undefined
|
||||
anchor: (id: string) => string
|
||||
scheduleScrollState: (el: HTMLDivElement) => void
|
||||
@@ -29,6 +28,7 @@ export const useSessionHashScroll = (input: {
|
||||
const visibleUserMessages = createMemo(() => input.visibleUserMessages())
|
||||
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
|
||||
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
|
||||
let pendingKey = ""
|
||||
|
||||
const clearMessageHash = () => {
|
||||
if (!window.location.hash) return
|
||||
@@ -45,9 +45,10 @@ export const useSessionHashScroll = (input: {
|
||||
|
||||
const a = el.getBoundingClientRect()
|
||||
const b = root.getBoundingClientRect()
|
||||
const sticky = root.querySelector("[data-session-title]")
|
||||
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
|
||||
const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
|
||||
const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
|
||||
const inset = Number.isNaN(title) ? 0 : title
|
||||
// With column-reverse, scrollTop is negative — don't clamp to 0
|
||||
const top = a.top - b.top + root.scrollTop - inset
|
||||
root.scrollTo({ top, behavior })
|
||||
return true
|
||||
}
|
||||
@@ -58,7 +59,6 @@ export const useSessionHashScroll = (input: {
|
||||
const index = messageIndex().get(message.id) ?? -1
|
||||
if (index !== -1 && index < input.turnStart()) {
|
||||
input.setTurnStart(index)
|
||||
input.scheduleTurnBackfill()
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(input.anchor(message.id))
|
||||
@@ -103,7 +103,7 @@ export const useSessionHashScroll = (input: {
|
||||
const applyHash = (behavior: ScrollBehavior) => {
|
||||
const hash = window.location.hash.slice(1)
|
||||
if (!hash) {
|
||||
input.autoScroll.forceScrollToBottom()
|
||||
input.autoScroll.snapToBottom()
|
||||
const el = input.scroller()
|
||||
if (el) input.scheduleScrollState(el)
|
||||
return
|
||||
@@ -127,20 +127,11 @@ export const useSessionHashScroll = (input: {
|
||||
return
|
||||
}
|
||||
|
||||
input.autoScroll.forceScrollToBottom()
|
||||
input.autoScroll.snapToBottom()
|
||||
const el = input.scroller()
|
||||
if (el) input.scheduleScrollState(el)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(input.sessionKey, (key) => {
|
||||
if (!input.sessionID()) return
|
||||
const messageID = input.consumePendingMessage(key)
|
||||
if (!messageID) return
|
||||
input.setPendingMessage(messageID)
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (!input.sessionID() || !input.messagesReady()) return
|
||||
requestAnimationFrame(() => applyHash("auto"))
|
||||
@@ -152,7 +143,20 @@ export const useSessionHashScroll = (input: {
|
||||
visibleUserMessages()
|
||||
input.turnStart()
|
||||
|
||||
const targetId = input.pendingMessage() ?? messageIdFromHash(window.location.hash)
|
||||
let targetId = input.pendingMessage()
|
||||
if (!targetId) {
|
||||
const key = input.sessionKey()
|
||||
if (pendingKey !== key) {
|
||||
pendingKey = key
|
||||
const next = input.consumePendingMessage(key)
|
||||
if (next) {
|
||||
input.setPendingMessage(next)
|
||||
targetId = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetId) targetId = messageIdFromHash(window.location.hash)
|
||||
if (!targetId) return
|
||||
if (input.currentMessageId() === targetId) return
|
||||
|
||||
@@ -164,9 +168,16 @@ export const useSessionHashScroll = (input: {
|
||||
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!input.sessionID() || !input.messagesReady()) return
|
||||
const handler = () => requestAnimationFrame(() => applyHash("auto"))
|
||||
onMount(() => {
|
||||
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
|
||||
window.history.scrollRestoration = "manual"
|
||||
}
|
||||
|
||||
const handler = () => {
|
||||
if (!input.sessionID() || !input.messagesReady()) return
|
||||
requestAnimationFrame(() => applyHash("auto"))
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", handler)
|
||||
onCleanup(() => window.removeEventListener("hashchange", handler))
|
||||
})
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
export function isEditableTarget(target: EventTarget | null | undefined) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (/^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName)) return true
|
||||
if (target.isContentEditable) return true
|
||||
if (target.closest("[contenteditable='true']")) return true
|
||||
if (target.closest("input, textarea, select")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function getCharacterOffsetInLine(lineElement: Element, targetNode: Node, offset: number): number {
|
||||
const r = document.createRange()
|
||||
r.selectNodeContents(lineElement)
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { handleNotificationClick } from "./notification-click"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { handleNotificationClick, setNavigate } from "./notification-click"
|
||||
|
||||
describe("notification click", () => {
|
||||
test("focuses and navigates when href exists", () => {
|
||||
const calls: string[] = []
|
||||
handleNotificationClick("/abc/session/123", {
|
||||
focus: () => calls.push("focus"),
|
||||
location: {
|
||||
assign: (href) => calls.push(href),
|
||||
},
|
||||
})
|
||||
expect(calls).toEqual(["focus", "/abc/session/123"])
|
||||
afterEach(() => {
|
||||
setNavigate(undefined as any)
|
||||
})
|
||||
|
||||
test("only focuses when href is missing", () => {
|
||||
test("navigates via registered navigate function", () => {
|
||||
const calls: string[] = []
|
||||
handleNotificationClick(undefined, {
|
||||
focus: () => calls.push("focus"),
|
||||
location: {
|
||||
assign: (href) => calls.push(href),
|
||||
},
|
||||
})
|
||||
expect(calls).toEqual(["focus"])
|
||||
setNavigate((href) => calls.push(href))
|
||||
handleNotificationClick("/abc/session/123")
|
||||
expect(calls).toEqual(["/abc/session/123"])
|
||||
})
|
||||
|
||||
test("does not navigate when href is missing", () => {
|
||||
const calls: string[] = []
|
||||
setNavigate((href) => calls.push(href))
|
||||
handleNotificationClick(undefined)
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
test("falls back to location.assign without registered navigate", () => {
|
||||
handleNotificationClick("/abc/session/123")
|
||||
// falls back to window.location.assign — no error thrown
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
type WindowTarget = {
|
||||
focus: () => void
|
||||
location: {
|
||||
assign: (href: string) => void
|
||||
}
|
||||
let nav: ((href: string) => void) | undefined
|
||||
|
||||
export const setNavigate = (fn: (href: string) => void) => {
|
||||
nav = fn
|
||||
}
|
||||
|
||||
export const handleNotificationClick = (href?: string, target: WindowTarget = window) => {
|
||||
target.focus()
|
||||
export const handleNotificationClick = (href?: string) => {
|
||||
window.focus()
|
||||
if (!href) return
|
||||
target.location.assign(href)
|
||||
if (nav) nav(href)
|
||||
else window.location.assign(href)
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return false
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((x, i) => x === b[i])
|
||||
}
|
||||
@@ -19,4 +19,4 @@ export function getRelativeTime(dateString: string, t: Translate): string {
|
||||
if (diffMinutes < 60) return t("common.time.minutesAgo.short", { count: diffMinutes })
|
||||
if (diffHours < 24) return t("common.time.hoursAgo.short", { count: diffHours })
|
||||
return t("common.time.daysAgo.short", { count: diffDays })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.15",
|
||||
"version": "1.2.17",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -26,6 +26,7 @@ async function getMainRoutes(): Promise<SitemapEntry[]> {
|
||||
{ path: "/enterprise", priority: 0.8, changefreq: "weekly" },
|
||||
{ path: "/brand", priority: 0.6, changefreq: "monthly" },
|
||||
{ path: "/zen", priority: 0.8, changefreq: "weekly" },
|
||||
{ path: "/go", priority: 0.8, changefreq: "weekly" },
|
||||
]
|
||||
|
||||
for (const item of staticRoutes) {
|
||||
|
||||
@@ -7,9 +7,21 @@ import { Font } from "@opencode-ai/ui/font"
|
||||
import "@ibm/plex/css/ibm-plex.css"
|
||||
import "./app.css"
|
||||
import { LanguageProvider } from "~/context/language"
|
||||
import { I18nProvider } from "~/context/i18n"
|
||||
import { I18nProvider, useI18n } from "~/context/i18n"
|
||||
import { strip } from "~/lib/language"
|
||||
|
||||
function AppMeta() {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<>
|
||||
<Title>opencode</Title>
|
||||
<Meta name="description" content={i18n.t("app.meta.description")} />
|
||||
<Favicon />
|
||||
<Font />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Router
|
||||
@@ -19,10 +31,7 @@ export default function App() {
|
||||
<LanguageProvider>
|
||||
<I18nProvider>
|
||||
<MetaProvider>
|
||||
<Title>opencode</Title>
|
||||
<Meta name="description" content="OpenCode - The open source coding agent." />
|
||||
<Favicon />
|
||||
<Font />
|
||||
<AppMeta />
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</MetaProvider>
|
||||
</I18nProvider>
|
||||
|
||||
6
packages/console/app/src/asset/go-ornate-dark.svg
Normal file
6
packages/console/app/src/asset/go-ornate-dark.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="54" height="30" viewBox="0 0 54 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24 30H0V0H24V6H6V24H18V18H12V12H24V30Z" fill="#F1ECEC"/>
|
||||
<path d="M12 18H18V24H6V12H12V18Z" fill="#4B4646"/>
|
||||
<path d="M48 12V24H36V12H48Z" fill="#4B4646"/>
|
||||
<path d="M54 30H30V0H54V30ZM36 24H48V6H36V24Z" fill="#F1ECEC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 333 B |
6
packages/console/app/src/asset/go-ornate-light.svg
Normal file
6
packages/console/app/src/asset/go-ornate-light.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="54" height="30" viewBox="0 0 54 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24 30H0V0H24V6H6V24H18V18H12V12H24V30Z" fill="#211E1E"/>
|
||||
<path d="M12 18H18V24H6V12H12V18Z" fill="#CFCECD"/>
|
||||
<path d="M48 12V24H36V12H48Z" fill="#CFCECD"/>
|
||||
<path d="M54 30H30V0H54V30ZM36 24H48V6H36V24Z" fill="#211E1E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 333 B |
@@ -36,7 +36,7 @@ const fetchSvgContent = async (svgPath: string): Promise<string> => {
|
||||
}
|
||||
}
|
||||
|
||||
export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
|
||||
export function Header(props: { zen?: boolean; go?: boolean; hideGetStarted?: boolean }) {
|
||||
const navigate = useNavigate()
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
@@ -124,8 +124,8 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
|
||||
<section data-component="top">
|
||||
<div onContextMenu={handleLogoContextMenu}>
|
||||
<A href={language.route("/")}>
|
||||
<img data-slot="logo light" src={logoLight} alt="OpenCode" width="189" height="34" />
|
||||
<img data-slot="logo dark" src={logoDark} alt="OpenCode" width="189" height="34" />
|
||||
<img data-slot="logo light" src={logoLight} alt={i18n.t("nav.logoAlt")} width="189" height="34" />
|
||||
<img data-slot="logo dark" src={logoDark} alt={i18n.t("nav.logoAlt")} width="189" height="34" />
|
||||
</A>
|
||||
</div>
|
||||
|
||||
@@ -161,19 +161,24 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
|
||||
<li>
|
||||
<a href={language.route("/docs")}>{i18n.t("nav.docs")}</a>
|
||||
</li>
|
||||
<Show when={!props.zen}>
|
||||
<li>
|
||||
<A href={language.route("/zen")}>{i18n.t("nav.zen")}</A>
|
||||
</li>
|
||||
</Show>
|
||||
<Show when={!props.go}>
|
||||
<li>
|
||||
<A href={language.route("/go")}>{i18n.t("nav.go")}</A>
|
||||
</li>
|
||||
</Show>
|
||||
<li>
|
||||
<A href={language.route("/enterprise")}>{i18n.t("nav.enterprise")}</A>
|
||||
</li>
|
||||
<li>
|
||||
<Switch>
|
||||
<Match when={props.zen}>
|
||||
<a href="/auth">{i18n.t("nav.login")}</a>
|
||||
</Match>
|
||||
<Match when={!props.zen}>
|
||||
<A href={language.route("/zen")}>{i18n.t("nav.zen")}</A>
|
||||
</Match>
|
||||
</Switch>
|
||||
</li>
|
||||
<Show when={props.zen || props.go}>
|
||||
<li>
|
||||
<a href="/auth">{i18n.t("nav.login")}</a>
|
||||
</li>
|
||||
</Show>
|
||||
<Show when={!props.hideGetStarted}>
|
||||
<li>
|
||||
<A href={language.route("/download")} data-slot="cta-button">
|
||||
@@ -257,19 +262,24 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
|
||||
<li>
|
||||
<a href={language.route("/docs")}>{i18n.t("nav.docs")}</a>
|
||||
</li>
|
||||
<Show when={!props.zen}>
|
||||
<li>
|
||||
<A href={language.route("/zen")}>{i18n.t("nav.zen")}</A>
|
||||
</li>
|
||||
</Show>
|
||||
<Show when={!props.go}>
|
||||
<li>
|
||||
<A href={language.route("/go")}>{i18n.t("nav.go")}</A>
|
||||
</li>
|
||||
</Show>
|
||||
<li>
|
||||
<A href={language.route("/enterprise")}>{i18n.t("nav.enterprise")}</A>
|
||||
</li>
|
||||
<li>
|
||||
<Switch>
|
||||
<Match when={props.zen}>
|
||||
<a href="/auth">{i18n.t("nav.login")}</a>
|
||||
</Match>
|
||||
<Match when={!props.zen}>
|
||||
<A href={language.route("/zen")}>{i18n.t("nav.zen")}</A>
|
||||
</Match>
|
||||
</Switch>
|
||||
</li>
|
||||
<Show when={props.zen || props.go}>
|
||||
<li>
|
||||
<a href="/auth">{i18n.t("nav.login")}</a>
|
||||
</li>
|
||||
</Show>
|
||||
<Show when={!props.hideGetStarted}>
|
||||
<li>
|
||||
<A href={language.route("/download")} data-slot="cta-button">
|
||||
|
||||
@@ -15,6 +15,7 @@ export const dict = {
|
||||
"nav.home": "الرئيسية",
|
||||
"nav.openMenu": "فتح القائمة",
|
||||
"nav.getStartedFree": "ابدأ مجانا",
|
||||
"nav.logoAlt": "OpenCode",
|
||||
|
||||
"nav.context.copyLogo": "نسخ الشعار كـ SVG",
|
||||
"nav.context.copyWordmark": "نسخ اسم العلامة كـ SVG",
|
||||
@@ -42,9 +43,13 @@ export const dict = {
|
||||
"notFound.docs": "الوثائق",
|
||||
"notFound.github": "GitHub",
|
||||
"notFound.discord": "Discord",
|
||||
"notFound.logoLightAlt": "شعار opencode الفاتح",
|
||||
"notFound.logoDarkAlt": "شعار opencode الداكن",
|
||||
|
||||
"user.logout": "تسجيل الخروج",
|
||||
|
||||
"auth.callback.error.codeMissing": "لم يتم العثور على رمز التفويض.",
|
||||
|
||||
"workspace.select": "اختر مساحة العمل",
|
||||
"workspace.createNew": "+ إنشاء مساحة عمل جديدة",
|
||||
"workspace.modal.title": "إنشاء مساحة عمل جديدة",
|
||||
@@ -76,6 +81,8 @@ export const dict = {
|
||||
"error.reloadAmountMin": "يجب أن يكون مبلغ الشحن ${{amount}} على الأقل",
|
||||
"error.reloadTriggerMin": "يجب أن يكون حد الرصيد ${{amount}} على الأقل",
|
||||
|
||||
"app.meta.description": "OpenCode - وكيل البرمجة مفتوح المصدر.",
|
||||
|
||||
"home.title": "OpenCode | وكيل برمجة بالذكاء الاصطناعي مفتوح المصدر",
|
||||
|
||||
"temp.title": "opencode | وكيل برمجة بالذكاء الاصطناعي مبني للطرفية",
|
||||
@@ -91,6 +98,8 @@ export const dict = {
|
||||
"temp.feature.models.afterLink": "، بما في ذلك النماذج المحلية",
|
||||
"temp.screenshot.caption": "واجهة OpenCode الطرفية مع سمة tokyonight",
|
||||
"temp.screenshot.alt": "واجهة OpenCode الطرفية بسمة tokyonight",
|
||||
"temp.logoLightAlt": "شعار opencode الفاتح",
|
||||
"temp.logoDarkAlt": "شعار opencode الداكن",
|
||||
|
||||
"home.banner.badge": "جديد",
|
||||
"home.banner.text": "تطبيق سطح المكتب متاح بنسخة تجريبية",
|
||||
@@ -238,6 +247,24 @@ export const dict = {
|
||||
"تتم استضافة جميع نماذج Zen في الولايات المتحدة. يتبع المزودون سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج، مع",
|
||||
"zen.privacy.exceptionsLink": "الاستثناءات التالية",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.",
|
||||
"zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم",
|
||||
"zen.api.error.modelFormatNotSupported": "النموذج {{model}} غير مدعوم للتنسيق {{format}}",
|
||||
"zen.api.error.noProviderAvailable": "لا يوجد مزود متاح",
|
||||
"zen.api.error.providerNotSupported": "المزود {{provider}} غير مدعوم",
|
||||
"zen.api.error.missingApiKey": "مفتاح API مفقود.",
|
||||
"zen.api.error.invalidApiKey": "مفتاح API غير صالح.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "تم تجاوز حصة الاشتراك. أعد المحاولة خلال {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"تم تجاوز حصة الاشتراك. يمكنك الاستمرار في استخدام النماذج المجانية.",
|
||||
"zen.api.error.noPaymentMethod": "لا توجد طريقة دفع. أضف طريقة دفع هنا: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "رصيد غير كاف. إدارة فواتيرك هنا: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
"وصلت مساحة العمل الخاصة بك إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{billingUrl}}",
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "النموذج معطل",
|
||||
|
||||
"black.meta.title": "OpenCode Black | الوصول إلى أفضل نماذج البرمجة في العالم",
|
||||
"black.meta.description": "احصل على وصول إلى Claude، GPT، Gemini والمزيد مع خطط اشتراك OpenCode Black.",
|
||||
"black.hero.title": "الوصول إلى أفضل نماذج البرمجة في العالم",
|
||||
@@ -446,6 +473,7 @@ export const dict = {
|
||||
"workspace.reload.updatePaymentMethod": "يرجى تحديث طريقة الدفع والمحاولة مرة أخرى.",
|
||||
"workspace.reload.retrying": "جارٍ إعادة المحاولة...",
|
||||
"workspace.reload.retry": "أعد المحاولة",
|
||||
"workspace.reload.error.paymentFailed": "فشلت عملية الدفع.",
|
||||
|
||||
"workspace.payments.title": "سجل المدفوعات",
|
||||
"workspace.payments.subtitle": "معاملات الدفع الأخيرة.",
|
||||
@@ -563,6 +591,10 @@ export const dict = {
|
||||
"enterprise.form.send": "إرسال",
|
||||
"enterprise.form.sending": "جارٍ الإرسال...",
|
||||
"enterprise.form.success": "تم إرسال الرسالة، سنتواصل معك قريبًا.",
|
||||
"enterprise.form.success.submitted": "تم إرسال النموذج بنجاح.",
|
||||
"enterprise.form.error.allFieldsRequired": "جميع الحقول مطلوبة.",
|
||||
"enterprise.form.error.invalidEmailFormat": "تنسيق البريد الإلكتروني غير صالح.",
|
||||
"enterprise.form.error.internalServer": "خطأ داخلي في الخادم.",
|
||||
"enterprise.faq.title": "الأسئلة الشائعة",
|
||||
"enterprise.faq.q1": "ما هو OpenCode Enterprise؟",
|
||||
"enterprise.faq.a1":
|
||||
@@ -595,6 +627,7 @@ export const dict = {
|
||||
"bench.list.table.agent": "الوكيل",
|
||||
"bench.list.table.model": "النموذج",
|
||||
"bench.list.table.score": "الدرجة",
|
||||
"bench.submission.error.allFieldsRequired": "جميع الحقول مطلوبة.",
|
||||
|
||||
"bench.detail.title": "المعيار - {{task}}",
|
||||
"bench.detail.notFound": "المهمة غير موجودة",
|
||||
|
||||
@@ -15,6 +15,7 @@ export const dict = {
|
||||
"nav.home": "Início",
|
||||
"nav.openMenu": "Abrir menu",
|
||||
"nav.getStartedFree": "Começar grátis",
|
||||
"nav.logoAlt": "OpenCode",
|
||||
|
||||
"nav.context.copyLogo": "Copiar logo como SVG",
|
||||
"nav.context.copyWordmark": "Copiar marca como SVG",
|
||||
@@ -42,9 +43,13 @@ export const dict = {
|
||||
"notFound.docs": "Documentação",
|
||||
"notFound.github": "GitHub",
|
||||
"notFound.discord": "Discord",
|
||||
"notFound.logoLightAlt": "logo opencode claro",
|
||||
"notFound.logoDarkAlt": "logo opencode escuro",
|
||||
|
||||
"user.logout": "Sair",
|
||||
|
||||
"auth.callback.error.codeMissing": "Nenhum código de autorização encontrado.",
|
||||
|
||||
"workspace.select": "Selecionar workspace",
|
||||
"workspace.createNew": "+ Criar novo workspace",
|
||||
"workspace.modal.title": "Criar novo workspace",
|
||||
@@ -76,6 +81,8 @@ export const dict = {
|
||||
"error.reloadAmountMin": "O valor de recarga deve ser de pelo menos ${{amount}}",
|
||||
"error.reloadTriggerMin": "O gatilho de saldo deve ser de pelo menos ${{amount}}",
|
||||
|
||||
"app.meta.description": "OpenCode - O agente de codificação de código aberto.",
|
||||
|
||||
"home.title": "OpenCode | O agente de codificação de código aberto com IA",
|
||||
|
||||
"temp.title": "opencode | Agente de codificação com IA feito para o terminal",
|
||||
@@ -91,6 +98,8 @@ export const dict = {
|
||||
"temp.feature.models.afterLink": ", incluindo modelos locais",
|
||||
"temp.screenshot.caption": "OpenCode TUI com o tema tokyonight",
|
||||
"temp.screenshot.alt": "OpenCode TUI com tema tokyonight",
|
||||
"temp.logoLightAlt": "logo opencode claro",
|
||||
"temp.logoDarkAlt": "logo opencode escuro",
|
||||
|
||||
"home.banner.badge": "Novo",
|
||||
"home.banner.text": "App desktop disponível em beta",
|
||||
@@ -242,6 +251,24 @@ export const dict = {
|
||||
"Todos os modelos Zen são hospedados nos EUA. Os provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelo, com as",
|
||||
"zen.privacy.exceptionsLink": "seguintes exceções",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.",
|
||||
"zen.api.error.modelNotSupported": "Modelo {{model}} não suportado",
|
||||
"zen.api.error.modelFormatNotSupported": "Modelo {{model}} não suportado para o formato {{format}}",
|
||||
"zen.api.error.noProviderAvailable": "Nenhum provedor disponível",
|
||||
"zen.api.error.providerNotSupported": "Provedor {{provider}} não suportado",
|
||||
"zen.api.error.missingApiKey": "Chave de API ausente.",
|
||||
"zen.api.error.invalidApiKey": "Chave de API inválida.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Cota de assinatura excedida. Tente novamente em {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Cota de assinatura excedida. Você pode continuar usando modelos gratuitos.",
|
||||
"zen.api.error.noPaymentMethod": "Nenhuma forma de pagamento. Adicione uma forma de pagamento aqui: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Saldo insuficiente. Gerencie seu faturamento aqui: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
"Seu workspace atingiu o limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{billingUrl}}",
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "O modelo está desabilitado",
|
||||
|
||||
"black.meta.title": "OpenCode Black | Acesse os melhores modelos de codificação do mundo",
|
||||
"black.meta.description": "Tenha acesso ao Claude, GPT, Gemini e mais com os planos de assinatura OpenCode Black.",
|
||||
"black.hero.title": "Acesse os melhores modelos de codificação do mundo",
|
||||
@@ -451,6 +478,7 @@ export const dict = {
|
||||
"workspace.reload.updatePaymentMethod": "Por favor, atualize sua forma de pagamento e tente novamente.",
|
||||
"workspace.reload.retrying": "Tentando novamente...",
|
||||
"workspace.reload.retry": "Tentar novamente",
|
||||
"workspace.reload.error.paymentFailed": "Pagamento falhou.",
|
||||
|
||||
"workspace.payments.title": "Histórico de Pagamentos",
|
||||
"workspace.payments.subtitle": "Transações de pagamento recentes.",
|
||||
@@ -571,6 +599,10 @@ export const dict = {
|
||||
"enterprise.form.send": "Enviar",
|
||||
"enterprise.form.sending": "Enviando...",
|
||||
"enterprise.form.success": "Mensagem enviada, entraremos em contato em breve.",
|
||||
"enterprise.form.success.submitted": "Formulário enviado com sucesso.",
|
||||
"enterprise.form.error.allFieldsRequired": "Todos os campos são obrigatórios.",
|
||||
"enterprise.form.error.invalidEmailFormat": "Formato de e-mail inválido.",
|
||||
"enterprise.form.error.internalServer": "Erro interno do servidor.",
|
||||
"enterprise.faq.title": "FAQ",
|
||||
"enterprise.faq.q1": "O que é OpenCode Enterprise?",
|
||||
"enterprise.faq.a1":
|
||||
@@ -603,6 +635,7 @@ export const dict = {
|
||||
"bench.list.table.agent": "Agente",
|
||||
"bench.list.table.model": "Modelo",
|
||||
"bench.list.table.score": "Pontuação",
|
||||
"bench.submission.error.allFieldsRequired": "Todos os campos são obrigatórios.",
|
||||
|
||||
"bench.detail.title": "Benchmark - {{task}}",
|
||||
"bench.detail.notFound": "Tarefa não encontrada",
|
||||
|
||||
@@ -15,6 +15,7 @@ export const dict = {
|
||||
"nav.home": "Hjem",
|
||||
"nav.openMenu": "Åbn menu",
|
||||
"nav.getStartedFree": "Kom i gang gratis",
|
||||
"nav.logoAlt": "OpenCode",
|
||||
|
||||
"nav.context.copyLogo": "Kopier logo som SVG",
|
||||
"nav.context.copyWordmark": "Kopier wordmark som SVG",
|
||||
@@ -42,9 +43,13 @@ export const dict = {
|
||||
"notFound.docs": "Dokumentation",
|
||||
"notFound.github": "GitHub",
|
||||
"notFound.discord": "Discord",
|
||||
"notFound.logoLightAlt": "opencode logo light",
|
||||
"notFound.logoDarkAlt": "opencode logo dark",
|
||||
|
||||
"user.logout": "Log ud",
|
||||
|
||||
"auth.callback.error.codeMissing": "Ingen autorisationskode fundet.",
|
||||
|
||||
"workspace.select": "Vælg workspace",
|
||||
"workspace.createNew": "+ Opret nyt workspace",
|
||||
"workspace.modal.title": "Opret nyt workspace",
|
||||
@@ -76,6 +81,8 @@ export const dict = {
|
||||
"error.reloadAmountMin": "Genopfyldningsbeløb skal være mindst ${{amount}}",
|
||||
"error.reloadTriggerMin": "Saldogrænse skal være mindst ${{amount}}",
|
||||
|
||||
"app.meta.description": "OpenCode - Den open source kodningsagent.",
|
||||
|
||||
"home.title": "OpenCode | Den open source AI-kodningsagent",
|
||||
|
||||
"temp.title": "opencode | AI-kodningsagent bygget til terminalen",
|
||||
@@ -91,6 +98,8 @@ export const dict = {
|
||||
"temp.feature.models.afterLink": ", inklusive lokale modeller",
|
||||
"temp.screenshot.caption": "opencode TUI med tokyonight-temaet",
|
||||
"temp.screenshot.alt": "opencode TUI med tokyonight-temaet",
|
||||
"temp.logoLightAlt": "opencode logo light",
|
||||
"temp.logoDarkAlt": "opencode logo dark",
|
||||
|
||||
"home.banner.badge": "Ny",
|
||||
"home.banner.text": "Desktop-app tilgængelig i beta",
|
||||
@@ -240,6 +249,24 @@ export const dict = {
|
||||
"Alle Zen-modeller er hostet i USA. Udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning med",
|
||||
"zen.privacy.exceptionsLink": "følgende undtagelser",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke",
|
||||
"zen.api.error.modelFormatNotSupported": "Model {{model}} understøttes ikke for format {{format}}",
|
||||
"zen.api.error.noProviderAvailable": "Ingen udbyder tilgængelig",
|
||||
"zen.api.error.providerNotSupported": "Udbyder {{provider}} understøttes ikke",
|
||||
"zen.api.error.missingApiKey": "Manglende API-nøgle.",
|
||||
"zen.api.error.invalidApiKey": "Ugyldig API-nøgle.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igen om {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Abonnementskvote overskredet. Du kan fortsætte med at bruge gratis modeller.",
|
||||
"zen.api.error.noPaymentMethod": "Ingen betalingsmetode. Tilføj en betalingsmetode her: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Utilstrækkelig saldo. Administrer din fakturering her: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
"Dit workspace har nået sin månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{billingUrl}}",
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "Modellen er deaktiveret",
|
||||
|
||||
"black.meta.title": "OpenCode Black | Få adgang til verdens bedste kodningsmodeller",
|
||||
"black.meta.description": "Få adgang til Claude, GPT, Gemini og mere med OpenCode Black-abonnementer.",
|
||||
"black.hero.title": "Få adgang til verdens bedste kodningsmodeller",
|
||||
@@ -449,6 +476,7 @@ export const dict = {
|
||||
"workspace.reload.updatePaymentMethod": "Opdater din betalingsmetode, og prøv igen.",
|
||||
"workspace.reload.retrying": "Prøver igen...",
|
||||
"workspace.reload.retry": "Prøv igen",
|
||||
"workspace.reload.error.paymentFailed": "Betaling mislykkedes.",
|
||||
|
||||
"workspace.payments.title": "Betalingshistorik",
|
||||
"workspace.payments.subtitle": "Seneste betalingstransaktioner.",
|
||||
@@ -567,6 +595,10 @@ export const dict = {
|
||||
"enterprise.form.send": "Send",
|
||||
"enterprise.form.sending": "Sender...",
|
||||
"enterprise.form.success": "Besked sendt, vi vender tilbage snart.",
|
||||
"enterprise.form.success.submitted": "Formular indsendt med succes.",
|
||||
"enterprise.form.error.allFieldsRequired": "Alle felter er påkrævet.",
|
||||
"enterprise.form.error.invalidEmailFormat": "Ugyldigt e-mailformat.",
|
||||
"enterprise.form.error.internalServer": "Intern serverfejl.",
|
||||
"enterprise.faq.title": "FAQ",
|
||||
"enterprise.faq.q1": "Hvad er OpenCode Enterprise?",
|
||||
"enterprise.faq.a1":
|
||||
@@ -599,6 +631,7 @@ export const dict = {
|
||||
"bench.list.table.agent": "Agent",
|
||||
"bench.list.table.model": "Model",
|
||||
"bench.list.table.score": "Score",
|
||||
"bench.submission.error.allFieldsRequired": "Alle felter er påkrævet.",
|
||||
|
||||
"bench.detail.title": "Benchmark - {{task}}",
|
||||
"bench.detail.notFound": "Opgave ikke fundet",
|
||||
|
||||
@@ -15,6 +15,7 @@ export const dict = {
|
||||
"nav.home": "Startseite",
|
||||
"nav.openMenu": "Menü öffnen",
|
||||
"nav.getStartedFree": "Kostenlos starten",
|
||||
"nav.logoAlt": "OpenCode",
|
||||
|
||||
"nav.context.copyLogo": "Logo als SVG kopieren",
|
||||
"nav.context.copyWordmark": "Wortmarke als SVG kopieren",
|
||||
@@ -42,9 +43,13 @@ export const dict = {
|
||||
"notFound.docs": "Dokumentation",
|
||||
"notFound.github": "GitHub",
|
||||
"notFound.discord": "Discord",
|
||||
"notFound.logoLightAlt": "OpenCode Logo hell",
|
||||
"notFound.logoDarkAlt": "OpenCode Logo dunkel",
|
||||
|
||||
"user.logout": "Abmelden",
|
||||
|
||||
"auth.callback.error.codeMissing": "Kein Autorisierungscode gefunden.",
|
||||
|
||||
"workspace.select": "Workspace auswählen",
|
||||
"workspace.createNew": "+ Neuen Workspace erstellen",
|
||||
"workspace.modal.title": "Neuen Workspace erstellen",
|
||||
@@ -76,6 +81,8 @@ export const dict = {
|
||||
"error.reloadAmountMin": "Aufladebetrag muss mindestens ${{amount}} betragen",
|
||||
"error.reloadTriggerMin": "Guthaben-Auslöser muss mindestens ${{amount}} betragen",
|
||||
|
||||
"app.meta.description": "OpenCode - Der Open-Source Coding-Agent.",
|
||||
|
||||
"home.title": "OpenCode | Der Open-Source AI-Coding-Agent",
|
||||
|
||||
"temp.title": "OpenCode | Für das Terminal gebauter AI-Coding-Agent",
|
||||
@@ -91,6 +98,8 @@ export const dict = {
|
||||
"temp.feature.models.afterLink": ", einschließlich lokaler Modelle",
|
||||
"temp.screenshot.caption": "OpenCode TUI mit dem Tokyonight-Theme",
|
||||
"temp.screenshot.alt": "OpenCode TUI mit Tokyonight-Theme",
|
||||
"temp.logoLightAlt": "OpenCode Logo hell",
|
||||
"temp.logoDarkAlt": "OpenCode Logo dunkel",
|
||||
|
||||
"home.banner.badge": "Neu",
|
||||
"home.banner.text": "Desktop-App in der Beta verfügbar",
|
||||
@@ -242,6 +251,24 @@ export const dict = {
|
||||
"Alle Zen-Modelle werden in den USA gehostet. Anbieter folgen einer Zero-Retention-Policy und nutzen deine Daten nicht für Modelltraining, mit den",
|
||||
"zen.privacy.exceptionsLink": "folgenden Ausnahmen",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.",
|
||||
"zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt",
|
||||
"zen.api.error.modelFormatNotSupported": "Modell {{model}} wird für das Format {{format}} nicht unterstützt",
|
||||
"zen.api.error.noProviderAvailable": "Kein Anbieter verfügbar",
|
||||
"zen.api.error.providerNotSupported": "Anbieter {{provider}} wird nicht unterstützt",
|
||||
"zen.api.error.missingApiKey": "Fehlender API-Key.",
|
||||
"zen.api.error.invalidApiKey": "Ungültiger API-Key.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Abonnement-Quote überschritten. Erneuter Versuch in {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Abonnement-Quote überschritten. Du kannst weiterhin kostenlose Modelle nutzen.",
|
||||
"zen.api.error.noPaymentMethod": "Keine Zahlungsmethode. Füge hier eine Zahlungsmethode hinzu: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Unzureichendes Guthaben. Verwalte deine Abrechnung hier: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
"Dein Workspace hat sein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{billingUrl}}",
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "Modell ist deaktiviert",
|
||||
|
||||
"black.meta.title": "OpenCode Black | Zugriff auf die weltweit besten Coding-Modelle",
|
||||
"black.meta.description": "Erhalte Zugriff auf Claude, GPT, Gemini und mehr mit OpenCode Black Abos.",
|
||||
"black.hero.title": "Zugriff auf die weltweit besten Coding-Modelle",
|
||||
@@ -451,6 +478,7 @@ export const dict = {
|
||||
"workspace.reload.updatePaymentMethod": "Bitte aktualisiere deine Zahlungsmethode und versuche es erneut.",
|
||||
"workspace.reload.retrying": "Versuche erneut...",
|
||||
"workspace.reload.retry": "Erneut versuchen",
|
||||
"workspace.reload.error.paymentFailed": "Zahlung fehlgeschlagen.",
|
||||
|
||||
"workspace.payments.title": "Zahlungshistorie",
|
||||
"workspace.payments.subtitle": "Kürzliche Zahlungstransaktionen.",
|
||||
@@ -571,6 +599,10 @@ export const dict = {
|
||||
"enterprise.form.send": "Senden",
|
||||
"enterprise.form.sending": "Sende...",
|
||||
"enterprise.form.success": "Nachricht gesendet, wir melden uns bald.",
|
||||
"enterprise.form.success.submitted": "Formular erfolgreich gesendet.",
|
||||
"enterprise.form.error.allFieldsRequired": "Alle Felder sind erforderlich.",
|
||||
"enterprise.form.error.invalidEmailFormat": "Ungültiges E-Mail-Format.",
|
||||
"enterprise.form.error.internalServer": "Interner Serverfehler.",
|
||||
"enterprise.faq.title": "FAQ",
|
||||
"enterprise.faq.q1": "Was ist OpenCode Enterprise?",
|
||||
"enterprise.faq.a1":
|
||||
@@ -603,6 +635,7 @@ export const dict = {
|
||||
"bench.list.table.agent": "Agent",
|
||||
"bench.list.table.model": "Modell",
|
||||
"bench.list.table.score": "Score",
|
||||
"bench.submission.error.allFieldsRequired": "Alle Felder sind erforderlich.",
|
||||
|
||||
"bench.detail.title": "Benchmark - {{task}}",
|
||||
"bench.detail.notFound": "Task nicht gefunden",
|
||||
|
||||
@@ -6,11 +6,13 @@ export const dict = {
|
||||
"nav.x": "X",
|
||||
"nav.enterprise": "Enterprise",
|
||||
"nav.zen": "Zen",
|
||||
"nav.go": "Go",
|
||||
"nav.login": "Login",
|
||||
"nav.free": "Free",
|
||||
"nav.home": "Home",
|
||||
"nav.openMenu": "Open menu",
|
||||
"nav.getStartedFree": "Get started for free",
|
||||
"nav.logoAlt": "OpenCode",
|
||||
|
||||
"nav.context.copyLogo": "Copy logo as SVG",
|
||||
"nav.context.copyWordmark": "Copy wordmark as SVG",
|
||||
@@ -38,9 +40,13 @@ export const dict = {
|
||||
"notFound.docs": "Docs",
|
||||
"notFound.github": "GitHub",
|
||||
"notFound.discord": "Discord",
|
||||
"notFound.logoLightAlt": "opencode logo light",
|
||||
"notFound.logoDarkAlt": "opencode logo dark",
|
||||
|
||||
"user.logout": "Logout",
|
||||
|
||||
"auth.callback.error.codeMissing": "No authorization code found.",
|
||||
|
||||
"workspace.select": "Select workspace",
|
||||
"workspace.createNew": "+ Create New Workspace",
|
||||
"workspace.modal.title": "Create New Workspace",
|
||||
@@ -49,6 +55,7 @@ export const dict = {
|
||||
"common.cancel": "Cancel",
|
||||
"common.creating": "Creating...",
|
||||
"common.create": "Create",
|
||||
"common.contactUs": "Contact us",
|
||||
|
||||
"common.videoUnsupported": "Your browser does not support the video tag.",
|
||||
"common.figure": "Fig {{n}}.",
|
||||
@@ -72,6 +79,8 @@ export const dict = {
|
||||
"error.reloadAmountMin": "Reload amount must be at least ${{amount}}",
|
||||
"error.reloadTriggerMin": "Balance trigger must be at least ${{amount}}",
|
||||
|
||||
"app.meta.description": "OpenCode - The open source coding agent.",
|
||||
|
||||
"home.title": "OpenCode | The open source AI coding agent",
|
||||
|
||||
"temp.title": "opencode | AI coding agent built for the terminal",
|
||||
@@ -87,6 +96,8 @@ export const dict = {
|
||||
"temp.feature.models.afterLink": ", including local models",
|
||||
"temp.screenshot.caption": "opencode TUI with the tokyonight theme",
|
||||
"temp.screenshot.alt": "opencode TUI with tokyonight theme",
|
||||
"temp.logoLightAlt": "opencode logo light",
|
||||
"temp.logoDarkAlt": "opencode logo dark",
|
||||
|
||||
"home.banner.badge": "New",
|
||||
"home.banner.text": "Desktop app available in beta",
|
||||
@@ -234,6 +245,123 @@ export const dict = {
|
||||
"All Zen models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model training, with the",
|
||||
"zen.privacy.exceptionsLink": "following exceptions",
|
||||
|
||||
"go.title": "OpenCode Go | Low cost coding models for everyone",
|
||||
"go.meta.description":
|
||||
"Go is a $10/month subscription with generous 5-hour request limits for GLM-5, Kimi K2.5, and MiniMax M2.5.",
|
||||
"go.hero.title": "Low cost coding models for everyone",
|
||||
"go.hero.body":
|
||||
"Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.",
|
||||
|
||||
"go.cta.start": "Subscribe to Go",
|
||||
"go.cta.template": "{{text}} {{price}}",
|
||||
"go.cta.text": "Subscribe to Go",
|
||||
"go.cta.price": "$10/month",
|
||||
"go.pricing.body": "Use with any agent. Top up credit if needed. Cancel any time.",
|
||||
"go.graph.free": "Free",
|
||||
"go.graph.freePill": "Big Pickle and free models",
|
||||
"go.graph.go": "Go",
|
||||
"go.graph.label": "Requests per 5 hour",
|
||||
"go.graph.usageLimits": "Usage limits",
|
||||
"go.graph.tick": "{{n}}x",
|
||||
"go.graph.aria": "Requests per 5h: {{free}} vs {{go}}",
|
||||
|
||||
"go.testimonials.brand.zen": "Zen",
|
||||
"go.testimonials.brand.go": "Go",
|
||||
"go.testimonials.handle": "@OpenCode",
|
||||
"go.testimonials.dax.name": "Dax Raad",
|
||||
"go.testimonials.dax.title": "ex-CEO, Terminal Products",
|
||||
"go.testimonials.dax.quoteAfter": "has been life changing, it's truly a no-brainer.",
|
||||
"go.testimonials.jay.name": "Jay V",
|
||||
"go.testimonials.jay.title": "ex-Founder, SEED, PM, Melt, Pop, Dapt, Cadmus, and ViewPoint",
|
||||
"go.testimonials.jay.quoteBefore": "4 out of 5 people on our team love using",
|
||||
"go.testimonials.jay.quoteAfter": ".",
|
||||
"go.testimonials.adam.name": "Adam Elmore",
|
||||
"go.testimonials.adam.title": "ex-Hero, AWS",
|
||||
"go.testimonials.adam.quoteBefore": "I can't recommend",
|
||||
"go.testimonials.adam.quoteAfter": "enough. Seriously, it's really good.",
|
||||
"go.testimonials.david.name": "David Hill",
|
||||
"go.testimonials.david.title": "ex-Head of Design, Laravel",
|
||||
"go.testimonials.david.quoteBefore": "With",
|
||||
"go.testimonials.david.quoteAfter": "I know all the models are tested and perfect for coding agents.",
|
||||
"go.testimonials.frank.name": "Frank Wang",
|
||||
"go.testimonials.frank.title": "ex-Intern, Nvidia (4 times)",
|
||||
"go.testimonials.frank.quote": "I wish I was still at Nvidia.",
|
||||
"go.problem.title": "What problem is Go solving?",
|
||||
"go.problem.body":
|
||||
"We're focused on bringing the OpenCode experience to as many people as possible. OpenCode Go is a low cost ($10/month) subscription designed to bring agentic coding to programmers around the world. It provides generous limits and reliable access to the most capable open source models.",
|
||||
"go.problem.subtitle": " ",
|
||||
"go.problem.item1": "Low cost subscription pricing",
|
||||
"go.problem.item2": "Generous limits and reliable access",
|
||||
"go.problem.item3": "Built for as many programmers as possible",
|
||||
"go.problem.item4": "Includes GLM-5, Kimi K2.5, and MiniMax M2.5",
|
||||
"go.how.title": "How Go works",
|
||||
"go.how.body": "Go is a $10/month subscription you can use with OpenCode or any agent.",
|
||||
"go.how.step1.title": "Create an account",
|
||||
"go.how.step1.beforeLink": "follow the",
|
||||
"go.how.step1.link": "setup instructions",
|
||||
"go.how.step2.title": "Subscribe to Go",
|
||||
"go.how.step2.link": "$10/month",
|
||||
"go.how.step2.afterLink": "with generous limits",
|
||||
"go.how.step3.title": "Start coding",
|
||||
"go.how.step3.body": "with reliable access to open-source models",
|
||||
"go.privacy.title": "Your privacy is important to us",
|
||||
"go.privacy.body":
|
||||
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access.",
|
||||
"go.privacy.contactAfter": "if you have any questions.",
|
||||
"go.privacy.beforeExceptions":
|
||||
"Go models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model training, with the",
|
||||
"go.privacy.exceptionsLink": "following exceptions",
|
||||
"go.faq.q1": "What is OpenCode Go?",
|
||||
"go.faq.a1":
|
||||
"Go is a low-cost subscription that gives you reliable access to capable open-source models for agentic coding.",
|
||||
"go.faq.q2": "What models does Go include?",
|
||||
"go.faq.a2": "Go includes GLM-5, Kimi K2.5, and MiniMax M2.5, with generous limits and reliable access.",
|
||||
"go.faq.q3": "Is Go the same as Zen?",
|
||||
"go.faq.a3":
|
||||
"No. Zen is pay-as-you-go, while Go is a $10/month subscription with generous limits and reliable access to open-source models GLM-5, Kimi K2.5, and MiniMax M2.5.",
|
||||
"go.faq.q4": "How much does Go cost?",
|
||||
"go.faq.a4.p1.beforePricing": "Go costs",
|
||||
"go.faq.a4.p1.pricingLink": "$10/month",
|
||||
"go.faq.a4.p1.afterPricing": "with generous limits.",
|
||||
"go.faq.a4.p2.beforeAccount": "You can manage your subscription in your",
|
||||
"go.faq.a4.p2.accountLink": "account",
|
||||
"go.faq.a4.p3": "Cancel any time.",
|
||||
"go.faq.q5": "What about data and privacy?",
|
||||
"go.faq.a5.body":
|
||||
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access.",
|
||||
"go.faq.a5.contactAfter": "if you have any questions.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Go models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model training, with the",
|
||||
"go.faq.a5.exceptionsLink": "following exceptions",
|
||||
"go.faq.q6": "Can I top up credit?",
|
||||
"go.faq.a6": "If you need more usage, you can top up credit in your account.",
|
||||
"go.faq.q7": "Can I cancel?",
|
||||
"go.faq.a7": "Yes, you can cancel any time.",
|
||||
"go.faq.q8": "Can I use Go with other coding agents?",
|
||||
"go.faq.a8": "Yes, you can use Go with any agent. Follow the setup instructions in your preferred coding agent.",
|
||||
|
||||
"go.faq.q9": "What is the difference between free models and Go?",
|
||||
"go.faq.a9":
|
||||
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5, Kimi K2.5, and MiniMax M2.5 with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} not supported",
|
||||
"zen.api.error.modelFormatNotSupported": "Model {{model}} not supported for format {{format}}",
|
||||
"zen.api.error.noProviderAvailable": "No provider available",
|
||||
"zen.api.error.providerNotSupported": "Provider {{provider}} not supported",
|
||||
"zen.api.error.missingApiKey": "Missing API key.",
|
||||
"zen.api.error.invalidApiKey": "Invalid API key.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Subscription quota exceeded. Retry in {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Subscription quota exceeded. You can continue using free models.",
|
||||
"zen.api.error.noPaymentMethod": "No payment method. Add a payment method here: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Insufficient balance. Manage your billing here: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
"Your workspace has reached its monthly spending limit of ${{amount}}. Manage your limits here: {{billingUrl}}",
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "Model is disabled",
|
||||
|
||||
"black.meta.title": "OpenCode Black | Access all the world's best coding models",
|
||||
"black.meta.description": "Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans.",
|
||||
"black.hero.title": "Access all the world's best coding models",
|
||||
@@ -443,6 +571,7 @@ export const dict = {
|
||||
"workspace.reload.updatePaymentMethod": "Please update your payment method and try again.",
|
||||
"workspace.reload.retrying": "Retrying...",
|
||||
"workspace.reload.retry": "Retry",
|
||||
"workspace.reload.error.paymentFailed": "Payment failed.",
|
||||
|
||||
"workspace.payments.title": "Payments History",
|
||||
"workspace.payments.subtitle": "Recent payment transactions.",
|
||||
@@ -561,6 +690,10 @@ export const dict = {
|
||||
"enterprise.form.send": "Send",
|
||||
"enterprise.form.sending": "Sending...",
|
||||
"enterprise.form.success": "Message sent, we'll be in touch soon.",
|
||||
"enterprise.form.success.submitted": "Form submitted successfully.",
|
||||
"enterprise.form.error.allFieldsRequired": "All fields are required.",
|
||||
"enterprise.form.error.invalidEmailFormat": "Invalid email format.",
|
||||
"enterprise.form.error.internalServer": "Internal server error.",
|
||||
"enterprise.faq.title": "FAQ",
|
||||
"enterprise.faq.q1": "What is OpenCode Enterprise?",
|
||||
"enterprise.faq.a1":
|
||||
@@ -593,6 +726,7 @@ export const dict = {
|
||||
"bench.list.table.agent": "Agent",
|
||||
"bench.list.table.model": "Model",
|
||||
"bench.list.table.score": "Score",
|
||||
"bench.submission.error.allFieldsRequired": "All fields are required.",
|
||||
|
||||
"bench.detail.title": "Benchmark - {{task}}",
|
||||
"bench.detail.notFound": "Task not found",
|
||||
|
||||
@@ -15,6 +15,7 @@ export const dict = {
|
||||
"nav.home": "Inicio",
|
||||
"nav.openMenu": "Abrir menú",
|
||||
"nav.getStartedFree": "Empezar gratis",
|
||||
"nav.logoAlt": "OpenCode",
|
||||
|
||||
"nav.context.copyLogo": "Copiar logo como SVG",
|
||||
"nav.context.copyWordmark": "Copiar marca como SVG",
|
||||
@@ -42,9 +43,13 @@ export const dict = {
|
||||
"notFound.docs": "Documentación",
|
||||
"notFound.github": "GitHub",
|
||||
"notFound.discord": "Discord",
|
||||
"notFound.logoLightAlt": "opencode logo claro",
|
||||
"notFound.logoDarkAlt": "opencode logo oscuro",
|
||||
|
||||
"user.logout": "Cerrar sesión",
|
||||
|
||||
"auth.callback.error.codeMissing": "No se encontró código de autorización.",
|
||||
|
||||
"workspace.select": "Seleccionar espacio de trabajo",
|
||||
"workspace.createNew": "+ Crear nuevo espacio de trabajo",
|
||||
"workspace.modal.title": "Crear nuevo espacio de trabajo",
|
||||
@@ -76,6 +81,8 @@ export const dict = {
|
||||
"error.reloadAmountMin": "La cantidad de recarga debe ser al menos ${{amount}}",
|
||||
"error.reloadTriggerMin": "El disparador de saldo debe ser al menos ${{amount}}",
|
||||
|
||||
"app.meta.description": "OpenCode - El agente de codificación de código abierto.",
|
||||
|
||||
"home.title": "OpenCode | El agente de codificación IA de código abierto",
|
||||
|
||||
"temp.title": "opencode | Agente de codificación IA creado para la terminal",
|
||||
@@ -91,6 +98,8 @@ export const dict = {
|
||||
"temp.feature.models.afterLink": ", incluyendo modelos locales",
|
||||
"temp.screenshot.caption": "opencode TUI con el tema tokyonight",
|
||||
"temp.screenshot.alt": "opencode TUI con tema tokyonight",
|
||||
"temp.logoLightAlt": "logo de opencode claro",
|
||||
"temp.logoDarkAlt": "logo de opencode oscuro",
|
||||
|
||||
"home.banner.badge": "Nuevo",
|
||||
"home.banner.text": "Aplicación de escritorio disponible en beta",
|
||||
@@ -243,6 +252,24 @@ export const dict = {
|
||||
"Todos los modelos Zen están alojados en EE. UU. Los proveedores siguen una política de cero retención y no usan tus datos para entrenamiento de modelos, con las",
|
||||
"zen.privacy.exceptionsLink": "siguientes excepciones",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.",
|
||||
"zen.api.error.modelNotSupported": "Modelo {{model}} no soportado",
|
||||
"zen.api.error.modelFormatNotSupported": "Modelo {{model}} no soportado para el formato {{format}}",
|
||||
"zen.api.error.noProviderAvailable": "Ningún proveedor disponible",
|
||||
"zen.api.error.providerNotSupported": "Proveedor {{provider}} no soportado",
|
||||
"zen.api.error.missingApiKey": "Falta la clave API.",
|
||||
"zen.api.error.invalidApiKey": "Clave API inválida.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Cuota de suscripción excedida. Reintenta en {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Cuota de suscripción excedida. Puedes continuar usando modelos gratuitos.",
|
||||
"zen.api.error.noPaymentMethod": "Sin método de pago. Añade un método de pago aquí: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Saldo insuficiente. Gestiona tu facturación aquí: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
"Tu espacio de trabajo ha alcanzado su límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{billingUrl}}",
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"Has alcanzado tu límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "El modelo está deshabilitado",
|
||||
|
||||
"black.meta.title": "OpenCode Black | Accede a los mejores modelos de codificación del mundo",
|
||||
"black.meta.description": "Obtén acceso a Claude, GPT, Gemini y más con los planes de suscripción de OpenCode Black.",
|
||||
"black.hero.title": "Accede a los mejores modelos de codificación del mundo",
|
||||
@@ -452,6 +479,7 @@ export const dict = {
|
||||
"workspace.reload.updatePaymentMethod": "Por favor actualiza tu método de pago e intenta de nuevo.",
|
||||
"workspace.reload.retrying": "Reintentando...",
|
||||
"workspace.reload.retry": "Reintentar",
|
||||
"workspace.reload.error.paymentFailed": "El pago falló.",
|
||||
|
||||
"workspace.payments.title": "Historial de Pagos",
|
||||
"workspace.payments.subtitle": "Transacciones de pago recientes.",
|
||||
@@ -571,6 +599,10 @@ export const dict = {
|
||||
"enterprise.form.send": "Enviar",
|
||||
"enterprise.form.sending": "Enviando...",
|
||||
"enterprise.form.success": "Mensaje enviado, estaremos en contacto pronto.",
|
||||
"enterprise.form.success.submitted": "Formulario enviado con éxito.",
|
||||
"enterprise.form.error.allFieldsRequired": "Todos los campos son obligatorios.",
|
||||
"enterprise.form.error.invalidEmailFormat": "Formato de correo inválido.",
|
||||
"enterprise.form.error.internalServer": "Error interno del servidor.",
|
||||
"enterprise.faq.title": "FAQ",
|
||||
"enterprise.faq.q1": "¿Qué es OpenCode Enterprise?",
|
||||
"enterprise.faq.a1":
|
||||
@@ -603,6 +635,7 @@ export const dict = {
|
||||
"bench.list.table.agent": "Agente",
|
||||
"bench.list.table.model": "Modelo",
|
||||
"bench.list.table.score": "Puntuación",
|
||||
"bench.submission.error.allFieldsRequired": "Todos los campos son obligatorios.",
|
||||
|
||||
"bench.detail.title": "Benchmark - {{task}}",
|
||||
"bench.detail.notFound": "Tarea no encontrada",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user