mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 08:46:15 -04:00
Compare commits
276 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecf550c88c | |||
| ee5ee61db5 | |||
| c5fff9fb68 | |||
| dbbe67f066 | |||
| fff0ec294c | |||
| cf2d1dd3e9 | |||
| 27ca0f8825 | |||
| 73dbd8a312 | |||
| 443f103ee3 | |||
| 6c36b585c3 | |||
| c2e6b18076 | |||
| 8d97c8d412 | |||
| f8b357b267 | |||
| 621796d8ce | |||
| ba2455ecc7 | |||
| 44308dfd7e | |||
| b000256f57 | |||
| 30b2544fe1 | |||
| 1b096b4148 | |||
| be227503af | |||
| 4ddfa7c6fa | |||
| 2415434ad9 | |||
| bf4c647653 | |||
| f35bb5184c | |||
| 2c5335d84c | |||
| 7793db3ac8 | |||
| 5f77482a29 | |||
| 30aec297d8 | |||
| a9c810cbbc | |||
| fe2e4e21d0 | |||
| 03a2504d3d | |||
| bace18ccd3 | |||
| cd2e6f06ee | |||
| 7e7ad37736 | |||
| ca8db315a9 | |||
| 9eb07ab55a | |||
| e07e420637 | |||
| 84f94e6143 | |||
| a150424c16 | |||
| 2e71292f2f | |||
| e2527db3c7 | |||
| a1dee8b273 | |||
| 04e5ca9d1b | |||
| 6dd4d1473c | |||
| 31c5454ee6 | |||
| 31b233e9db | |||
| 567d6ed1a3 | |||
| 2bde20c2ba | |||
| 92c70c9c3d | |||
| 318dbe93ba | |||
| 8bd249d8cf | |||
| 710e408709 | |||
| dac0dd5309 | |||
| bf05e8a122 | |||
| 51b10b128e | |||
| 47a45601fd | |||
| 69623c2b79 | |||
| 38536cf733 | |||
| 8bf0675997 | |||
| ff967e582c | |||
| 20bf18ffb4 | |||
| 51891d56e7 | |||
| cc22646467 | |||
| eb70b6137b | |||
| 6e2bcafd34 | |||
| 07b983e82f | |||
| c51a1588c7 | |||
| f43b0d3afd | |||
| 722f4dd416 | |||
| bb82aab5c8 | |||
| 936363e7a8 | |||
| 8688ed7e3e | |||
| 5b54203168 | |||
| bed780fac9 | |||
| 14ec7ed5b3 | |||
| 2c65273204 | |||
| 2e0f88d0e3 | |||
| e1073e5d18 | |||
| 649618c50a | |||
| 02608a4e97 | |||
| 3ad6923c61 | |||
| e4300e9b74 | |||
| 538cfaff0d | |||
| 1dad38d1b5 | |||
| c9e2a38bf4 | |||
| 5863e1254c | |||
| 4c9abff445 | |||
| 2cf68f32b5 | |||
| 90fb32be30 | |||
| 97e713e8aa | |||
| 826419127a | |||
| e0449c0b96 | |||
| 5e342f7114 | |||
| 174ab58343 | |||
| 954d618790 | |||
| 91073360c6 | |||
| 8a2cfc00c9 | |||
| 0fc33e2a06 | |||
| 6c6ed68b5a | |||
| e9106efbdc | |||
| e9e2612706 | |||
| 4ae468ff06 | |||
| 785918262a | |||
| be8fee5c2b | |||
| 4597c687fa | |||
| c4aa049042 | |||
| 1d46b5c33c | |||
| 6ae6f0fe8f | |||
| a0409e64d8 | |||
| ce4e658e3f | |||
| 215fb39fc5 | |||
| 1cc94bb23e | |||
| bc42187c58 | |||
| 80c0b06980 | |||
| 381eabb970 | |||
| c939aa04fe | |||
| 9b8e356fca | |||
| 07e5ea9367 | |||
| 7a54a2c49c | |||
| 600e405ba0 | |||
| 1daf535e6b | |||
| cc52dc396c | |||
| 132ef57272 | |||
| 0777cf1ccf | |||
| c4bc902958 | |||
| d68397b42f | |||
| 531eebb88e | |||
| db9391e8a6 | |||
| f1c31f4eba | |||
| ada5b31bb0 | |||
| ffcb45d7c9 | |||
| 6566ede935 | |||
| 0bb677cef9 | |||
| 8415ff50cb | |||
| 37522185d3 | |||
| 960eacebcf | |||
| b4a6419215 | |||
| 7c1e61af1e | |||
| 671d193786 | |||
| 2ababc87da | |||
| 5372c63c7c | |||
| ab701d20eb | |||
| 384a8f58c6 | |||
| 50c9d5221f | |||
| 1a08ee77c7 | |||
| 79cff288a6 | |||
| 0efc334ff9 | |||
| 277ecc505c | |||
| f565ff3c09 | |||
| 161247c70d | |||
| a86ecf3bba | |||
| 6e84142b59 | |||
| fc52c5ac87 | |||
| 537666149b | |||
| c06ad7c881 | |||
| 0a7cb20e66 | |||
| f43209bb8c | |||
| b34d9242d1 | |||
| 9654412021 | |||
| 31d2fecf63 | |||
| f3f59db3ae | |||
| 89e2a23b5b | |||
| ea5f97609a | |||
| 4119051077 | |||
| f116a55e4a | |||
| 89e371c94b | |||
| b1a6c40ad0 | |||
| 1772e8ee6e | |||
| 0050134d9e | |||
| d46af9cf1e | |||
| bea56feb63 | |||
| 685a894a6f | |||
| 79ea379df2 | |||
| 4863aedf3d | |||
| b5cb9aae7f | |||
| 4d09a71ef4 | |||
| 65a3f7f749 | |||
| b1d14acc35 | |||
| 3867fa2bad | |||
| 07808bea12 | |||
| 914a643ab2 | |||
| c495635f05 | |||
| 8ff40133b4 | |||
| aacdb34e3f | |||
| 233427f08e | |||
| e82542b802 | |||
| 2006259a02 | |||
| 472b33113e | |||
| 218147271c | |||
| a29deb1ee9 | |||
| f24049736e | |||
| 21a644fef5 | |||
| 106f8e94d6 | |||
| 7a2c49e762 | |||
| 9b4d5b0395 | |||
| f20655bef4 | |||
| 31c099be43 | |||
| 155e1f20d6 | |||
| fe0c4f8c74 | |||
| 53ff1b57c9 | |||
| 1025540fcc | |||
| eb9a683b40 | |||
| 48c26fa039 | |||
| 10d1e04e9b | |||
| 12acb9a59a | |||
| 807c804c24 | |||
| 660a00d317 | |||
| effd27b239 | |||
| 06d7840d1d | |||
| 0875203a6c | |||
| b9131aa69c | |||
| 4519a1da32 | |||
| 77963d884a | |||
| 7d3d80f840 | |||
| 4814ab3a3d | |||
| 747b8daafc | |||
| 1399323b78 | |||
| bd7eb0603f | |||
| 09d9cf01f9 | |||
| 4ac4df448a | |||
| 147169e9b7 | |||
| ceccde7e84 | |||
| 54f4974546 | |||
| ba57718b05 | |||
| 3f0ef9b71c | |||
| fa2b63f850 | |||
| 83dca45dd5 | |||
| f750deaa3e | |||
| b36b85936d | |||
| 7c6adcf60f | |||
| 3e704d096f | |||
| 1fd9c77744 | |||
| 9ed17da55a | |||
| 24347f336c | |||
| 93a58f55ca | |||
| e3a55db5b5 | |||
| 015e79fa59 | |||
| d5b205657f | |||
| a645615a49 | |||
| 499a8a4b0c | |||
| 969bb90f69 | |||
| f591bf5f93 | |||
| 025e1ac69f | |||
| 820c984d47 | |||
| c814f84c87 | |||
| a57fb32d95 | |||
| 05d1104ecd | |||
| 7ebc7ffb87 | |||
| a9094fd059 | |||
| 760d523847 | |||
| 0bdd9aa494 | |||
| ca9bf7abf9 | |||
| 3bbf8c8989 | |||
| beae7290f3 | |||
| a7bd1cd0d0 | |||
| ecdfcd91ca | |||
| 3151e2246a | |||
| d2204e0ff5 | |||
| f26a9e8856 | |||
| 12e38866ed | |||
| 41bd9124f4 | |||
| 8ad44cdd22 | |||
| 9b09075bb7 | |||
| 76a81ac7c4 | |||
| 3f64b5e621 | |||
| cc487dd032 | |||
| f8cf8fa983 | |||
| 5d7157fe51 | |||
| e9aa33d4bd | |||
| 02a5ae6585 | |||
| fff36b70bc | |||
| 236cfcbbc3 | |||
| a8adfb6305 | |||
| 5c8eb0ab2e | |||
| 1e216e12dc | |||
| 48106b7e78 |
+2
-4
@@ -1,5 +1,3 @@
|
||||
# web + desktop packages
|
||||
packages/app/ @adamdotdevin
|
||||
packages/tauri/ @adamdotdevin
|
||||
packages/desktop/src-tauri/ @brendonovich
|
||||
packages/desktop/ @adamdotdevin
|
||||
packages/app/ @Hona @Brendonovich
|
||||
packages/desktop/ @Hona @Brendonovich
|
||||
|
||||
@@ -56,14 +56,24 @@ jobs:
|
||||
BUILD_LOG=$(mktemp)
|
||||
trap 'rm -f "$BUILD_LOG"' EXIT
|
||||
|
||||
# Build with fakeHash to trigger hash mismatch and reveal correct hash
|
||||
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
||||
HASH=""
|
||||
MAX_ATTEMPTS=3
|
||||
for ((ATTEMPT = 1; ATTEMPT <= MAX_ATTEMPTS; ATTEMPT++)); do
|
||||
# Build with fakeHash to trigger hash mismatch and reveal correct hash
|
||||
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
||||
|
||||
# Extract hash from build log with portability
|
||||
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
||||
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
||||
|
||||
[ -n "$HASH" ] && break
|
||||
|
||||
if [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; then
|
||||
echo "::warning::Attempt ${ATTEMPT}/${MAX_ATTEMPTS} produced no hash for ${SYSTEM}; retrying in $((ATTEMPT * 10))s"
|
||||
sleep $((ATTEMPT * 10))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$HASH" ]; then
|
||||
echo "::error::Failed to compute hash for ${SYSTEM}"
|
||||
echo "::error::Failed to compute hash for ${SYSTEM} after ${MAX_ATTEMPTS} attempts"
|
||||
cat "$BUILD_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -334,9 +334,9 @@ jobs:
|
||||
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
|
||||
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
|
||||
|
||||
- name: Package and publish
|
||||
- name: Package
|
||||
if: needs.version.outputs.release
|
||||
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish always --config electron-builder.config.ts
|
||||
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts
|
||||
working-directory: packages/desktop
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -356,11 +356,9 @@ jobs:
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
|
||||
- name: Create and upload macOS .app.tar.gz
|
||||
- name: Create macOS .app.tar.gz
|
||||
if: runner.os == 'macOS' && needs.version.outputs.release
|
||||
working-directory: packages/desktop/dist
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
run: |
|
||||
if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then
|
||||
APP_DIR="mac"
|
||||
@@ -378,7 +376,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
tar -czf "$OUT_NAME" -C "$(dirname "$APP_PATH")" "$(basename "$APP_PATH")"
|
||||
gh release upload "v${{ needs.version.outputs.version }}" "$OUT_NAME" --clobber --repo "${{ needs.version.outputs.repo }}"
|
||||
|
||||
- name: Verify signed Windows Electron artifacts
|
||||
if: runner.os == 'Windows'
|
||||
@@ -464,6 +461,13 @@ jobs:
|
||||
pattern: latest-yml-*
|
||||
path: /tmp/latest-yml
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: needs.version.outputs.release
|
||||
with:
|
||||
pattern: opencode-desktop-*
|
||||
path: /tmp/desktop
|
||||
merge-multiple: true
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
@@ -490,6 +494,19 @@ jobs:
|
||||
git config --global user.name "opencode"
|
||||
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true
|
||||
|
||||
- name: Upload desktop release assets
|
||||
if: needs.version.outputs.release
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
files=(/tmp/desktop/*.{exe,blockmap,dmg,zip,AppImage,deb,rpm} /tmp/desktop/*.app.tar.gz)
|
||||
if (( ${#files[@]} == 0 )); then
|
||||
echo "No desktop release assets found"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "v${{ needs.version.outputs.version }}" "${files[@]}" --clobber --repo "${{ needs.version.outputs.repo }}"
|
||||
|
||||
- run: ./script/publish.ts
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: bun turbo test:ci --log-order=stream --log-prefix=task
|
||||
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
@@ -74,26 +74,6 @@ jobs:
|
||||
working-directory: packages/opencode
|
||||
run: bun run test:httpapi
|
||||
|
||||
- name: Publish unit reports
|
||||
if: always()
|
||||
uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0
|
||||
with:
|
||||
report_paths: packages/*/.artifacts/unit/junit.xml
|
||||
check_name: "unit results (${{ matrix.settings.name }})"
|
||||
detailed_summary: true
|
||||
include_time_in_summary: true
|
||||
fail_on_failure: false
|
||||
|
||||
- name: Upload unit artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
||||
include-hidden-files: true
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
path: packages/*/.artifacts/unit/junit.xml
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
strategy:
|
||||
@@ -151,7 +131,6 @@ jobs:
|
||||
run: bun --cwd packages/app test:e2e:local
|
||||
env:
|
||||
CI: true
|
||||
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
|
||||
timeout-minutes: 30
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
@@ -162,6 +141,5 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
path: |
|
||||
packages/app/e2e/junit-*.xml
|
||||
packages/app/e2e/test-results
|
||||
packages/app/e2e/playwright-report
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
mode: primary
|
||||
hidden: true
|
||||
model: opencode/gpt-5.4-nano
|
||||
model: opencode/gpt-5.4-mini
|
||||
color: "#44BA81"
|
||||
tools:
|
||||
"*": false
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {},
|
||||
"permission": {},
|
||||
"reference": {
|
||||
"effect": "github.com/Effect-TS/effect-smol",
|
||||
"references": {
|
||||
"effect": {
|
||||
"repository": "github.com/Effect-TS/effect-smol",
|
||||
"description": "Use for Effect v4 and effect-smol implementation details",
|
||||
},
|
||||
"opencode-local": {
|
||||
"path": "~/.local/share/opencode",
|
||||
"description": "Contains opencode logs and data",
|
||||
},
|
||||
},
|
||||
"mcp": {},
|
||||
"tools": {
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
- The default branch in this repo is `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
|
||||
## Branch Names
|
||||
|
||||
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
|
||||
|
||||
Examples: `session-recovery`, `fix-scroll-state`, `regenerate-sdk`.
|
||||
|
||||
## Commits and PR Titles
|
||||
|
||||
Use conventional commit-style messages and PR titles: `type(scope): summary`.
|
||||
@@ -143,9 +149,10 @@ const table = sqliteTable("session", {
|
||||
|
||||
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. It discovers placement through the read-side `SessionStore` and `LocationServiceMap.get(session.location)`; no layer should take a Session ID.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles.
|
||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
|
||||
|
||||
+37
-1
@@ -8,6 +8,10 @@ OpenCode sessions preserve durable conversational history while assembling the r
|
||||
The structured collection of contextual facts presented to the model as initial instructions and chronological updates.
|
||||
_Avoid_: System prompt
|
||||
|
||||
**Session History**:
|
||||
The projected chronological conversation selected for a provider turn after applying the active compaction and **Context Epoch** cutoffs.
|
||||
_Avoid_: Session Context
|
||||
|
||||
**Context Source**:
|
||||
One independently observed typed value within the **System Context**, represented by a stable key, JSON codec, infallible loader, pure baseline/update renderers, and an optional removal renderer for dynamic sources.
|
||||
_Avoid_: Prompt fragment
|
||||
@@ -20,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta
|
||||
_Avoid_: System update, system notification, raw text diff
|
||||
|
||||
**Context Epoch**:
|
||||
The span during which one initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
|
||||
The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
|
||||
|
||||
**Baseline System Context**:
|
||||
The full **System Context** rendered at the start of a **Context Epoch**.
|
||||
@@ -35,9 +39,23 @@ An expected temporary inability to observe a **Context Source** value; the runti
|
||||
**Safe Provider-Turn Boundary**:
|
||||
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
|
||||
|
||||
**Model Tool Output**:
|
||||
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
|
||||
|
||||
**Managed Tool Output File**:
|
||||
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
|
||||
|
||||
**Model Request Options**:
|
||||
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
|
||||
_Avoid_: Request body, wire options
|
||||
|
||||
**Generation Controls**:
|
||||
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
||||
- **Session History** contains projected conversational messages and admitted **Mid-Conversation System Messages**; the active **Baseline System Context** remains separate provider-request state.
|
||||
- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Provider-Turn Boundary**.
|
||||
- A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state.
|
||||
- A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model.
|
||||
@@ -65,18 +83,36 @@ The point immediately before a provider call, after durable input promotion and
|
||||
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
|
||||
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
|
||||
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
|
||||
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
||||
- Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent.
|
||||
- A cross-agent replacement must complete before another provider turn; unavailable admitted context blocks that replacement instead of exposing the previous agent's privileged baseline.
|
||||
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
|
||||
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
|
||||
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
|
||||
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
|
||||
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
||||
- A **Context Epoch** begins with one immutable **Baseline System Context**.
|
||||
- A **Context Epoch** durably records the effective agent that owns its **Baseline System Context**.
|
||||
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
|
||||
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
||||
- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
|
||||
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
|
||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
||||
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
|
||||
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
||||
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
|
||||
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
|
||||
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
|
||||
- A truncated **Model Tool Output** identifies its complete text both in the bounded model-visible preview and as a typed managed output path. Managed output paths do not modify the tool's validated structured result.
|
||||
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
|
||||
- Failure to retain a **Managed Tool Output File** does not change a successful tool operation into a failed one. The Session records an explicitly lossy bounded output without a path, while operators receive diagnostics for the storage failure.
|
||||
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
|
||||
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
|
||||
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
|
||||
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
|
||||
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
|
||||
|
||||
## Example dialogue
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
exact = true
|
||||
# Only install newly resolved package versions published at least 3 days ago.
|
||||
minimumReleaseAge = 259200
|
||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider"]
|
||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
||||
|
||||
[test]
|
||||
root = "./do-not-run-tests-from-root"
|
||||
|
||||
+1
-1
@@ -165,7 +165,7 @@ export const app = new sst.cloudflare.x.SolidStart("Stats", {
|
||||
domain: `stats.${domain}`,
|
||||
link: [database, EMAILOCTOPUS_API_KEY],
|
||||
environment: {
|
||||
PUBLIC_URL: `https://${domain}/stats`,
|
||||
PUBLIC_URL: `https://${domain}/data`,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-mXTzANDuuy+BY4vzhuuL5Q6JVVTJCKdHuD/Fo8pSfgI=",
|
||||
"aarch64-linux": "sha256-t1Uf+PIDvj9bogsSo2Dg1e+zJM2CHQ8lpA/I3vFQA1Q=",
|
||||
"aarch64-darwin": "sha256-HKpMwzpYhCQOu0xHugi4ZIC/Va2BSiQpM2TbA6BEZDU=",
|
||||
"x86_64-darwin": "sha256-m5h7h9KxkcIrdTO2QzQftq68d0Ru0IsCfu3WzMp4P68="
|
||||
"x86_64-linux": "sha256-u3CBUflGrE1unVRTvvLKaDkvx5ZHXyRLd/SJOUUsRdc=",
|
||||
"aarch64-linux": "sha256-hUu70IlOXXQ9apQe3IPOdQGf3ZSVCpvRuAWSKxipk/k=",
|
||||
"aarch64-darwin": "sha256-N6Mqmo94q0rsskLcXoUGWCMJjQ9wCuuo0Lby3j6+Dps=",
|
||||
"x86_64-darwin": "sha256-5691VKK2zWqAwe94CCjZuRjXEaswedevFtHxensVLfg="
|
||||
}
|
||||
}
|
||||
|
||||
+6
-15
@@ -39,9 +39,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.3.2",
|
||||
"@opentui/keymap": "0.3.2",
|
||||
"@opentui/solid": "0.3.2",
|
||||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
@@ -52,7 +52,7 @@
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"opentui-spinner": "0.0.6",
|
||||
"opentui-spinner": "0.0.7",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
@@ -133,24 +133,15 @@
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
"@aws-sdk/credential-provider-cognito-identity": "3.972.22",
|
||||
"@aws-sdk/credential-provider-env": "3.972.25",
|
||||
"@aws-sdk/credential-provider-http": "3.972.27",
|
||||
"@aws-sdk/credential-provider-ini": "3.972.29",
|
||||
"@aws-sdk/credential-provider-login": "3.972.29",
|
||||
"@aws-sdk/credential-provider-node": "3.972.30",
|
||||
"@aws-sdk/credential-provider-process": "3.972.25",
|
||||
"@aws-sdk/credential-provider-sso": "3.972.29",
|
||||
"@aws-sdk/credential-provider-web-identity": "3.972.29",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@smithy/shared-ini-file-loader": "4.4.8",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
||||
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.4",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./desktop-menu": "./src/desktop-menu.ts",
|
||||
"./updater": "./src/updater.ts",
|
||||
"./wsl/types": "./src/wsl/types.ts",
|
||||
"./vite": "./vite.js",
|
||||
"./index.css": "./src/index.css"
|
||||
},
|
||||
@@ -16,8 +18,7 @@
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "bun run test:unit",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"test:unit": "bun test --preload ./happydom.ts ./src",
|
||||
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
|
||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:local": "playwright test",
|
||||
|
||||
@@ -7,12 +7,6 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||
const reuse = !process.env.CI
|
||||
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||
const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
|
||||
|
||||
if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
|
||||
reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
outputDir: "./e2e/test-results",
|
||||
@@ -24,7 +18,7 @@ export default defineConfig({
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers,
|
||||
reporter,
|
||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||
webServer: {
|
||||
command,
|
||||
url: baseURL,
|
||||
|
||||
+92
-15
@@ -9,7 +9,7 @@ import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
@@ -43,24 +43,88 @@ import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import { TabsProvider } from "@/context/tabs"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
|
||||
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
|
||||
const SessionRoute = Object.assign(
|
||||
() => (
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
),
|
||||
() => {
|
||||
const settings = useSettings()
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
|
||||
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
|
||||
// is replaced by a draft at /new-session?draftId=…
|
||||
createEffect(() => {
|
||||
if (!settings.general.newLayoutDesigns()) return
|
||||
if (params.id || search.draftId) return
|
||||
if (!tabs.ready() || !sdk.directory) return
|
||||
tabs.newDraft({ server: server.key, directory: sdk.directory }, search.prompt)
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
)
|
||||
},
|
||||
{ preload: Session.preload },
|
||||
)
|
||||
|
||||
function DraftRoute() {
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
|
||||
{(draftID) => <ResolvedDraftRoute draftID={draftID} />}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedDraftRoute(props: { draftID: string }) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const draft = createMemo(() =>
|
||||
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const current = draft()
|
||||
if (current && current.server !== server.key) server.setActive(current.server)
|
||||
})
|
||||
|
||||
// Key on the directory so retargeting the draft's project re-instantiates the
|
||||
// SDK/data providers for the new directory while keeping the same draft id.
|
||||
const directory = () => draft()?.directory
|
||||
|
||||
return (
|
||||
<Show when={directory()} keyed>
|
||||
{(dir) => (
|
||||
<SDKProvider directory={dir}>
|
||||
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
|
||||
<DraftProviders>
|
||||
<NewSession />
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function UiI18nBridge(props: ParentProps) {
|
||||
const language = useLanguage()
|
||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||
@@ -69,9 +133,7 @@ function UiI18nBridge(props: ParentProps) {
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCODE__?: {
|
||||
updaterEnabled?: boolean
|
||||
deepLinks?: string[]
|
||||
wsl?: boolean
|
||||
}
|
||||
api?: {
|
||||
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
||||
@@ -142,6 +204,18 @@ function SessionProviders(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||
function DraftProviders(props: ParentProps) {
|
||||
return (
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<CommentsProvider>{props.children}</CommentsProvider>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||
return (
|
||||
<AppShellProviders>
|
||||
@@ -171,11 +245,13 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
}}
|
||||
>
|
||||
<QueryProvider>
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
<WslServersProvider>
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
</WslServersProvider>
|
||||
</QueryProvider>
|
||||
</ErrorBoundary>
|
||||
</UiI18nBridge>
|
||||
@@ -334,6 +410,7 @@ export function AppInterface(props: {
|
||||
)}
|
||||
>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
|
||||
@@ -399,6 +399,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
items={items}
|
||||
key={(item) => item.id}
|
||||
filterKeys={["title", "description", "category"]}
|
||||
skipFilter={(item) => item.type === "file"}
|
||||
groupBy={grouped() ? (item) => item.category : () => ""}
|
||||
onMove={handleMove}
|
||||
onSelect={handleSelect}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
import { Component, createMemo, Show } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
|
||||
const statusLabels = {
|
||||
connected: "mcp.status.connected",
|
||||
@@ -19,10 +16,7 @@ const statusLabels = {
|
||||
|
||||
export const DialogSelectMcp: Component = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
const queryClient = useQueryClient()
|
||||
const queryOptions = useQueryOptions()
|
||||
|
||||
const items = createMemo(() =>
|
||||
Object.entries(sync.data.mcp ?? {})
|
||||
@@ -30,21 +24,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
|
||||
const toggle = useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
return
|
||||
}
|
||||
if (status?.status === "needs_auth") {
|
||||
await sdk.client.mcp.auth.authenticate({ name })
|
||||
return
|
||||
}
|
||||
await sdk.client.mcp.connect({ name })
|
||||
},
|
||||
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
|
||||
}))
|
||||
const toggle = useMcpToggle()
|
||||
|
||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||
const totalCount = createMemo(() => items().length)
|
||||
|
||||
@@ -189,7 +189,7 @@ export function DialogSelectServer() {
|
||||
)
|
||||
}
|
||||
|
||||
export function useServerManagementController(options: { onSelect?: () => void } = {}) {
|
||||
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
@@ -265,6 +265,11 @@ export function useServerManagementController(options: { onSelect?: () => void }
|
||||
}
|
||||
|
||||
resetAdd()
|
||||
if (options.navigateOnAdd === false) {
|
||||
server.add(conn)
|
||||
options.onSelect?.()
|
||||
return
|
||||
}
|
||||
await select(conn, true)
|
||||
},
|
||||
}))
|
||||
@@ -504,11 +509,16 @@ export function useServerManagementController(options: { onSelect?: () => void }
|
||||
resetEdit()
|
||||
})
|
||||
|
||||
async function handleRemove(url: ServerConnection.Key) {
|
||||
tabs.removeServer(url)
|
||||
server.remove(url)
|
||||
if ((await platform.getDefaultServer?.()) === url) {
|
||||
void platform.setDefaultServer?.(null)
|
||||
async function handleRemove(key: ServerConnection.Key) {
|
||||
try {
|
||||
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
||||
tabs.removeServer(key)
|
||||
server.remove(key)
|
||||
if ((await platform.getDefaultServer?.()) === key) {
|
||||
await setDefault(null)
|
||||
}
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import type { Platform } from "@/context/platform"
|
||||
|
||||
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
|
||||
if (platform === "desktop" && ServerConnection.local(server)) return "native" as const
|
||||
return "server" as const
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { directoryPickerKind } from "./directory-picker-policy"
|
||||
|
||||
const local = {
|
||||
type: "sidecar",
|
||||
variant: "base",
|
||||
http: { url: "http://localhost:4096" },
|
||||
} as const
|
||||
const remote = {
|
||||
type: "ssh",
|
||||
host: "example.test",
|
||||
http: { url: "http://localhost:4096" },
|
||||
} as const
|
||||
|
||||
describe("directoryPickerKind", () => {
|
||||
test("uses the native picker only for local desktop projects", () => {
|
||||
expect(directoryPickerKind("desktop", local)).toBe("native")
|
||||
expect(directoryPickerKind("desktop", remote)).toBe("server")
|
||||
expect(directoryPickerKind("web", local)).toBe("server")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { DialogSelectDirectory } from "./dialog-select-directory"
|
||||
import { directoryPickerKind } from "./directory-picker-policy"
|
||||
|
||||
type DirectoryPickerInput = {
|
||||
server: ServerConnection.Any
|
||||
title?: string
|
||||
multiple?: boolean
|
||||
onSelect: (result: string | string[] | null) => void
|
||||
}
|
||||
|
||||
export function useDirectoryPicker() {
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
|
||||
return (input: DirectoryPickerInput) => {
|
||||
if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
|
||||
void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect)
|
||||
return
|
||||
}
|
||||
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory {...input} />,
|
||||
() => input.onSelect(null),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
useLocation: () => ({}),
|
||||
useSearchParams: () => [{}, () => undefined],
|
||||
}))
|
||||
mock.module("@/context/file", () => ({
|
||||
useFile: () => ({
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export function HelpButton() {
|
||||
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
|
||||
|
||||
const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */
|
||||
const [shown, setShown] = createSignal(false)
|
||||
|
||||
return (
|
||||
<Show when={!state.dismissed}>
|
||||
<div class="fixed bottom-4 right-4 z-50">
|
||||
<Popover
|
||||
open={shown()}
|
||||
onOpenChange={setShown}
|
||||
triggerAs="button"
|
||||
triggerProps={{
|
||||
type: "button",
|
||||
"aria-label": "Help",
|
||||
class:
|
||||
"size-7 rounded-full bg-background-base shadow-[var(--shadow-lg-border-base)] flex items-center justify-center text-text-base hover:text-text-strong transition-colors",
|
||||
}}
|
||||
trigger={<span aria-hidden="true">?</span>}
|
||||
class="[&_[data-slot=popover-body]]:p-0 w-[320px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
|
||||
gutter={8}
|
||||
placement="top-end"
|
||||
>
|
||||
<Show when={shown()}>
|
||||
<div class="relative flex flex-col gap-1 w-[320px] p-4 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
class="absolute top-3.5 right-3.5 size-6 rounded-md flex items-center justify-center text-text-base hover:text-text-strong hover:bg-surface-raised-base-hover transition-colors"
|
||||
onClick={() => {
|
||||
setShown(false)
|
||||
setState("dismissed", true)
|
||||
}}
|
||||
>
|
||||
<Icon name="xmark-small" />
|
||||
</button>
|
||||
<span class="text-14-regular text-text-strong">Lorem ipsum dolor sit amet</span>
|
||||
<p class="text-12-regular text-text-weak">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
|
||||
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</Popover>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -31,9 +31,10 @@ import {
|
||||
FileAttachmentPart,
|
||||
} from "@/context/prompt"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -56,7 +57,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||
import { createPromptAttachments } from "./prompt-input/attachments"
|
||||
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
|
||||
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
|
||||
import {
|
||||
canNavigateHistoryAtCursor,
|
||||
navigatePromptHistory,
|
||||
@@ -72,6 +73,8 @@ import { PromptContextItems } from "./prompt-input/context-items"
|
||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { useDirectoryPicker } from "./directory-picker"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { useQueries } from "@tanstack/solid-query"
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
@@ -139,7 +142,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const permission = usePermission()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const settings = useSettings()
|
||||
const tabsStore = useTabs()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
let editorRef!: HTMLDivElement
|
||||
let fileInputRef: HTMLInputElement | undefined
|
||||
@@ -465,7 +471,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
|
||||
|
||||
const pick = () => fileInputRef?.click()
|
||||
const pick = () => {
|
||||
pickAttachmentFiles({
|
||||
picker: platform.openAttachmentPickerDialog,
|
||||
directory: () => sdk.directory,
|
||||
fallback: () => fileInputRef?.click(),
|
||||
onFile: addAttachment,
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const setMode = (mode: "normal" | "shell") => {
|
||||
setStore("mode", mode)
|
||||
@@ -625,6 +644,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
},
|
||||
key: atKey,
|
||||
filterKeys: ["display"],
|
||||
skipFilter: (item) => item.type === "file" && !item.recent,
|
||||
groupBy: (item) => {
|
||||
if (item.type === "agent") return "agent"
|
||||
if (item.recent) return "recent"
|
||||
@@ -1075,7 +1095,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
editor: () => editorRef,
|
||||
isDialogActive: () => !!dialog.active,
|
||||
setDraggingType: (type) => setStore("draggingType", type),
|
||||
@@ -1364,9 +1384,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
layout.projects.open(worktree)
|
||||
server.projects.touch(worktree)
|
||||
|
||||
// On the draft route, retarget the existing draft in place so we keep the same
|
||||
// draft id (and its tab/prompt) instead of spawning a new draft for the new directory.
|
||||
const draftID = search.draftId
|
||||
if (draftID) {
|
||||
tabsStore.updateDraft(draftID, { server: server.key, directory: worktree })
|
||||
restoreFocus()
|
||||
return
|
||||
}
|
||||
|
||||
navigate(`/${base64Encode(worktree)}/session`)
|
||||
}
|
||||
const addProject = async () => {
|
||||
const addProject = () => {
|
||||
const conn = server.current
|
||||
if (!conn) return
|
||||
const select = (result: string | string[] | null) => {
|
||||
@@ -1374,15 +1404,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (!directory) return
|
||||
selectProject(directory)
|
||||
}
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") }))
|
||||
return
|
||||
}
|
||||
void import("@/components/dialog-select-directory").then((x) => {
|
||||
dialog.show(
|
||||
() => <x.DialogSelectDirectory onSelect={select} server={conn} />,
|
||||
() => select(null),
|
||||
)
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
onSelect: select,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { attachmentMime } from "./files"
|
||||
import { attachmentMime, pickAttachmentFiles } from "./files"
|
||||
import { pasteMode } from "./paste"
|
||||
|
||||
describe("attachmentMime", () => {
|
||||
@@ -24,6 +24,70 @@ describe("attachmentMime", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("pickAttachmentFiles", () => {
|
||||
test("reads the current project directory for every native picker invocation", async () => {
|
||||
const paths: string[] = []
|
||||
const files: File[] = []
|
||||
const file = new File(["hello"], "hello.txt", { type: "text/plain" })
|
||||
let directory = "C:\\Projects\\LoremIpsum"
|
||||
const picker = async (options?: { defaultPath?: string }, onFile?: (file: File) => Promise<unknown>) => {
|
||||
paths.push(options?.defaultPath ?? "")
|
||||
await onFile?.(file)
|
||||
}
|
||||
|
||||
pickAttachmentFiles({
|
||||
picker,
|
||||
directory: () => directory,
|
||||
fallback: () => undefined,
|
||||
onFile: async (selected) => files.push(selected),
|
||||
onError: () => undefined,
|
||||
})
|
||||
await Promise.resolve()
|
||||
directory = "C:\\Projects\\DolorSit"
|
||||
pickAttachmentFiles({
|
||||
picker,
|
||||
directory: () => directory,
|
||||
fallback: () => undefined,
|
||||
onFile: async (selected) => files.push(selected),
|
||||
onError: () => undefined,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(files).toEqual([file, file])
|
||||
expect(paths).toEqual(["C:\\Projects\\LoremIpsum", "C:\\Projects\\DolorSit"])
|
||||
})
|
||||
|
||||
test("uses the browser file input when no native picker exists", async () => {
|
||||
let fallback = 0
|
||||
pickAttachmentFiles({
|
||||
directory: () => "/projects/consectetur-adipiscing",
|
||||
fallback: () => {
|
||||
fallback += 1
|
||||
},
|
||||
onFile: async () => undefined,
|
||||
onError: () => undefined,
|
||||
})
|
||||
expect(fallback).toBe(1)
|
||||
})
|
||||
|
||||
test("reports native picker failures without rejecting", async () => {
|
||||
const error = new Error("picker unavailable")
|
||||
const errors: unknown[] = []
|
||||
const handled = Promise.withResolvers<void>()
|
||||
pickAttachmentFiles({
|
||||
picker: async () => Promise.reject(error),
|
||||
directory: () => "C:\\Projects\\LoremIpsum",
|
||||
fallback: () => undefined,
|
||||
onFile: async () => undefined,
|
||||
onError: (cause) => {
|
||||
errors.push(cause)
|
||||
handled.resolve()
|
||||
},
|
||||
})
|
||||
await handled.promise
|
||||
expect(errors).toEqual([error])
|
||||
})
|
||||
})
|
||||
|
||||
describe("pasteMode", () => {
|
||||
test("uses native paste for short single-line text", () => {
|
||||
expect(pasteMode("hello world")).toBe("native")
|
||||
|
||||
@@ -2,6 +2,38 @@ import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-pick
|
||||
|
||||
export { ACCEPTED_FILE_TYPES }
|
||||
|
||||
type AttachmentPicker = (
|
||||
options: {
|
||||
defaultPath?: string
|
||||
multiple?: boolean
|
||||
accept?: string[]
|
||||
},
|
||||
onFile: (file: File) => Promise<unknown>,
|
||||
) => Promise<void>
|
||||
|
||||
export function pickAttachmentFiles(input: {
|
||||
picker?: AttachmentPicker
|
||||
directory: () => string
|
||||
fallback: () => void
|
||||
onFile: (file: File) => Promise<unknown>
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
if (!input.picker) {
|
||||
input.fallback()
|
||||
return
|
||||
}
|
||||
void input
|
||||
.picker(
|
||||
{
|
||||
defaultPath: input.directory(),
|
||||
multiple: true,
|
||||
accept: ACCEPTED_FILE_TYPES,
|
||||
},
|
||||
input.onFile,
|
||||
)
|
||||
.catch(input.onError)
|
||||
}
|
||||
|
||||
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
|
||||
const IMAGE_EXTS = new Map([
|
||||
["gif", "image/gif"],
|
||||
|
||||
@@ -61,6 +61,8 @@ beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => params,
|
||||
useLocation: () => ({}),
|
||||
useSearchParams: () => [{}, () => undefined],
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||
@@ -103,6 +105,16 @@ beforeAll(async () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/server", () => ({
|
||||
useServer: () => ({ key: "server-key" }),
|
||||
}))
|
||||
|
||||
mock.module("@/context/tabs", () => ({
|
||||
useTabs: () => ({
|
||||
promoteDraft: () => undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/prompt", () => ({
|
||||
usePrompt: () => ({
|
||||
current: () => promptValue,
|
||||
|
||||
@@ -2,9 +2,11 @@ import type { Message, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
@@ -213,6 +215,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID)
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
@@ -381,7 +386,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||
local.session.promote(sessionDirectory, session.id)
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
const draftID = search.draftId
|
||||
if (draftID)
|
||||
tabs.promoteDraft(draftID, {
|
||||
server: server.key,
|
||||
dirBase64: base64Encode(sessionDirectory),
|
||||
sessionId: session.id,
|
||||
})
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { type Component, Show } from "solid-js"
|
||||
import { useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
|
||||
export const ServerRowMenu: Component<{
|
||||
server: ServerConnection.Any
|
||||
controller: ReturnType<typeof useServerManagementController>
|
||||
onEdit: (server: ServerConnection.Http) => void
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const key = ServerConnection.key(props.server)
|
||||
const builtin = ServerConnection.builtin(props.server)
|
||||
const isDefault = () => props.controller.defaultKey() === key
|
||||
|
||||
return (
|
||||
<MenuV2 gutter={4} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" />}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
|
||||
<MenuV2.Item
|
||||
disabled={builtin || props.server.type !== "http"}
|
||||
onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
|
||||
>
|
||||
{language.t("dialog.server.menu.edit")}
|
||||
</MenuV2.Item>
|
||||
<Show when={props.controller.canDefault() && !isDefault()}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<Show when={props.controller.canDefault() && isDefault()}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
|
||||
{language.t("dialog.server.menu.delete")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
@@ -8,13 +7,13 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useUpdaterAction } from "./updater-action"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
@@ -91,9 +90,7 @@ export const SettingsGeneral: Component = () => {
|
||||
const params = useParams()
|
||||
const settings = useSettings()
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
})
|
||||
const updater = useUpdaterAction()
|
||||
|
||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||
const dir = createMemo(() => decode64(params.dir))
|
||||
@@ -123,58 +120,6 @@ export const SettingsGeneral: Component = () => {
|
||||
}
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
const check = () => {
|
||||
if (!platform.checkUpdate) return
|
||||
setStore("checking", true)
|
||||
|
||||
void platform
|
||||
.checkUpdate()
|
||||
.then((result) => {
|
||||
if (!result.updateAvailable) {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("settings.updates.toast.latest.title"),
|
||||
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const actions = platform.updateAndRestart
|
||||
? [
|
||||
{
|
||||
label: language.t("toast.update.action.installRestart"),
|
||||
onClick: async () => {
|
||||
await platform.updateAndRestart!()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: language.t("toast.update.action.notYet"),
|
||||
onClick: "dismiss" as const,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: language.t("toast.update.action.notYet"),
|
||||
onClick: "dismiss" as const,
|
||||
},
|
||||
]
|
||||
|
||||
showToast({
|
||||
persistent: true,
|
||||
icon: "download",
|
||||
title: language.t("toast.update.title"),
|
||||
description: language.t("toast.update.description", { version: result.version ?? "" }),
|
||||
actions,
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
.finally(() => setStore("checking", false))
|
||||
}
|
||||
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const serverSync = useServerSync()
|
||||
@@ -723,19 +668,6 @@ export const SettingsGeneral: Component = () => {
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.updates.row.startup.title")}
|
||||
description={language.t("settings.updates.row.startup.description")}
|
||||
>
|
||||
<div data-action="settings-updates-startup">
|
||||
<Switch
|
||||
checked={settings.updates.startup()}
|
||||
disabled={!platform.checkUpdate}
|
||||
onChange={(checked) => settings.updates.setStartup(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.releaseNotes.title")}
|
||||
description={language.t("settings.general.row.releaseNotes.description")}
|
||||
@@ -752,10 +684,8 @@ export const SettingsGeneral: Component = () => {
|
||||
title={language.t("settings.updates.row.check.title")}
|
||||
description={language.t("settings.updates.row.check.description")}
|
||||
>
|
||||
<Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}>
|
||||
{store.checking
|
||||
? language.t("settings.updates.action.checking")
|
||||
: language.t("settings.updates.action.checkNow")}
|
||||
<Button size="small" variant="secondary" disabled={!updater.action().run} onClick={updater.run}>
|
||||
{language.t(updater.action().label)}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type ServerConnection } from "@/context/server"
|
||||
import { useServerManagementController } from "../dialog-select-server"
|
||||
import "./settings-v2.css"
|
||||
|
||||
export const DialogServerV2: Component<{
|
||||
mode: "add" | "edit"
|
||||
server?: ServerConnection.Http
|
||||
}> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const controller = useServerManagementController({
|
||||
onSelect: () => dialog.close(),
|
||||
navigateOnAdd: false,
|
||||
})
|
||||
const [opened, setOpened] = createSignal(false)
|
||||
|
||||
onMount(() => {
|
||||
if (props.mode === "add") controller.startAdd()
|
||||
if (props.mode === "edit" && props.server) controller.startEdit(props.server)
|
||||
setOpened(true)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
controller.resetForm()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!opened()) return
|
||||
if (controller.isFormMode()) return
|
||||
dialog.close()
|
||||
})
|
||||
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Enter" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
controller.submitForm()
|
||||
}
|
||||
|
||||
const title = () =>
|
||||
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
|
||||
|
||||
const submitLabel = () => {
|
||||
if (controller.formBusy()) return language.t("dialog.server.add.checking")
|
||||
if (props.mode === "add") return language.t("dialog.server.add.button")
|
||||
return language.t("common.save")
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={title()} fit class="settings-v2-server-dialog">
|
||||
<div class="flex w-full min-w-0 flex-1 flex-col px-4">
|
||||
<div class="flex w-full min-w-0 flex-col gap-6">
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label>
|
||||
<TextInputV2
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={controller.formValue()}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
invalid={!!controller.formError()}
|
||||
disabled={controller.formBusy()}
|
||||
autofocus
|
||||
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<Show when={controller.formError()}>
|
||||
<span class="settings-v2-server-dialog-error">{controller.formError()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.name")}</label>
|
||||
<TextInputV2
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={controller.formName()}
|
||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||
disabled={controller.formBusy()}
|
||||
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid w-full min-w-0 grid-cols-2 gap-4">
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.username")}</label>
|
||||
<TextInputV2
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={controller.formUsername()}
|
||||
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
||||
disabled={controller.formBusy()}
|
||||
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.password")}</label>
|
||||
<TextInputV2
|
||||
type="password"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={controller.formPassword()}
|
||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||
disabled={controller.formBusy()}
|
||||
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
|
||||
{submitLabel()}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { SettingsKeybinds } from "../settings-keybinds"
|
||||
import { SettingsProvidersV2 } from "./providers"
|
||||
import { SettingsModelsV2 } from "./models"
|
||||
import "./settings-v2.css"
|
||||
import { SettingsServers } from "../settings-servers"
|
||||
import { SettingsServersV2 } from "./servers"
|
||||
|
||||
export const DialogSettings: Component = () => {
|
||||
const language = useLanguage()
|
||||
@@ -33,16 +33,16 @@ export const DialogSettings: Component = () => {
|
||||
<Icon name="keyboard" />
|
||||
{language.t("settings.tab.shortcuts")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="servers">
|
||||
<Icon name="server" />
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<TabsV2.Trigger value="servers">
|
||||
<Icon name="server" />
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="providers">
|
||||
<Icon name="providers" />
|
||||
{language.t("settings.providers.title")}
|
||||
@@ -68,7 +68,7 @@ export const DialogSettings: Component = () => {
|
||||
<SettingsKeybinds v2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="servers" class="settings-v2-panel">
|
||||
<SettingsServers />
|
||||
<SettingsServersV2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||
<SettingsProvidersV2 />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
@@ -8,13 +7,13 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useUpdaterAction } from "../updater-action"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
@@ -93,9 +92,7 @@ export const SettingsGeneralV2: Component = () => {
|
||||
const params = useParams()
|
||||
const settings = useSettings()
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
})
|
||||
const updater = useUpdaterAction()
|
||||
|
||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||
const dir = createMemo(() => decode64(params.dir))
|
||||
@@ -125,58 +122,6 @@ export const SettingsGeneralV2: Component = () => {
|
||||
}
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
const check = () => {
|
||||
if (!platform.checkUpdate) return
|
||||
setStore("checking", true)
|
||||
|
||||
void platform
|
||||
.checkUpdate()
|
||||
.then((result) => {
|
||||
if (!result.updateAvailable) {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("settings.updates.toast.latest.title"),
|
||||
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const actions = platform.updateAndRestart
|
||||
? [
|
||||
{
|
||||
label: language.t("toast.update.action.installRestart"),
|
||||
onClick: async () => {
|
||||
await platform.updateAndRestart!()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: language.t("toast.update.action.notYet"),
|
||||
onClick: "dismiss" as const,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: language.t("toast.update.action.notYet"),
|
||||
onClick: "dismiss" as const,
|
||||
},
|
||||
]
|
||||
|
||||
showToast({
|
||||
persistent: true,
|
||||
icon: "download",
|
||||
title: language.t("toast.update.title"),
|
||||
description: language.t("toast.update.description", { version: result.version ?? "" }),
|
||||
actions,
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
.finally(() => setStore("checking", false))
|
||||
}
|
||||
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const serverSync = useServerSync()
|
||||
@@ -740,19 +685,6 @@ export const SettingsGeneralV2: Component = () => {
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.updates.row.startup.title")}
|
||||
description={language.t("settings.updates.row.startup.description")}
|
||||
>
|
||||
<div data-action="settings-updates-startup">
|
||||
<Switch
|
||||
checked={settings.updates.startup()}
|
||||
disabled={!platform.checkUpdate}
|
||||
onChange={(checked) => settings.updates.setStartup(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.releaseNotes.title")}
|
||||
description={language.t("settings.general.row.releaseNotes.description")}
|
||||
@@ -769,10 +701,8 @@ export const SettingsGeneralV2: Component = () => {
|
||||
title={language.t("settings.updates.row.check.title")}
|
||||
description={language.t("settings.updates.row.check.description")}
|
||||
>
|
||||
<ButtonV2 size="normal" variant="neutral" disabled={store.checking || !platform.checkUpdate} onClick={check}>
|
||||
{store.checking
|
||||
? language.t("settings.updates.action.checking")
|
||||
: language.t("settings.updates.action.checkNow")}
|
||||
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={updater.run}>
|
||||
{language.t(updater.action().label)}
|
||||
</ButtonV2>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { type Component, For, Show, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ServerRowMenu } from "@/components/server/server-row-menu"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { useServerManagementController } from "../dialog-select-server"
|
||||
import { DialogServerV2 } from "./dialog-server-v2"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { isWslServer, useFilteredWslServers, WslAddServerButton, WslServerSettings } from "@/wsl/settings"
|
||||
import "./settings-v2.css"
|
||||
|
||||
export const SettingsServersV2: Component = () => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const controller = useServerManagementController()
|
||||
const [store, setStore] = createStore({ filter: "" })
|
||||
const wslServers = useFilteredWslServers(() => store.filter)
|
||||
|
||||
const showSearch = createMemo(
|
||||
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||
)
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const items = controller.sortedItems().filter((item) => !isWslServer(item))
|
||||
const query = store.filter.trim()
|
||||
if (!query) return items
|
||||
return fuzzysort
|
||||
.go(query, items, {
|
||||
keys: [(item) => serverName(item), (item) => item.http.url],
|
||||
})
|
||||
.map((result) => result.obj)
|
||||
})
|
||||
|
||||
const openAdd = () => {
|
||||
dialog.push(() => <DialogServerV2 mode="add" />)
|
||||
}
|
||||
|
||||
const openEdit = (server: ServerConnection.Http) => {
|
||||
dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
class="settings-v2-tab-header settings-v2-servers-header"
|
||||
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
|
||||
>
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
|
||||
<ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
|
||||
{language.t("dialog.server.add.button")}
|
||||
</ButtonV2>
|
||||
<WslAddServerButton />
|
||||
</div>
|
||||
<Show when={showSearch()}>
|
||||
<div class="settings-v2-tab-search">
|
||||
<TextInputV2
|
||||
type="search"
|
||||
appearance="base"
|
||||
value={store.filter}
|
||||
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
||||
placeholder={language.t("dialog.server.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("dialog.server.search.placeholder")}
|
||||
/>
|
||||
<Show when={store.filter}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-v2-tab-search-clear"
|
||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => setStore("filter", "")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body settings-v2-servers">
|
||||
<Show
|
||||
when={filtered().length > 0 || wslServers().length > 0}
|
||||
fallback={
|
||||
<div class="settings-v2-servers-status">
|
||||
<span>{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")}</span>
|
||||
<Show when={store.filter}>
|
||||
<span class="settings-v2-servers-status-filter">"{store.filter}"</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SettingsListV2>
|
||||
<WslServerSettings controller={controller} servers={wslServers} />
|
||||
<For each={filtered()}>
|
||||
{(item) => {
|
||||
const key = ServerConnection.key(item)
|
||||
const health = () => controller.status()[key]
|
||||
const isDefault = () => controller.defaultKey() === key
|
||||
return (
|
||||
<div class="settings-v2-servers-row">
|
||||
<div class="settings-v2-servers-lead">
|
||||
<ServerHealthIndicator health={health()} />
|
||||
<div class="settings-v2-servers-copy">
|
||||
<span class="settings-v2-servers-name">{serverName(item)}</span>
|
||||
<span class="settings-v2-servers-meta">
|
||||
<Show when={health()?.version}>v{health()?.version}</Show>
|
||||
<Show when={health()?.version && item.type === "http"}> • </Show>
|
||||
<Show
|
||||
when={item.type === "http" && item.http.username}
|
||||
fallback={<Show when={item.type === "http"}>{language.t("server.row.noUsername")}</Show>}
|
||||
>
|
||||
{item.http.username}
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-v2-servers-actions">
|
||||
<Show when={controller.canDefault() && isDefault()}>
|
||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||
</Show>
|
||||
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SettingsListV2>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -511,3 +511,144 @@
|
||||
.settings-v2-shortcuts-status-filter {
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-tab-body.settings-v2-servers {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.settings-v2-tab-header.settings-v2-servers-header {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings-v2-servers-header .settings-v2-tab-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-tab-header.settings-v2-servers-header.settings-v2-tab-header--stacked {
|
||||
gap: 24px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings-v2-servers [data-component="settings-v2-list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.settings-v2-servers-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-servers-row:not(:last-child) {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-servers-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-v2-servers-lead {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-v2-servers-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settings-v2-servers-name {
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-servers-meta {
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-v2-servers-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding-block: 48px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-v2-servers-status-filter {
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
|
||||
width: 480px;
|
||||
max-width: calc(100vw - 32px);
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-content"] {
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] {
|
||||
align-items: center;
|
||||
padding: 24px 24px 0;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-footer"] {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.settings-v2-server-dialog-label {
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-server-dialog-error {
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-state-fg-danger);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
@@ -11,16 +10,12 @@ import { createStore } from "solid-js/store"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { type ServerHealth } from "@/utils/server-health"
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useSettings } from "@/context/settings"
|
||||
|
||||
const pollMs = 10_000
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
@@ -60,7 +55,7 @@ const useDefaultServerKey = (
|
||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||
) => {
|
||||
const [state, setState] = createStore({
|
||||
url: undefined as string | undefined,
|
||||
key: undefined as ServerConnection.Key | undefined,
|
||||
tick: 0,
|
||||
})
|
||||
|
||||
@@ -69,7 +64,7 @@ const useDefaultServerKey = (
|
||||
let dead = false
|
||||
const result = get?.()
|
||||
if (!result) {
|
||||
setState("url", undefined)
|
||||
setState("key", undefined)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
@@ -79,7 +74,7 @@ const useDefaultServerKey = (
|
||||
if (result instanceof Promise) {
|
||||
void result.then((next) => {
|
||||
if (dead) return
|
||||
setState("url", next ? normalizeServerUrl(next) : undefined)
|
||||
setState("key", next ?? undefined)
|
||||
})
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
@@ -87,7 +82,7 @@ const useDefaultServerKey = (
|
||||
return
|
||||
}
|
||||
|
||||
setState("url", normalizeServerUrl(result))
|
||||
setState("key", ServerConnection.Key.make(result))
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
@@ -95,45 +90,12 @@ const useDefaultServerKey = (
|
||||
|
||||
return {
|
||||
key: () => {
|
||||
const u = state.url
|
||||
if (!u) return
|
||||
return ServerConnection.key({ type: "http", http: { url: u } })
|
||||
return state.key
|
||||
},
|
||||
refresh: () => setState("tick", (value) => value + 1),
|
||||
}
|
||||
}
|
||||
|
||||
const useMcpToggleMutation = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
const queryClient = useQueryClient()
|
||||
const queryOptions = useQueryOptions()
|
||||
|
||||
return useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
return
|
||||
}
|
||||
if (status?.status === "needs_auth") {
|
||||
await sdk.client.mcp.auth.authenticate({ name })
|
||||
return
|
||||
}
|
||||
await sdk.client.mcp.connect({ name })
|
||||
},
|
||||
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
|
||||
onError: (err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
type ServerStatusState = {
|
||||
servers: () => ServerStatusItem[]
|
||||
defaultKey: () => ServerConnection.Key | undefined
|
||||
@@ -160,7 +122,6 @@ export function StatusPopoverServerBody() {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
let dialogRun = 0
|
||||
let dialogDead = false
|
||||
onCleanup(() => {
|
||||
@@ -316,7 +277,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
dialogRun += 1
|
||||
})
|
||||
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
|
||||
const toggleMcp = useMcpToggleMutation()
|
||||
const toggleMcp = useMcpToggle()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
|
||||
@@ -280,7 +280,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
|
||||
const matchRoute = (route: LayoutRoute) => {
|
||||
if (route.type === "home") return
|
||||
if (route.type === "dir-new-sesssion") {
|
||||
if (route.type === "draft") {
|
||||
return tabsStore.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||
}
|
||||
if (route.type === "session") {
|
||||
const main = tabsStore.find(
|
||||
@@ -447,11 +448,33 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
refreshTabsAreOverflowing()
|
||||
})
|
||||
|
||||
const divider = () =>
|
||||
i() !== 0 && (
|
||||
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
||||
)
|
||||
|
||||
if (tab.type === "draft") {
|
||||
return (
|
||||
<>
|
||||
{divider()}
|
||||
<DraftTabItem
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
title={language.t("command.session.new")}
|
||||
active={currentTab() === tab}
|
||||
onNavigate={() => {
|
||||
navigateTab(tab)
|
||||
ref.scrollIntoView({ behavior: "instant" })
|
||||
}}
|
||||
onClose={() => tabsStoreActions.removeTab(i())}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{i() !== 0 && (
|
||||
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
||||
)}
|
||||
{divider()}
|
||||
<TabNavItem
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
@@ -782,7 +805,6 @@ function TabNavItem(props: {
|
||||
>
|
||||
<Show when={session.latest}>
|
||||
{(session) => {
|
||||
console.log({ session: session() })
|
||||
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
|
||||
|
||||
return (
|
||||
@@ -851,6 +873,59 @@ function ProjectTabAvatar(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function DraftTabItem(props: {
|
||||
ref?: HTMLDivElement
|
||||
href: string
|
||||
title: string
|
||||
active?: boolean
|
||||
onNavigate: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={props.ref}
|
||||
data-active={props.active}
|
||||
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={props.href}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
|
||||
<IconV2 name="edit" />
|
||||
</span>
|
||||
<span class="truncate leading-5">{props.title}</span>
|
||||
</a>
|
||||
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label="Close tab"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: string; onClose: () => void }) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { updaterAction } from "./updater-action"
|
||||
|
||||
describe("updaterAction", () => {
|
||||
test("disables update actions when the platform has no updater", () => {
|
||||
expect(updaterAction(undefined)).toEqual({ label: "settings.updates.action.checkNow" })
|
||||
})
|
||||
|
||||
test("projects updater transitions into one settings action", () => {
|
||||
expect(updaterAction({ status: "idle" })).toEqual({
|
||||
label: "settings.updates.action.checkNow",
|
||||
run: "check",
|
||||
})
|
||||
expect(updaterAction({ status: "checking" })).toEqual({ label: "settings.updates.action.checking" })
|
||||
expect(updaterAction({ status: "downloading", version: "2.0.0" })).toEqual({
|
||||
label: "settings.updates.action.downloading",
|
||||
})
|
||||
expect(updaterAction({ status: "ready", version: "2.0.0" })).toEqual({
|
||||
label: "toast.update.action.installRestart",
|
||||
run: "install",
|
||||
})
|
||||
expect(updaterAction({ status: "installing", version: "2.0.0" })).toEqual({
|
||||
label: "settings.updates.action.installing",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import type { UpdaterState } from "@/updater"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
export function updaterAction(state: UpdaterState | undefined) {
|
||||
if (!state) return { label: "settings.updates.action.checkNow" as const }
|
||||
switch (state.status) {
|
||||
case "checking":
|
||||
return { label: "settings.updates.action.checking" as const }
|
||||
case "downloading":
|
||||
return { label: "settings.updates.action.downloading" as const }
|
||||
case "ready":
|
||||
return { label: "toast.update.action.installRestart" as const, run: "install" as const }
|
||||
case "installing":
|
||||
return { label: "settings.updates.action.installing" as const }
|
||||
case "disabled":
|
||||
return { label: "settings.updates.action.checkNow" as const }
|
||||
default:
|
||||
return { label: "settings.updates.action.checkNow" as const, run: "check" as const }
|
||||
}
|
||||
}
|
||||
|
||||
export function useUpdaterAction() {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const action = createMemo(() => updaterAction(platform.updater?.state()))
|
||||
|
||||
return {
|
||||
action,
|
||||
async run() {
|
||||
const run = action().run
|
||||
if (run === "install") return platform.updater?.install()
|
||||
if (run !== "check") return
|
||||
|
||||
const state = await platform.updater?.check()
|
||||
if (state?.status === "up-to-date") {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("settings.updates.toast.latest.title"),
|
||||
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
|
||||
})
|
||||
}
|
||||
if (state?.status === "error") {
|
||||
showToast({ title: language.t("common.requestFailed"), description: state.message })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
useLocation: () => ({}),
|
||||
useSearchParams: () => [{}, () => undefined],
|
||||
}))
|
||||
mock.module("@opencode-ai/ui/context", () => ({
|
||||
createSimpleContext: () => ({
|
||||
|
||||
@@ -609,6 +609,9 @@ export const createDirSyncContext = (
|
||||
)
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
toggle: (name: string) => serverSync.mcp.toggle(directory, name),
|
||||
},
|
||||
absolute,
|
||||
get directory() {
|
||||
return current()[0].path.directory
|
||||
|
||||
@@ -133,6 +133,7 @@ describe("createChildStoreManager", () => {
|
||||
const [store] = manager.child("/project")
|
||||
|
||||
expect(store.status).toBe("loading")
|
||||
expect(store.limit).toBe(5)
|
||||
expect(bootstraps).toEqual(["/project"])
|
||||
} finally {
|
||||
dispose()
|
||||
|
||||
@@ -134,6 +134,27 @@ describe("applyGlobalEvent", () => {
|
||||
})
|
||||
|
||||
describe("applyDirectoryEvent", () => {
|
||||
test("preserves a Home-specific retained session limit", () => {
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
limit: 1,
|
||||
session: [rootSession({ id: "a" }), rootSession({ id: "b" }), rootSession({ id: "c" })],
|
||||
}),
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.created", properties: { info: rootSession({ id: "d" }) } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
retainedLimit: 3,
|
||||
})
|
||||
|
||||
expect(store.session).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("inserts root sessions in sorted order and updates sessionTotal", () => {
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
|
||||
@@ -99,8 +99,10 @@ export function applyDirectoryEvent(input: {
|
||||
loadLsp: () => void
|
||||
vcsCache?: VcsCache
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
|
||||
retainedLimit?: number
|
||||
}) {
|
||||
const event = input.event
|
||||
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
|
||||
switch (event.type) {
|
||||
case "server.instance.disposed": {
|
||||
input.push(input.directory)
|
||||
@@ -115,7 +117,7 @@ export function applyDirectoryEvent(input: {
|
||||
}
|
||||
const next = input.store.session.slice()
|
||||
next.splice(result.index, 0, info)
|
||||
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
||||
const trimmed = trimSessions(next, { limit, permission: input.store.permission })
|
||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
||||
@@ -145,7 +147,7 @@ export function applyDirectoryEvent(input: {
|
||||
}
|
||||
const next = input.store.session.slice()
|
||||
next.splice(result.index, 0, info)
|
||||
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
||||
const trimmed = trimSessions(next, { limit, permission: input.store.permission })
|
||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||
break
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { toggleMcp } from "./mcp"
|
||||
|
||||
describe("toggleMcp", () => {
|
||||
test("runs the status action before refreshing the owning query", async () => {
|
||||
const calls: string[] = []
|
||||
const input = (status: "connected" | "needs_auth" | "disabled") => ({
|
||||
status,
|
||||
connect: async () => {
|
||||
calls.push("connect")
|
||||
},
|
||||
disconnect: async () => {
|
||||
calls.push("disconnect")
|
||||
},
|
||||
authenticate: async () => {
|
||||
calls.push("authenticate")
|
||||
},
|
||||
refresh: async () => {
|
||||
calls.push("refresh")
|
||||
},
|
||||
})
|
||||
|
||||
await toggleMcp(input("connected"))
|
||||
expect(calls).toEqual(["disconnect", "refresh"])
|
||||
|
||||
calls.length = 0
|
||||
await toggleMcp(input("needs_auth"))
|
||||
expect(calls).toEqual(["authenticate", "refresh"])
|
||||
|
||||
calls.length = 0
|
||||
await toggleMcp(input("disabled"))
|
||||
expect(calls).toEqual(["connect", "refresh"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { McpStatus } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export async function toggleMcp(input: {
|
||||
status: McpStatus["status"]
|
||||
connect: () => Promise<void>
|
||||
disconnect: () => Promise<void>
|
||||
authenticate: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
}) {
|
||||
await {
|
||||
connected: input.disconnect,
|
||||
needs_auth: input.authenticate,
|
||||
disabled: input.connect,
|
||||
failed: input.connect,
|
||||
needs_client_registration: input.connect,
|
||||
}[input.status]()
|
||||
await input.refresh()
|
||||
}
|
||||
@@ -77,6 +77,7 @@ export type ReviewDiffStyle = "unified" | "split"
|
||||
|
||||
export type LayoutRoute =
|
||||
| { type: "home" }
|
||||
| { type: "draft"; draftID: string; server?: ServerConnection.Key }
|
||||
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
|
||||
| { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key }
|
||||
|
||||
@@ -120,10 +121,16 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
|
||||
}
|
||||
}
|
||||
|
||||
const currentRoute = (pathname: string): LayoutRoute => {
|
||||
const currentRoute = (pathname: string, search: string): LayoutRoute => {
|
||||
const parts = pathname.split("/").filter(Boolean)
|
||||
if (parts.length === 0) return { type: "home" }
|
||||
|
||||
if (parts[0] === "new-session") {
|
||||
const draftID = new URLSearchParams(search).get("draftId")
|
||||
if (!draftID) return { type: "home" }
|
||||
return { type: "draft", draftID }
|
||||
}
|
||||
|
||||
const dirBase64 = parts[0]
|
||||
const dir = decode64(dirBase64)
|
||||
if (!dir) return { type: "home" }
|
||||
@@ -145,7 +152,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const platform = usePlatform()
|
||||
const location = useLocation()
|
||||
const route = createMemo(() => {
|
||||
const value = currentRoute(location.pathname)
|
||||
const value = currentRoute(location.pathname, location.search)
|
||||
if (value.type === "home") return value
|
||||
return { ...value, server: server.key }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
export function useMcpToggle() {
|
||||
const sync = useSync()
|
||||
const language = useLanguage()
|
||||
|
||||
return useMutation(() => ({
|
||||
mutationFn: sync.mcp.toggle,
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -3,12 +3,19 @@ import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { DesktopMenuAction } from "../desktop-menu"
|
||||
import { ServerConnection } from "./server"
|
||||
import type { WslServersPlatform } from "../wsl/types"
|
||||
import type { UpdaterPlatform } from "../updater"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
|
||||
type OpenAttachmentPickerOptions = {
|
||||
title?: string
|
||||
multiple?: boolean
|
||||
accept?: string[]
|
||||
extensions?: string[]
|
||||
defaultPath?: string
|
||||
}
|
||||
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
||||
type UpdateInfo = { updateAvailable: boolean; version?: string }
|
||||
type PlatformName = "web" | "desktop"
|
||||
type DesktopOS = "macos" | "windows" | "linux"
|
||||
|
||||
@@ -20,13 +27,7 @@ export type FatalRendererErrorLog = {
|
||||
os?: DesktopOS
|
||||
}
|
||||
|
||||
export type Platform = {
|
||||
/** Platform discriminator */
|
||||
platform: PlatformName
|
||||
|
||||
/** Desktop OS (Tauri only) */
|
||||
os?: DesktopOS
|
||||
|
||||
type PlatformBase = {
|
||||
/** App version */
|
||||
version?: string
|
||||
|
||||
@@ -48,23 +49,20 @@ export type Platform = {
|
||||
/** Send a system notification (optional deep link) */
|
||||
notify(title: string, description?: string, href?: string): Promise<void>
|
||||
|
||||
/** Open directory picker dialog (native on Tauri, server-backed on web) */
|
||||
openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
|
||||
/** Open a native attachment picker and read selected files sequentially (desktop only) */
|
||||
openAttachmentPickerDialog?(
|
||||
opts: OpenAttachmentPickerOptions,
|
||||
onFile: (file: File) => Promise<unknown>,
|
||||
): Promise<void>
|
||||
|
||||
/** Open native file picker dialog (Tauri only) */
|
||||
openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise<PickerPaths>
|
||||
|
||||
/** Save file picker dialog (Tauri only) */
|
||||
/** Open a native save file picker dialog (desktop only) */
|
||||
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
|
||||
|
||||
/** Storage mechanism, defaults to localStorage */
|
||||
storage?: (name?: string) => SyncStorage | AsyncStorage
|
||||
|
||||
/** Check for a downloadable desktop update */
|
||||
checkUpdate?(): Promise<UpdateInfo>
|
||||
|
||||
/** Install the downloaded update using the platform restart flow */
|
||||
updateAndRestart?(): Promise<void>
|
||||
/** Application-global desktop updater */
|
||||
updater?: UpdaterPlatform
|
||||
|
||||
/** Fetch override */
|
||||
fetch?: typeof fetch
|
||||
@@ -75,11 +73,8 @@ export type Platform = {
|
||||
/** Set the default server URL to use on app startup (platform-specific) */
|
||||
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
|
||||
|
||||
/** Get the configured WSL integration (desktop only) */
|
||||
getWslEnabled?(): Promise<boolean>
|
||||
|
||||
/** Set the configured WSL integration (desktop only) */
|
||||
setWslEnabled?(config: boolean): Promise<void> | void
|
||||
/** Manage WSL sidecar servers (Electron on Windows only) */
|
||||
wslServers?: WslServersPlatform
|
||||
|
||||
/** Get the preferred display backend (desktop only) */
|
||||
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
|
||||
@@ -115,6 +110,16 @@ export type Platform = {
|
||||
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
||||
}
|
||||
|
||||
export type Platform = PlatformBase &
|
||||
(
|
||||
| { platform: "web"; os?: never }
|
||||
| {
|
||||
platform: "desktop"
|
||||
os?: DesktopOS
|
||||
openDirectoryPickerDialog(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
|
||||
}
|
||||
)
|
||||
|
||||
export type DisplayBackend = "auto" | "wayland"
|
||||
|
||||
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
@@ -153,9 +153,11 @@ const MAX_PROMPT_SESSIONS = 20
|
||||
|
||||
type PromptSession = ReturnType<typeof createPromptSession>
|
||||
|
||||
type Scope = {
|
||||
dir: string
|
||||
id?: string
|
||||
type Scope = { draftID: string } | { dir: string; id?: string }
|
||||
|
||||
function scopeKey(scope: Scope) {
|
||||
if ("draftID" in scope) return `draft:${scope.draftID}`
|
||||
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
|
||||
}
|
||||
|
||||
type PromptCacheEntry = {
|
||||
@@ -163,11 +165,15 @@ type PromptCacheEntry = {
|
||||
dispose: VoidFunction
|
||||
}
|
||||
|
||||
function createPromptSession(scope: ServerScope, dir: string, id: string | undefined) {
|
||||
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
|
||||
function promptTarget(serverScope: ServerScope, scope: Scope) {
|
||||
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
|
||||
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
||||
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
||||
}
|
||||
|
||||
function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.serverScoped(scope, dir, id, "prompt", [legacy]),
|
||||
promptTarget(serverScope, scope),
|
||||
createStore<{
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
@@ -231,6 +237,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
gate: false,
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const serverSDK = useServerSDK()
|
||||
const cache = new Map<string, PromptCacheEntry>()
|
||||
|
||||
@@ -254,8 +261,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
const owner = getOwner()
|
||||
const load = (dir: string, id: string | undefined) => {
|
||||
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
||||
const load = (scope: Scope) => {
|
||||
const key = scopeKey(scope)
|
||||
const existing = cache.get(key)
|
||||
if (existing) {
|
||||
cache.delete(key)
|
||||
@@ -265,7 +272,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
|
||||
const entry = createRoot(
|
||||
(dispose) => ({
|
||||
value: createPromptSession(serverSDK.scope, dir, id),
|
||||
value: createPromptSession(serverSDK.scope, scope),
|
||||
dispose,
|
||||
}),
|
||||
owner,
|
||||
@@ -276,8 +283,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
return entry.value
|
||||
}
|
||||
|
||||
const session = createMemo(() => load(params.dir!, params.id))
|
||||
const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session())
|
||||
const session = createMemo(() =>
|
||||
load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }),
|
||||
)
|
||||
const pick = (scope?: Scope) => (scope ? load(scope) : session())
|
||||
|
||||
return {
|
||||
ready: () => session().ready,
|
||||
|
||||
@@ -36,6 +36,7 @@ import { ServerConnection, useServer } from "./server"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -247,17 +248,21 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
|
||||
},
|
||||
})
|
||||
|
||||
async function loadSessions(directory: string) {
|
||||
async function loadSessions(directory: string, options?: { limit?: number }) {
|
||||
const key = directoryKey(directory)
|
||||
const pending = sessionLoads.get(key)
|
||||
if (pending) return pending
|
||||
if (pending) {
|
||||
await pending
|
||||
return loadSessions(directory, options)
|
||||
}
|
||||
|
||||
children.pin(key)
|
||||
const [store, setStore] = children.child(directory, { bootstrap: false })
|
||||
const meta = sessionMeta.get(key)
|
||||
if (meta && meta.limit >= store.limit) {
|
||||
const retainedLimit = Math.max(store.limit, options?.limit ?? 0, meta?.limit ?? 0)
|
||||
if (meta && meta.limit >= retainedLimit) {
|
||||
const next = trimSessions(store.session, {
|
||||
limit: store.limit,
|
||||
limit: retainedLimit,
|
||||
permission: store.permission,
|
||||
})
|
||||
if (next.length !== store.session.length) {
|
||||
@@ -268,7 +273,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
|
||||
return
|
||||
}
|
||||
|
||||
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
|
||||
const limit = Math.max(retainedLimit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
|
||||
const promise = queryClient
|
||||
.fetchQuery({
|
||||
...queryOptionsApi.sessions(key),
|
||||
@@ -283,7 +288,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
|
||||
.filter((s) => !!s?.id)
|
||||
.filter((s) => !s.time?.archived)
|
||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
const limit = store.limit
|
||||
const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0)
|
||||
const childSessions = store.session.filter((s) => !!s.parentID)
|
||||
const sessions = trimSessions([...nonArchived, ...childSessions], {
|
||||
limit,
|
||||
@@ -400,6 +405,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
|
||||
setStore,
|
||||
push: queue.push,
|
||||
setSessionTodo,
|
||||
retainedLimit: sessionMeta.get(key)?.limit,
|
||||
vcsCache: children.vcsCache.get(key),
|
||||
loadLsp: () => {
|
||||
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
|
||||
@@ -476,6 +482,28 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
|
||||
todo: {
|
||||
set: setSessionTodo,
|
||||
},
|
||||
mcp: {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
const key = directoryKey(directory)
|
||||
const sdk = sdkFor(key)
|
||||
const status = children.child(key, { bootstrap: false })[0].mcp[name].status
|
||||
await toggleMcp({
|
||||
status,
|
||||
connect: async () => {
|
||||
await sdk.mcp.connect({ name })
|
||||
},
|
||||
disconnect: async () => {
|
||||
await sdk.mcp.disconnect({ name })
|
||||
},
|
||||
authenticate: async () => {
|
||||
await sdk.mcp.auth.authenticate({ name })
|
||||
},
|
||||
refresh: async () => {
|
||||
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createServerProjects, migrateCanonicalLocalServerState, resolveServerList, ServerConnection } from "./server"
|
||||
import {
|
||||
createServerProjects,
|
||||
migrateCanonicalLocalServerState,
|
||||
nextServerAfterRemoval,
|
||||
resolveServerList,
|
||||
ServerConnection,
|
||||
} from "./server"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
describe("resolveServerList", () => {
|
||||
@@ -55,6 +61,40 @@ describe("resolveServerList", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("treats WSL sidecars as remote server connections", () => {
|
||||
expect(
|
||||
ServerConnection.local({
|
||||
type: "sidecar",
|
||||
variant: "wsl",
|
||||
distro: "Debian",
|
||||
http: { url: "http://127.0.0.1:4097" },
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(ServerConnection.local({ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } })).toBe(
|
||||
true,
|
||||
)
|
||||
expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:4096" } })).toBe(true)
|
||||
expect(ServerConnection.local({ type: "http", http: { url: "https://server.example.test" } })).toBe(false)
|
||||
})
|
||||
|
||||
test("active server removal falls back across built-in and persisted servers", () => {
|
||||
const local = { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } } as const
|
||||
const debian = {
|
||||
type: "sidecar",
|
||||
variant: "wsl",
|
||||
distro: "Debian",
|
||||
http: { url: "http://127.0.0.1:4097" },
|
||||
} as const
|
||||
|
||||
expect(
|
||||
nextServerAfterRemoval(
|
||||
[local, debian],
|
||||
ServerConnection.Key.make("wsl:Debian"),
|
||||
ServerConnection.Key.make("sidecar"),
|
||||
),
|
||||
).toBe(ServerConnection.Key.make("sidecar"))
|
||||
})
|
||||
|
||||
describe("createServerProjects", () => {
|
||||
test("keeps active and explicit server buckets in one reactive store", () => {
|
||||
createRoot((dispose) => {
|
||||
|
||||
@@ -145,7 +145,7 @@ export function resolveServerList(input: {
|
||||
}
|
||||
|
||||
export namespace ServerConnection {
|
||||
type Base = { displayName?: string }
|
||||
type Base = { displayName?: string; label?: string }
|
||||
|
||||
export type HttpBase = {
|
||||
url: string
|
||||
@@ -202,6 +202,20 @@ export namespace ServerConnection {
|
||||
|
||||
export type Key = string & { _brand: "Key" }
|
||||
export const Key = { make: (v: string) => v as Key }
|
||||
|
||||
export const builtin = (conn: Any) => conn.type === "sidecar" && conn.variant === "base"
|
||||
export const local = (conn?: Any) =>
|
||||
!!conn && (builtin(conn) || (conn.type === "http" && isLocalHost(conn.http.url) === "local"))
|
||||
}
|
||||
|
||||
export function nextServerAfterRemoval(
|
||||
servers: ServerConnection.Any[],
|
||||
removed: ServerConnection.Key,
|
||||
fallback: ServerConnection.Key,
|
||||
) {
|
||||
const remaining = servers.filter((server) => ServerConnection.key(server) !== removed)
|
||||
const next = remaining.find((server) => ServerConnection.key(server) === fallback) ?? remaining[0]
|
||||
return next ? ServerConnection.key(next) : fallback
|
||||
}
|
||||
|
||||
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||
@@ -255,13 +269,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
function remove(key: ServerConnection.Key) {
|
||||
const next = nextServerAfterRemoval(allServers(), key, props.defaultServer)
|
||||
const list = store.list.filter((x) => url(x) !== key)
|
||||
batch(() => {
|
||||
setStore("list", list)
|
||||
if (state.active === key) {
|
||||
const next = list[0]
|
||||
setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
|
||||
}
|
||||
if (state.active === key) setState("active", next)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -280,10 +292,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
|
||||
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
|
||||
)
|
||||
const isLocal = createMemo(() => {
|
||||
const c = current()
|
||||
return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url))
|
||||
})
|
||||
const isLocal = createMemo(() => ServerConnection.local(current()))
|
||||
|
||||
return {
|
||||
ready: isReady,
|
||||
|
||||
@@ -35,9 +35,6 @@ export interface Settings {
|
||||
showCustomAgents: boolean
|
||||
newLayoutDesigns?: boolean
|
||||
}
|
||||
updates: {
|
||||
startup: boolean
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
mono: string
|
||||
@@ -122,9 +119,6 @@ const defaultSettings: Settings = {
|
||||
showSessionProgressBar: true,
|
||||
showCustomAgents: false,
|
||||
},
|
||||
updates: {
|
||||
startup: true,
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
mono: "",
|
||||
@@ -250,12 +244,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setStore("general", "newLayoutDesigns", value)
|
||||
},
|
||||
},
|
||||
updates: {
|
||||
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
|
||||
setStartup(value: boolean) {
|
||||
setStore("updates", "startup", value)
|
||||
},
|
||||
},
|
||||
appearance: {
|
||||
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
||||
setFontSize(value: number) {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { createEffect, startTransition } from "solid-js"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { usePlatform } from "./platform"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||
|
||||
export type SessionTab = {
|
||||
@@ -13,16 +17,37 @@ export type SessionTab = {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export type Tab = SessionTab
|
||||
export type DraftTab = {
|
||||
type: "draft"
|
||||
draftID: string
|
||||
server: ServerConnection.Key
|
||||
directory: string
|
||||
worktree?: string
|
||||
}
|
||||
|
||||
export const tabHref = (tab: Tab) => `/${tab.dirBase64}/session/${tab.sessionId}`
|
||||
export const tabKey = (tab: Tab) => `${tab.server}\n${tabHref(tab)}`
|
||||
export type Tab = SessionTab | DraftTab
|
||||
|
||||
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
|
||||
|
||||
export const tabHref = (tab: Tab) =>
|
||||
tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}`
|
||||
|
||||
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
|
||||
|
||||
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
|
||||
const dirBase64 = base64Encode(session.directory)
|
||||
return tabs.some(
|
||||
(tab) =>
|
||||
tab.type === "session" && tab.server === server && tab.dirBase64 === dirBase64 && tab.sessionId === session.id,
|
||||
)
|
||||
}
|
||||
|
||||
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
name: "Tabs",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const fallback = server.key
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
@@ -40,9 +65,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const closing = new Set<string>()
|
||||
|
||||
const removeDraftPersisted = (draftID: string) => {
|
||||
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
const servers = new Set(server.list.map(ServerConnection.key))
|
||||
@@ -73,10 +103,48 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
}),
|
||||
)
|
||||
},
|
||||
draft(draftID: string) {
|
||||
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
|
||||
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
|
||||
return tab
|
||||
},
|
||||
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
|
||||
const draftID = uuid()
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs.push({ type: "draft", draftID, ...draft })
|
||||
}),
|
||||
)
|
||||
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
|
||||
},
|
||||
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
||||
setStore(
|
||||
(tab) => tab.type === "draft" && tab.draftID === draftID,
|
||||
produce((tab) => Object.assign(tab, draft)),
|
||||
)
|
||||
},
|
||||
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
|
||||
// We're viewing this draft when /new-session?draftId=… points at it. Promoting
|
||||
// replaces the draft tab with a session tab, so the draft route would stop resolving
|
||||
// and fall back home. Navigate to the new session first so we leave /new-session
|
||||
// before the draft is removed from the store.
|
||||
const active = location.pathname === "/new-session" && location.query.draftId === draftID
|
||||
startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
|
||||
if (index !== -1) tabs[index] = { type: "session", ...session }
|
||||
}),
|
||||
)
|
||||
if (active) navigateTab({ type: "session", ...session })
|
||||
})
|
||||
removeDraftPersisted(draftID)
|
||||
},
|
||||
removeTab: (index: number) => {
|
||||
const tab = store[index]
|
||||
if (!tab) return
|
||||
const key = tabKey(tab)
|
||||
const draftID = tab.type === "draft" ? tab.draftID : undefined
|
||||
const nextTab = store[index + 1] ?? store[index - 1]
|
||||
closing.add(key)
|
||||
void startTransition(() => {
|
||||
@@ -88,9 +156,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
if (nextTab) navigateTab(nextTab)
|
||||
else navigate("/")
|
||||
}).finally(() => closing.delete(key))
|
||||
if (draftID) removeDraftPersisted(draftID)
|
||||
},
|
||||
removeServer(key: ServerConnection.Key) {
|
||||
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
|
||||
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
|
||||
for (const draftID of drafts) removeDraftPersisted(draftID)
|
||||
if (server.key === key) navigate("/")
|
||||
},
|
||||
removeSessions: (input: SessionTabsRemovedDetail) => {
|
||||
@@ -100,7 +171,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const sessionIDs = new Set(input.sessionIDs)
|
||||
const currentHref =
|
||||
params.dir && params.id
|
||||
? tabHref({ type: "session", server: server.key, dirBase64: params.dir, sessionId: params.id })
|
||||
? tabHref({
|
||||
type: "session",
|
||||
server: server.key,
|
||||
dirBase64: params.dir,
|
||||
sessionId: params.id,
|
||||
})
|
||||
: undefined
|
||||
const currentIndex = currentHref
|
||||
? tabs.findIndex(
|
||||
|
||||
@@ -9,6 +9,8 @@ beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
useLocation: () => ({}),
|
||||
useSearchParams: () => [{}, () => undefined],
|
||||
}))
|
||||
mock.module("@opencode-ai/ui/context", () => ({
|
||||
createSimpleContext: () => ({
|
||||
|
||||
@@ -349,6 +349,59 @@ export const dict = {
|
||||
"dialog.server.menu.delete": "Delete",
|
||||
"dialog.server.current": "Current Server",
|
||||
"dialog.server.status.default": "Default",
|
||||
"wsl.server.add": "Add WSL server",
|
||||
"wsl.server.addShort": "Add WSL",
|
||||
"wsl.server.label": "WSL",
|
||||
"wsl.server.menu.label": "WSL server",
|
||||
"wsl.server.retryStart": "Retry start",
|
||||
"wsl.server.updating": "Updating...",
|
||||
"wsl.onboarding.step.distro": "Choose distro",
|
||||
"wsl.onboarding.step.opencode": "OpenCode",
|
||||
"wsl.onboarding.checkingRuntime": "Checking WSL...",
|
||||
"wsl.onboarding.restartRequired": "Windows needs a restart to finish installing WSL.",
|
||||
"wsl.onboarding.ready": "WSL is ready.",
|
||||
"wsl.onboarding.required": "WSL is required to continue.",
|
||||
"wsl.onboarding.checkingDistros": "Checking distros...",
|
||||
"wsl.onboarding.installingDistro": "Installing {{distro}}...",
|
||||
"wsl.onboarding.checkingDistro": "Checking {{distro}}...",
|
||||
"wsl.onboarding.listingDistros": "Listing distros...",
|
||||
"wsl.onboarding.distroReady": "{{distro}} is ready.",
|
||||
"wsl.onboarding.distroNotInstalled": "{{distro}} is not installed yet.",
|
||||
"wsl.onboarding.openDistroOnce": "Open {{distro}} once to finish setup.",
|
||||
"wsl.onboarding.finishingDistro": "Finishing setup for {{distro}}.",
|
||||
"wsl.onboarding.pickDistro": "Pick a distro or install one below.",
|
||||
"wsl.onboarding.checkingOpencode": "Checking OpenCode...",
|
||||
"wsl.onboarding.checkingOpencodeIn": "Checking OpenCode in {{distro}}...",
|
||||
"wsl.onboarding.updatingOpencode": "Updating OpenCode...",
|
||||
"wsl.onboarding.updatingOpencodeIn": "Updating OpenCode in {{distro}}...",
|
||||
"wsl.onboarding.updateOpencodeIn": "Update OpenCode in {{distro}}.",
|
||||
"wsl.onboarding.updateOpencode": "Update OpenCode",
|
||||
"wsl.onboarding.opencodeReadyIn": "OpenCode is ready in {{distro}}.",
|
||||
"wsl.onboarding.opencodeReady": "OpenCode is ready.",
|
||||
"wsl.onboarding.installOpencodeIn": "Install OpenCode in {{distro}}.",
|
||||
"wsl.onboarding.installOpencode": "Install OpenCode",
|
||||
"wsl.onboarding.chooseDistroFirst": "Choose a distro first.",
|
||||
"wsl.onboarding.loadFailed": "Failed to load WSL state.",
|
||||
"wsl.onboarding.loading": "Loading...",
|
||||
"wsl.onboarding.installWsl": "Install WSL",
|
||||
"wsl.onboarding.windowsRestartRequired": "Restart Windows to finish installing WSL, then reopen OpenCode.",
|
||||
"wsl.onboarding.next": "Next",
|
||||
"wsl.onboarding.refresh": "Refresh",
|
||||
"wsl.onboarding.allDistrosAdded": "All installed distros are already added.",
|
||||
"wsl.onboarding.noDistros": "No distros detected yet.",
|
||||
"wsl.onboarding.install": "Install",
|
||||
"wsl.onboarding.installing": "Installing...",
|
||||
"wsl.onboarding.installDistro": "Install distro",
|
||||
"wsl.onboarding.wsl2Required": "WSL 2 is required.",
|
||||
"wsl.onboarding.toolsRequired": "This distro needs bash and curl.",
|
||||
"wsl.onboarding.openTerminal": "Open terminal",
|
||||
"wsl.onboarding.path": "Path: {{path}}",
|
||||
"wsl.onboarding.notFound": "not found",
|
||||
"wsl.onboarding.version": "Version: {{version}}",
|
||||
"wsl.onboarding.unknown": "unknown",
|
||||
"wsl.onboarding.desktopVersion": "desktop {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Installed version does not match the desktop app version.",
|
||||
"wsl.onboarding.adding": "Adding...",
|
||||
"server.row.noUsername": "no username",
|
||||
|
||||
"dialog.project.edit.title": "Edit project",
|
||||
@@ -813,6 +866,8 @@ export const dict = {
|
||||
"settings.updates.row.check.description": "Manually check for updates and install if available",
|
||||
"settings.updates.action.checkNow": "Check now",
|
||||
"settings.updates.action.checking": "Checking...",
|
||||
"settings.updates.action.downloading": "Downloading...",
|
||||
"settings.updates.action.installing": "Installing...",
|
||||
"settings.updates.toast.latest.title": "You're up to date",
|
||||
"settings.updates.toast.latest.description": "You're running the latest version of OpenCode.",
|
||||
"sound.option.none": "None",
|
||||
|
||||
@@ -2,6 +2,22 @@ export { AppBaseProviders, AppInterface } from "./app"
|
||||
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
||||
export { useCommand } from "./context/command"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
||||
export { useWslServers } from "./wsl/context"
|
||||
export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
|
||||
export { type UpdaterPlatform, type UpdaterState } from "./updater"
|
||||
export {
|
||||
type WslDistroProbe,
|
||||
type WslInstalledDistro,
|
||||
type WslJob,
|
||||
type WslOnlineDistro,
|
||||
type WslOpencodeCheck,
|
||||
type WslRuntimeCheck,
|
||||
type WslServerConfig,
|
||||
type WslServerItem,
|
||||
type WslServerRuntime,
|
||||
type WslServersEvent,
|
||||
type WslServersPlatform,
|
||||
type WslServersState,
|
||||
} from "./wsl/types"
|
||||
export { ServerConnection } from "./context/server"
|
||||
export { handleNotificationClick } from "./utils/notification-click"
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useSync } from "@/context/sync"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Schema } from "effect"
|
||||
|
||||
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
export function DirectoryDataProvider(props: ParentProps<{ directory: string; draftID?: string }>) {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
@@ -18,6 +18,8 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
const slug = createMemo(() => base64Encode(props.directory))
|
||||
|
||||
createEffect(() => {
|
||||
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
|
||||
if (props.draftID) return
|
||||
const next = sync.data.path.directory
|
||||
if (!next || next === props.directory) return
|
||||
const path = location.pathname.slice(slug().length + 1)
|
||||
|
||||
@@ -225,8 +225,6 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
const formattedError = () => formatError(props.error, language.t)
|
||||
let recordedFatalError: Promise<void> | undefined
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
version: undefined as string | undefined,
|
||||
actionError: undefined as string | undefined,
|
||||
})
|
||||
|
||||
@@ -247,32 +245,25 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
})
|
||||
|
||||
async function checkForUpdates() {
|
||||
if (!platform.checkUpdate) return
|
||||
setStore("checking", true)
|
||||
await platform
|
||||
.checkUpdate()
|
||||
.then((result) => {
|
||||
setStore("actionError", undefined)
|
||||
if (result.updateAvailable && result.version) setStore("version", result.version)
|
||||
})
|
||||
.catch((err) => {
|
||||
setStore("actionError", formatError(err, language.t))
|
||||
})
|
||||
.finally(() => {
|
||||
setStore("checking", false)
|
||||
})
|
||||
const state = await platform.updater?.check()
|
||||
setStore("actionError", state?.status === "error" ? state.message : undefined)
|
||||
}
|
||||
|
||||
async function installUpdate() {
|
||||
if (!platform.updateAndRestart) return
|
||||
await platform
|
||||
.updateAndRestart()
|
||||
await platform.updater
|
||||
?.install()
|
||||
.then(() => setStore("actionError", undefined))
|
||||
.catch((err) => {
|
||||
setStore("actionError", formatError(err, language.t))
|
||||
})
|
||||
}
|
||||
|
||||
const updateVersion = () => {
|
||||
const state = platform.updater?.state()
|
||||
if (state?.status !== "ready") return
|
||||
return state.version
|
||||
}
|
||||
|
||||
async function exportDebugLogs() {
|
||||
const exportLogs = platform.exportDebugLogs
|
||||
if (!exportLogs) return
|
||||
@@ -327,20 +318,27 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
<Show when={platform.checkUpdate}>
|
||||
<Show when={platform.updater}>
|
||||
<Show
|
||||
when={store.version}
|
||||
when={updateVersion()}
|
||||
fallback={
|
||||
<Button size="large" variant="ghost" onClick={checkForUpdates} disabled={store.checking}>
|
||||
{store.checking
|
||||
<Button
|
||||
size="large"
|
||||
variant="ghost"
|
||||
onClick={checkForUpdates}
|
||||
disabled={["checking", "downloading", "installing"].includes(platform.updater?.state().status ?? "")}
|
||||
>
|
||||
{platform.updater?.state().status === "checking"
|
||||
? language.t("error.page.action.checking")
|
||||
: language.t("error.page.action.checkUpdates")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button size="large" onClick={installUpdate}>
|
||||
{language.t("error.page.action.updateTo", { version: store.version ?? "" })}
|
||||
</Button>
|
||||
{(version) => (
|
||||
<Button size="large" onClick={installUpdate}>
|
||||
{language.t("error.page.action.updateTo", { version: version() })}
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
+167
-187
@@ -6,12 +6,12 @@ import { useQuery } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TabStateIndicator } from "@opencode-ai/ui/v2/tab-state-indicator"
|
||||
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
@@ -19,41 +19,42 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { DateTime } from "luxon"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import {
|
||||
closeHomeProject,
|
||||
displayName,
|
||||
getProjectAvatarSource,
|
||||
homeProjectDirectories,
|
||||
homeProjectNavigation,
|
||||
homeSessionServerStatus,
|
||||
type HomeProjectSelection,
|
||||
projectForSession,
|
||||
sortedRootSessions,
|
||||
toggleHomeProjectSelection,
|
||||
} from "@/pages/layout/helpers"
|
||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { ServerRowMenu } from "@/components/server/server-row-menu"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { type ServerHealth } from "@/utils/server-health"
|
||||
|
||||
const HOME_SESSION_LIMIT = 15
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
const HOME_ROW_LAYOUT =
|
||||
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
|
||||
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
|
||||
const HOME_ROW = `${HOME_ROW_BASE} [font-weight:530] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover`
|
||||
const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:bg-v2-background-bg-layer-02 data-[selected]:text-v2-text-text-base data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-02 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]`
|
||||
const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-03 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]`
|
||||
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
|
||||
|
||||
type HomeSessionRecord = {
|
||||
@@ -62,8 +63,6 @@ type HomeSessionRecord = {
|
||||
projectName: string
|
||||
}
|
||||
|
||||
type HomeSessionSync = Pick<ReturnType<typeof useServerSync>, "child">
|
||||
|
||||
type HomeSessionGroup = {
|
||||
id: "today" | "yesterday" | "older"
|
||||
title: string
|
||||
@@ -110,53 +109,6 @@ function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) {
|
||||
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query)
|
||||
}
|
||||
|
||||
function createHomeSessionStatus(input: {
|
||||
record: () => HomeSessionRecord
|
||||
sync: () => HomeSessionSync
|
||||
activeServer: () => boolean
|
||||
}) {
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
const sessionStore = createMemo(() => input.sync().child(input.record().session.directory, { bootstrap: false })[0])
|
||||
const unseenCount = createMemo(() =>
|
||||
input.activeServer() ? notification.session.unseenCount(input.record().session.id) : 0,
|
||||
)
|
||||
const hasError = createMemo(
|
||||
() => input.activeServer() && notification.session.unseenHasError(input.record().session.id),
|
||||
)
|
||||
const hasPermissions = createMemo(
|
||||
() =>
|
||||
input.activeServer() &&
|
||||
!!sessionPermissionRequest(
|
||||
sessionStore().session,
|
||||
sessionStore().permission,
|
||||
input.record().session.id,
|
||||
(item) => {
|
||||
return !permission.autoResponds(item, input.record().session.directory)
|
||||
},
|
||||
),
|
||||
)
|
||||
const serverStatus = createMemo(() =>
|
||||
homeSessionServerStatus(input.activeServer(), () => ({
|
||||
working: sessionStore().session_working(input.record().session.id),
|
||||
tint: messageAgentColor(sessionStore().message[input.record().session.id], sessionStore().agent),
|
||||
})),
|
||||
)
|
||||
const isWorking = createMemo(() => {
|
||||
if (hasPermissions()) return false
|
||||
return serverStatus().working
|
||||
})
|
||||
const tint = createMemo(() => serverStatus().tint)
|
||||
return {
|
||||
unseenCount,
|
||||
hasError,
|
||||
hasPermissions,
|
||||
isWorking,
|
||||
tint,
|
||||
show: createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0),
|
||||
}
|
||||
}
|
||||
|
||||
function homeSessionSearchKey(record: HomeSessionRecord) {
|
||||
return `${pathKey(record.session.directory)}:${record.session.id}`
|
||||
}
|
||||
@@ -174,6 +126,7 @@ function HomeDesign() {
|
||||
const sync = useServerSync()
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
@@ -215,7 +168,11 @@ function HomeDesign() {
|
||||
const sessionLoad = useQuery(() => ({
|
||||
queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const,
|
||||
queryFn: async () => {
|
||||
await Promise.all(projectDirectories().map((directory) => focusedSync().project.loadSessions(directory)))
|
||||
await Promise.all(
|
||||
projectDirectories().map((directory) =>
|
||||
focusedSync().project.loadSessions(directory, { limit: HOME_SESSION_LIMIT }),
|
||||
),
|
||||
)
|
||||
return null
|
||||
},
|
||||
}))
|
||||
@@ -359,26 +316,19 @@ function HomeDesign() {
|
||||
navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`)
|
||||
}
|
||||
|
||||
async function chooseProject(conn: ServerConnection.Any) {
|
||||
function chooseProject(conn: ServerConnection.Any) {
|
||||
function resolve(result: string | string[] | null) {
|
||||
addProjects(conn, homeProjectDirectories(result))
|
||||
}
|
||||
|
||||
const server = global.createServerCtx(conn)
|
||||
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
})
|
||||
resolve(result)
|
||||
return
|
||||
}
|
||||
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: resolve,
|
||||
})
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
@@ -388,7 +338,7 @@ function HomeDesign() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 bg-v2-background-bg-base self-stretch flex-1">
|
||||
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
|
||||
<div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]">
|
||||
<HomeProjectColumn
|
||||
projects={projects()}
|
||||
@@ -414,14 +364,17 @@ function HomeDesign() {
|
||||
language={language}
|
||||
/>
|
||||
|
||||
<section class="min-w-0 flex-1 flex flex-col pt-12" aria-label={language.t("sidebar.project.recentSessions")}>
|
||||
<section
|
||||
class="min-h-0 min-w-0 flex-1 flex flex-col pt-12"
|
||||
aria-label={language.t("sidebar.project.recentSessions")}
|
||||
>
|
||||
<HomeSessionSearch
|
||||
value={state.search}
|
||||
placeholder={language.t("home.sessions.search.placeholder")}
|
||||
open={searchOpen()}
|
||||
loading={sessionLoad.isLoading}
|
||||
results={searchResults()}
|
||||
sync={focusedSync()}
|
||||
server={state.selection.server}
|
||||
activeServer={state.selection.server === server.key}
|
||||
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
|
||||
bindFocus={(focus) => {
|
||||
@@ -432,7 +385,7 @@ function HomeDesign() {
|
||||
onClose={closeSearch}
|
||||
onSelect={selectSearchSession}
|
||||
/>
|
||||
<div class="mt-3 min-h-0 flex-1 overflow-y-auto">
|
||||
<ScrollView class="mt-3 min-h-0 flex-1">
|
||||
<div class="pt-3 flex flex-col gap-6">
|
||||
<Show
|
||||
when={!sessionLoad.isLoading}
|
||||
@@ -461,7 +414,7 @@ function HomeDesign() {
|
||||
{(record) => (
|
||||
<HomeSessionRow
|
||||
record={record}
|
||||
sync={focusedSync()}
|
||||
server={state.selection.server}
|
||||
activeServer={state.selection.server === server.key}
|
||||
openSession={openSession}
|
||||
/>
|
||||
@@ -474,7 +427,7 @@ function HomeDesign() {
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -497,6 +450,8 @@ function HomeProjectColumn(props: {
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const dialog = useDialog()
|
||||
const controller = useServerManagementController({ navigateOnAdd: false })
|
||||
return (
|
||||
<aside class="flex min-w-0 flex-col lg:pt-[52px] mt-14 gap-4" aria-label={props.language.t("home.projects")}>
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
|
||||
@@ -524,29 +479,17 @@ function HomeProjectColumn(props: {
|
||||
const serverCtx = global.createServerCtx(item)
|
||||
return (
|
||||
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
|
||||
data-selected={props.selected.server === key && !props.selected.directory ? "" : undefined}
|
||||
disabled={!healthy()}
|
||||
onClick={() => props.focusServer(item)}
|
||||
>
|
||||
<div class="flex size-4 shrink-0 items-center justify-center">
|
||||
<ServerHealthIndicator health={global.servers.health[key]} />
|
||||
</div>
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{item.displayName ?? new URL(item.http.url).host}</span>
|
||||
</button>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 transition-opacity group-hover/server:opacity-100 focus:opacity-100"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
onClick={() => props.chooseProject(item)}
|
||||
/>
|
||||
</div>
|
||||
<HomeServerRow
|
||||
server={item}
|
||||
selected={props.selected.server === key && !props.selected.directory}
|
||||
healthy={healthy()}
|
||||
health={global.servers.health[key]}
|
||||
controller={controller}
|
||||
focusServer={props.focusServer}
|
||||
chooseProject={props.chooseProject}
|
||||
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
|
||||
language={props.language}
|
||||
/>
|
||||
<Show when={healthy()}>
|
||||
<div class="mx-3 h-px bg-v2-border-border-base" />
|
||||
<HomeProjectList {...props} server={item} projects={serverCtx.projects.list()} />
|
||||
@@ -556,7 +499,7 @@ function HomeProjectColumn(props: {
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<div class="mt-4 flex min-w-0 flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||
@@ -578,6 +521,65 @@ function HomeProjectColumn(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function HomeServerRow(props: {
|
||||
server: ServerConnection.Any
|
||||
selected: boolean
|
||||
healthy: boolean
|
||||
health: ServerHealth | undefined
|
||||
controller: ReturnType<typeof useServerManagementController>
|
||||
focusServer: (server: ServerConnection.Any) => void
|
||||
chooseProject: (server: ServerConnection.Any) => void
|
||||
openEdit: (server: ServerConnection.Http) => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const [state, setState] = createStore({ menuOpen: false })
|
||||
return (
|
||||
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
disabled={!props.healthy}
|
||||
onClick={() => props.focusServer(props.server)}
|
||||
>
|
||||
<div class="flex size-4 shrink-0 items-center justify-center">
|
||||
<ServerHealthIndicator health={props.health} />
|
||||
</div>
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span>
|
||||
<Show when={props.server.label}>
|
||||
{(label) => (
|
||||
<span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted">
|
||||
{label()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
data-menu={state.menuOpen}
|
||||
>
|
||||
<ServerRowMenu
|
||||
server={props.server}
|
||||
controller={props.controller}
|
||||
onEdit={props.openEdit}
|
||||
open={state.menuOpen}
|
||||
onOpenChange={(open) => setState("menuOpen", open)}
|
||||
/>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
onClick={() => props.chooseProject(props.server)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectList(props: {
|
||||
server: ServerConnection.Any
|
||||
projects: LocalProject[]
|
||||
@@ -705,13 +707,50 @@ function HomeProjectAvatar(props: { project: LocalProject }) {
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionAvatar(props: { project: LocalProject; session: Session; activeServer: boolean }) {
|
||||
const directory = () => props.session.directory
|
||||
const sessionId = () => props.session.id
|
||||
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
|
||||
return (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
unread={state.unread()}
|
||||
loading={state.loading()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionLeading(props: {
|
||||
project: LocalProject
|
||||
session: Session
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
}) {
|
||||
const tabs = useTabs()
|
||||
const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
|
||||
return (
|
||||
<div class="relative shrink-0">
|
||||
<Show when={hasOpenTab()}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute top-1/2 h-[7px] w-[3px] -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
|
||||
style={{ right: "calc(100% + 12px)" }}
|
||||
/>
|
||||
</Show>
|
||||
<HomeSessionAvatar project={props.project} session={props.session} activeServer={props.activeServer} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionSearch(props: {
|
||||
value: string
|
||||
placeholder: string
|
||||
open: boolean
|
||||
loading: boolean
|
||||
results: HomeSessionRecord[]
|
||||
sync: HomeSessionSync
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
noResultsLabel: string
|
||||
bindFocus: (focus: () => void) => void
|
||||
@@ -827,7 +866,7 @@ function HomeSessionSearch(props: {
|
||||
{(record) => (
|
||||
<HomeSessionSearchResultRow
|
||||
record={record}
|
||||
sync={props.sync}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
selected={store.active === homeSessionSearchKey(record)}
|
||||
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
|
||||
@@ -846,7 +885,7 @@ function HomeSessionSearch(props: {
|
||||
<label
|
||||
class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out"
|
||||
classList={{
|
||||
"bg-v2-background-bg-deep focus-within:bg-v2-background-bg-base focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]":
|
||||
"bg-v2-background-bg-layer-03 focus-within:bg-v2-background-bg-layer-03 focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]":
|
||||
!props.open,
|
||||
"bg-transparent shadow-[0_0_0_0.5px_var(--v2-border-border-focus)]": props.open,
|
||||
}}
|
||||
@@ -913,17 +952,12 @@ function HomeSessionSearch(props: {
|
||||
|
||||
function HomeSessionSearchResultRow(props: {
|
||||
record: HomeSessionRecord
|
||||
sync: HomeSessionSync
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
selected: boolean
|
||||
onHighlight: () => void
|
||||
onSelect: (session: Session) => void
|
||||
}) {
|
||||
const status = createHomeSessionStatus({
|
||||
record: () => props.record,
|
||||
sync: () => props.sync,
|
||||
activeServer: () => props.activeServer,
|
||||
})
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
|
||||
const key = () => homeSessionSearchKey(props.record)
|
||||
@@ -943,34 +977,12 @@ function HomeSessionSearchResultRow(props: {
|
||||
onMouseEnter={() => props.onHighlight()}
|
||||
onClick={() => props.onSelect(props.record.session)}
|
||||
>
|
||||
<Show
|
||||
when={status.show()}
|
||||
fallback={
|
||||
<div class="flex size-4 shrink-0 items-center justify-center">
|
||||
<TabStateIndicator />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="flex size-4 shrink-0 items-center justify-center"
|
||||
style={{ color: status.tint() ?? "var(--icon-interactive-base)" }}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={status.isWorking()}>
|
||||
<Spinner class="size-[15px]" />
|
||||
</Match>
|
||||
<Match when={status.hasPermissions()}>
|
||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||
</Match>
|
||||
<Match when={status.hasError()}>
|
||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
</Match>
|
||||
<Match when={status.unseenCount() > 0}>
|
||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Show>
|
||||
<HomeSessionLeading
|
||||
project={props.record.project}
|
||||
session={props.record.session}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<span
|
||||
class={`${HOME_SEARCH_RESULT_TITLE} ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||
@@ -1010,15 +1022,10 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
|
||||
|
||||
function HomeSessionRow(props: {
|
||||
record: HomeSessionRecord
|
||||
sync: HomeSessionSync
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
openSession: (session: Session) => void
|
||||
}) {
|
||||
const status = createHomeSessionStatus({
|
||||
record: () => props.record,
|
||||
sync: () => props.sync,
|
||||
activeServer: () => props.activeServer,
|
||||
})
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
|
||||
return (
|
||||
@@ -1028,34 +1035,12 @@ function HomeSessionRow(props: {
|
||||
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
|
||||
onClick={() => props.openSession(props.record.session)}
|
||||
>
|
||||
<Show
|
||||
when={status.show()}
|
||||
fallback={
|
||||
<div class="flex size-4 shrink-0 items-center justify-center">
|
||||
<TabStateIndicator />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="flex size-4 shrink-0 items-center justify-center"
|
||||
style={{ color: status.tint() ?? "var(--icon-interactive-base)" }}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={status.isWorking()}>
|
||||
<Spinner class="size-[15px]" />
|
||||
</Match>
|
||||
<Match when={status.hasPermissions()}>
|
||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||
</Match>
|
||||
<Match when={status.hasError()}>
|
||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
</Match>
|
||||
<Match when={status.unseenCount() > 0}>
|
||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Show>
|
||||
<HomeSessionLeading
|
||||
project={props.record.project}
|
||||
session={props.record.session}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
<span
|
||||
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||
>
|
||||
@@ -1111,6 +1096,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
|
||||
function LegacyHome() {
|
||||
const sync = useServerSync()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const global = useGlobal()
|
||||
@@ -1138,7 +1124,7 @@ function LegacyHome() {
|
||||
navigate(`/${base64Encode(directory)}`)
|
||||
}
|
||||
|
||||
async function chooseProject() {
|
||||
function chooseProject() {
|
||||
const s = server.current
|
||||
if (!s) return
|
||||
|
||||
@@ -1152,18 +1138,12 @@ function LegacyHome() {
|
||||
}
|
||||
}
|
||||
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
})
|
||||
resolve(result)
|
||||
} else {
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory multiple={true} onSelect={resolve} server={s} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
}
|
||||
pickDirectory({
|
||||
server: s,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: resolve,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { useLayout, LocalProject } from "@/context/layout"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -64,7 +63,9 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { HelpButton } from "@/components/help-button"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useLanguage, type Locale } from "@/context/language"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
@@ -90,7 +91,6 @@ import {
|
||||
} from "./layout/sidebar-workspace"
|
||||
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
||||
import { SidebarContent } from "./layout/sidebar-shell"
|
||||
import { runUpdateAndRestart } from "./layout/update"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const serverSDK = useServerSDK()
|
||||
@@ -119,6 +119,7 @@ export default function Layout(props: ParentProps) {
|
||||
const layout = useLayout()
|
||||
const layoutReady = createMemo(() => layout.ready())
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const settings = useSettings()
|
||||
const server = useServer()
|
||||
const notification = useNotification()
|
||||
@@ -168,28 +169,15 @@ export default function Layout(props: ParentProps) {
|
||||
peeked: false,
|
||||
})
|
||||
|
||||
const [update, setUpdate] = createStore({
|
||||
installing: false,
|
||||
})
|
||||
const updateQuery = useQuery(() => ({
|
||||
queryKey: ["desktop", "update"] as const,
|
||||
enabled: () =>
|
||||
!!platform.checkUpdate && !!platform.updateAndRestart && settings.ready() && settings.updates.startup(),
|
||||
queryFn: () => platform.checkUpdate?.() ?? Promise.resolve({ updateAvailable: false, version: undefined }),
|
||||
refetchInterval: (query) => (query.state.data?.updateAvailable ? false : 10 * 60 * 1000),
|
||||
}))
|
||||
const updateVersion = () => {
|
||||
if (!settings.ready()) return
|
||||
if (!settings.updates.startup()) return
|
||||
if (!updateQuery.data?.updateAvailable) return
|
||||
return updateQuery.data.version ?? ""
|
||||
}
|
||||
const installUpdate = () => {
|
||||
runUpdateAndRestart(platform.updateAndRestart, (installing) => setUpdate("installing", installing))
|
||||
const state = platform.updater?.state()
|
||||
if (state?.status !== "ready") return
|
||||
return state.version
|
||||
}
|
||||
const installUpdate = () => void platform.updater?.install()
|
||||
const titlebarUpdate: TitlebarUpdate = {
|
||||
version: updateVersion,
|
||||
installing: () => update.installing,
|
||||
installing: () => platform.updater?.state().status === "installing",
|
||||
install: installUpdate,
|
||||
}
|
||||
|
||||
@@ -1472,7 +1460,7 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
}
|
||||
|
||||
async function chooseProject() {
|
||||
function chooseProject() {
|
||||
const conn = server.current
|
||||
if (!conn) return
|
||||
function resolve(result: string | string[] | null) {
|
||||
@@ -1486,22 +1474,12 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
})
|
||||
resolve(result)
|
||||
} else {
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-select-directory").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(
|
||||
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
}
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: resolve,
|
||||
})
|
||||
}
|
||||
|
||||
const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
|
||||
@@ -2387,6 +2365,7 @@ export default function Layout(props: ParentProps) {
|
||||
</Show>
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
<HelpButton />
|
||||
<ToastRegion v2={newDesign()} />
|
||||
</div>
|
||||
}
|
||||
@@ -2540,6 +2519,7 @@ export default function Layout(props: ParentProps) {
|
||||
</div>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
</div>
|
||||
<HelpButton />
|
||||
<ToastRegion v2={newDesign()} />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { runUpdateAndRestart } from "./update"
|
||||
|
||||
describe("runUpdateAndRestart", () => {
|
||||
test("clears the installing state when restart resolves without exiting", async () => {
|
||||
const states: boolean[] = []
|
||||
await new Promise<void>((resolve) => {
|
||||
runUpdateAndRestart(
|
||||
async () => {},
|
||||
(installing) => {
|
||||
states.push(installing)
|
||||
if (states.length === 2) resolve()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
expect(states).toEqual([true, false])
|
||||
})
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
export function runUpdateAndRestart(
|
||||
updateAndRestart: (() => Promise<void>) | undefined,
|
||||
setInstalling: (installing: boolean) => void,
|
||||
) {
|
||||
if (!updateAndRestart) return
|
||||
setInstalling(true)
|
||||
void updateAndRestart()
|
||||
.catch(() => undefined)
|
||||
.finally(() => setInstalling(false))
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createEffect, createMemo, onMount, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionDesignView } from "@/components/session"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
||||
|
||||
/**
|
||||
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
|
||||
* composer for a brand-new session — no terminal, review pane, file tree, or message
|
||||
* timeline. Submitting promotes the draft into a real session (see prompt-input/submit).
|
||||
*/
|
||||
export default function NewSessionPage() {
|
||||
const prompt = usePrompt()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const comments = useComments()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
|
||||
let inputRef: HTMLDivElement | undefined
|
||||
|
||||
const composer = createSessionComposerState()
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
worktree: "main",
|
||||
})
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.worktree === "create") return "create"
|
||||
const project = sync.project
|
||||
if (project && sdk.directory !== project.worktree) return sdk.directory
|
||||
return "main"
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
untrack(() => {
|
||||
const text = searchParams.prompt
|
||||
if (!text) return
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||
setSearchParams({ ...searchParams, prompt: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
requestAnimationFrame(() => inputRef?.focus())
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<div class="@container relative flex flex-col min-h-0 h-full bg-background-stronger flex-1">
|
||||
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
|
||||
<NewSessionDesignView>
|
||||
<SessionComposerRegion
|
||||
state={composer}
|
||||
ready
|
||||
centered={false}
|
||||
placement="inline"
|
||||
inputRef={(el) => {
|
||||
inputRef = el
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
|
||||
onSubmit={() => comments.clear()}
|
||||
onResponseSubmit={() => {}}
|
||||
setPromptDockRef={() => {}}
|
||||
/>
|
||||
</NewSessionDesignView>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -31,13 +31,14 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionDesignView, NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useSettings } from "@/context/settings"
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
createSizing,
|
||||
focusTerminalById,
|
||||
shouldFocusTerminalOnKeyDown,
|
||||
shouldShowFileTree,
|
||||
} from "@/pages/session/helpers"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
@@ -61,7 +63,6 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
import { shouldUseV2NewSessionPage } from "@/pages/session/new-session-layout"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { diffs as list } from "@/utils/diffs"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -194,6 +195,7 @@ export default function Page() {
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
const settings = useSettings()
|
||||
const platform = usePlatform()
|
||||
const prompt = usePrompt()
|
||||
const comments = useComments()
|
||||
const terminal = useTerminal()
|
||||
@@ -268,10 +270,16 @@ export default function Page() {
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const size = createSizing()
|
||||
const isV2NewSessionPage = () =>
|
||||
shouldUseV2NewSessionPage({ newLayoutDesigns: newSessionDesign(), sessionID: params.id })
|
||||
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
|
||||
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
|
||||
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
const desktopFileTreeOpen = createMemo(
|
||||
() =>
|
||||
isDesktop() &&
|
||||
shouldShowFileTree({
|
||||
desktopV2: platform.platform === "desktop" && settings.general.newLayoutDesigns(),
|
||||
showFileTree: settings.general.showFileTree(),
|
||||
opened: layout.fileTree.opened(),
|
||||
}),
|
||||
)
|
||||
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
|
||||
const sessionPanelWidth = createMemo(() => {
|
||||
if (!desktopSidePanelOpen()) return "100%"
|
||||
@@ -1745,81 +1753,80 @@ export default function Page() {
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true,
|
||||
"@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!size.active() && !ui.reviewSnap,
|
||||
"transition-[width]": !isV2NewSessionPage(),
|
||||
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
|
||||
}}
|
||||
style={{
|
||||
width: sessionPanelWidth(),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-hidden"
|
||||
classList={{
|
||||
"rounded-[10px]": settings.general.newLayoutDesigns(),
|
||||
"flex-1 min-h-0 flex flex-col bg-background-stronger": true,
|
||||
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
|
||||
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
|
||||
}}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={params.id && mobileChanges()}>
|
||||
<div class="relative h-full overflow-hidden">
|
||||
{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-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={params.id}>
|
||||
<Show when={messagesReady()}>
|
||||
<MessageTimeline
|
||||
actions={actions}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
onUserScroll={markUserScroll}
|
||||
onHistoryScroll={historyLoader.onScrollerScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={() =>
|
||||
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||
}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id && mobileChanges()}>
|
||||
<div class="relative h-full overflow-hidden">
|
||||
{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-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={params.id}>
|
||||
<Show when={messagesReady()}>
|
||||
<MessageTimeline
|
||||
actions={actions}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
onUserScroll={markUserScroll}
|
||||
onHistoryScroll={historyLoader.onScrollerScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={() =>
|
||||
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||
}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
historyShift={historyLoader.shift()}
|
||||
userMessages={historyLoader.userMessages()}
|
||||
anchor={anchor}
|
||||
setRevealMessage={(fn) => {
|
||||
revealMessage = fn
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Show when={newSessionDesign()} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
|
||||
<NewSessionDesignView>{composerRegion("inline")}</NewSessionDesignView>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
historyShift={historyLoader.shift()}
|
||||
userMessages={historyLoader.userMessages()}
|
||||
anchor={anchor}
|
||||
setRevealMessage={(fn) => {
|
||||
revealMessage = fn
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<NewSessionView worktree={newSessionWorktree()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
|
||||
</div>
|
||||
|
||||
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
|
||||
|
||||
<Show when={desktopReviewOpen()}>
|
||||
<div onPointerDown={() => size.start()}>
|
||||
<ResizeHandle
|
||||
|
||||
@@ -8,8 +8,17 @@ import {
|
||||
focusTerminalById,
|
||||
getTabReorderIndex,
|
||||
shouldFocusTerminalOnKeyDown,
|
||||
shouldShowFileTree,
|
||||
} from "./helpers"
|
||||
|
||||
describe("shouldShowFileTree", () => {
|
||||
test("does not reserve space for a disabled v2 file tree", () => {
|
||||
expect(shouldShowFileTree({ desktopV2: true, showFileTree: false, opened: true })).toBe(false)
|
||||
expect(shouldShowFileTree({ desktopV2: false, showFileTree: false, opened: true })).toBe(true)
|
||||
expect(shouldShowFileTree({ desktopV2: true, showFileTree: true, opened: true })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createOpenReviewFile", () => {
|
||||
test("opens and loads selected review file", () => {
|
||||
const calls: string[] = []
|
||||
|
||||
@@ -20,6 +20,10 @@ type TabsInput = {
|
||||
|
||||
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
|
||||
|
||||
export function shouldShowFileTree(input: { desktopV2: boolean; showFileTree: boolean; opened: boolean }) {
|
||||
return input.opened && (!input.desktopV2 || input.showFileTree)
|
||||
}
|
||||
|
||||
export const createSessionTabs = (input: TabsInput) => {
|
||||
const review = input.review ?? (() => false)
|
||||
const hasReview = input.hasReview ?? (() => false)
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { shouldUseV2NewSessionPage } from "./new-session-layout"
|
||||
|
||||
describe("shouldUseV2NewSessionPage", () => {
|
||||
test("keeps disabled pages on the legacy layout", () => {
|
||||
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false, sessionID: "ses_123" })).toBe(false)
|
||||
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false })).toBe(false)
|
||||
})
|
||||
|
||||
test("uses the v2 layout only for enabled new-session pages", () => {
|
||||
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true })).toBe(true)
|
||||
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true, sessionID: "ses_123" })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,2 @@
|
||||
/** Inline new-session content width — keep in sync with session composer `placement === "inline"`. */
|
||||
export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[720px] px-0"
|
||||
|
||||
export function shouldUseV2NewSessionPage(input: { newLayoutDesigns: boolean; sessionID?: string }) {
|
||||
return input.newLayoutDesigns && !input.sessionID
|
||||
}
|
||||
|
||||
@@ -24,7 +24,13 @@ import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
||||
import {
|
||||
createOpenSessionFileTab,
|
||||
createSessionTabs,
|
||||
getTabReorderIndex,
|
||||
shouldShowFileTree,
|
||||
type Sizing,
|
||||
} from "@/pages/session/helpers"
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
@@ -59,10 +65,18 @@ export function SessionSidePanel(props: {
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
|
||||
const shown = createMemo(() => (desktopV2() ? settings.general.showFileTree() : true))
|
||||
const shown = createMemo(() => !desktopV2() || settings.general.showFileTree())
|
||||
|
||||
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())
|
||||
const fileOpen = createMemo(
|
||||
() =>
|
||||
isDesktop() &&
|
||||
shouldShowFileTree({
|
||||
desktopV2: desktopV2(),
|
||||
showFileTree: settings.general.showFileTree(),
|
||||
opened: layout.fileTree.opened(),
|
||||
}),
|
||||
)
|
||||
const open = createMemo(() => reviewOpen() || fileOpen())
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const panelWidth = createMemo(() => {
|
||||
|
||||
@@ -547,6 +547,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
description: language.t("command.agent.cycle.description"),
|
||||
keybind: "mod+.",
|
||||
slash: "agent",
|
||||
disabled: desktopV2() && !settings.general.showCustomAgents(),
|
||||
onSelect: () => local.agent.move(1),
|
||||
}),
|
||||
agentCommand({
|
||||
@@ -554,6 +555,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.agent.cycle.reverse"),
|
||||
description: language.t("command.agent.cycle.reverse.description"),
|
||||
keybind: "shift+mod+.",
|
||||
disabled: desktopV2() && !settings.general.showCustomAgents(),
|
||||
onSelect: () => local.agent.move(-1),
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Accessor } from "solid-js"
|
||||
|
||||
export type UpdaterState =
|
||||
| { status: "disabled" }
|
||||
| { status: "idle" }
|
||||
| { status: "checking" }
|
||||
| { status: "downloading"; version: string; percent?: number }
|
||||
| { status: "ready"; version: string }
|
||||
| { status: "up-to-date" }
|
||||
| { status: "installing"; version: string }
|
||||
| { status: "error"; message: string }
|
||||
|
||||
export type UpdaterPlatform = {
|
||||
state: Accessor<UpdaterState>
|
||||
check(): Promise<UpdaterState>
|
||||
install(): Promise<void>
|
||||
}
|
||||
@@ -166,6 +166,24 @@ describe("persist localStorage resilience", () => {
|
||||
expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull()
|
||||
})
|
||||
|
||||
test("draft target isolates storage per draft and namespaces keys", () => {
|
||||
const a = Persist.draft("draft-a", "prompt")
|
||||
const b = Persist.draft("draft-b", "prompt")
|
||||
|
||||
expect(a.key).toBe("draft:prompt")
|
||||
expect(a.storage).not.toBe(b.storage)
|
||||
expect(a.storage).not.toBe(Persist.workspace("/home/luke/repo", "prompt").storage)
|
||||
})
|
||||
|
||||
test("removes draft storage when removing persisted target", () => {
|
||||
const target = Persist.draft("draft-a", "prompt")
|
||||
storage.setItem(`${target.storage}:${target.key}`, '{"value":1}')
|
||||
|
||||
removePersisted(target)
|
||||
|
||||
expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull()
|
||||
})
|
||||
|
||||
test("server workspace target preserves local storage and isolates remote storage", () => {
|
||||
const local = Persist.serverWorkspace(ServerScope.local, "/home/luke/repo", "prompt")
|
||||
const windows = Persist.serverWorkspace("https://windows.example" as ServerScope, "/home/luke/repo", "prompt")
|
||||
|
||||
@@ -341,6 +341,12 @@ function workspaceStorage(dir: string) {
|
||||
return `opencode.workspace.${head}.${sum}.dat`
|
||||
}
|
||||
|
||||
function draftStorage(draftID: string) {
|
||||
const head = (draftID.slice(0, 12) || "draft").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const sum = checksum(draftID) ?? "0"
|
||||
return `opencode.draft.${head}.${sum}.dat`
|
||||
}
|
||||
|
||||
function legacyWorkspaceStorage(dir: string) {
|
||||
const storage = workspaceStorage(pathKey(dir))
|
||||
const result = new Set<string>()
|
||||
@@ -450,6 +456,12 @@ function localStorageDirect(): SyncStorage {
|
||||
}
|
||||
}
|
||||
|
||||
const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "model-selection", "file-view", "layout"]
|
||||
|
||||
export function draftPersistedKeys() {
|
||||
return DRAFT_PERSISTED_KEYS
|
||||
}
|
||||
|
||||
export const PersistTesting = {
|
||||
localStorageDirect,
|
||||
localStorageWithPrefix,
|
||||
@@ -462,6 +474,9 @@ export const Persist = {
|
||||
global(key: string, legacy?: string[]): PersistTarget {
|
||||
return { storage: GLOBAL_STORAGE, key, legacy }
|
||||
},
|
||||
draft(draftID: string, key: string, legacy?: string[]): PersistTarget {
|
||||
return { storage: draftStorage(draftID), key: `draft:${key}`, legacy }
|
||||
},
|
||||
serverGlobal(scope: ServerScopeValue, key: string, legacy?: string[]): PersistTarget {
|
||||
if (scope === ServerScope.local) return Persist.global(key, legacy)
|
||||
return { storage: GLOBAL_STORAGE, key: ScopedKey.from(scope, key) }
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { queryOptions, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import type { WslServersState } from "./types"
|
||||
import { usePlatform } from "../context/platform"
|
||||
|
||||
const wslServersQueryKey = ["platform", "wslServers"] as const
|
||||
|
||||
export const { use: useWslServers, provider: WslServersProvider } = createSimpleContext({
|
||||
name: "WslServers",
|
||||
init: () => {
|
||||
const platform = usePlatform()
|
||||
const queryClient = useQueryClient()
|
||||
const query = useQuery(() => {
|
||||
const api = platform.wslServers
|
||||
return queryOptions<WslServersState>({
|
||||
queryKey: wslServersQueryKey,
|
||||
queryFn: () => api!.getState(),
|
||||
enabled: !!api,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const api = platform.wslServers
|
||||
if (!api) return
|
||||
const off = api.subscribe((event) => {
|
||||
queryClient.setQueryData(wslServersQueryKey, event.state)
|
||||
})
|
||||
onCleanup(off)
|
||||
})
|
||||
|
||||
return query as typeof query & { readonly data: WslServersState | undefined }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,623 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useWslServers } from "./context"
|
||||
import { enterWslOpencodeStep } from "./settings-model"
|
||||
|
||||
type WslServerStep = "wsl" | "distro" | "opencode"
|
||||
|
||||
const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
|
||||
|
||||
function isHiddenDistro(name: string) {
|
||||
return /^docker-desktop(?:-data)?$/i.test(name)
|
||||
}
|
||||
|
||||
interface DialogWslServerProps {
|
||||
onAdded?: (distro: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
export function DialogAddWslServer(props: DialogWslServerProps = {}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const wslServers = useWslServers()
|
||||
const api = platform.wslServers!
|
||||
const [store, setStore] = createStore({
|
||||
step: undefined as WslServerStep | undefined,
|
||||
selectedDistro: null as string | null,
|
||||
installTarget: undefined as string | undefined,
|
||||
adding: false,
|
||||
})
|
||||
const current = () => wslServers.data
|
||||
let disposed = false
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
})
|
||||
const busy = createMemo(() => !!current()?.job || store.adding)
|
||||
const visibleInstalledDistros = createMemo(() =>
|
||||
(current()?.installed ?? []).filter((item) => !isHiddenDistro(item.name)),
|
||||
)
|
||||
const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name)))
|
||||
const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null)
|
||||
const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
|
||||
const addableInstalledDistros = createMemo(() => {
|
||||
return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name))
|
||||
})
|
||||
const selectedDistro = createMemo(() => {
|
||||
if (store.selectedDistro && addableInstalledDistros().some((item) => item.name === store.selectedDistro)) {
|
||||
return store.selectedDistro
|
||||
}
|
||||
const distro = defaultInstalledDistro()
|
||||
if (distro && !existingServerDistros().has(distro.name)) return distro.name
|
||||
return null
|
||||
})
|
||||
const selectedProbe = createMemo(() => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return null
|
||||
return current()?.distroProbes[distro] ?? null
|
||||
})
|
||||
const selectedInstalled = createMemo(() => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return null
|
||||
return (current()?.installed ?? []).find((item) => item.name === distro) ?? null
|
||||
})
|
||||
const opencodeCheck = createMemo(() => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return null
|
||||
return current()?.opencodeChecks[distro] ?? null
|
||||
})
|
||||
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
|
||||
const distroReady = createMemo(() => {
|
||||
const probe = selectedProbe()
|
||||
if (!probe || !selectedDistro()) return false
|
||||
if (selectedInstalled()?.version === 1) return false
|
||||
return probe.canExecute && probe.hasBash && probe.hasCurl
|
||||
})
|
||||
const opencodeReady = createMemo(() => {
|
||||
const check = opencodeCheck()
|
||||
return !!check?.resolvedPath && !check.error
|
||||
})
|
||||
const distroWarningProbe = createMemo(() => {
|
||||
const probe = selectedProbe()
|
||||
if (!probe) return null
|
||||
if (distroReady()) return null
|
||||
return probe
|
||||
})
|
||||
const distroUnavailableMessage = createMemo(() => {
|
||||
const probe = distroWarningProbe()
|
||||
const distro = selectedDistro()
|
||||
if (!probe || probe.canExecute || !distro) return null
|
||||
if (!selectedInstalled()) return language.t("wsl.onboarding.distroNotInstalled", { distro })
|
||||
return language.t("wsl.onboarding.openDistroOnce", { distro })
|
||||
})
|
||||
const distroMissingTools = createMemo(() => {
|
||||
const probe = distroWarningProbe()
|
||||
if (!probe?.canExecute) return null
|
||||
if (probe.hasBash && probe.hasCurl) return null
|
||||
return probe
|
||||
})
|
||||
const installableDistros = createMemo(() => {
|
||||
const online = visibleOnlineDistros()
|
||||
const installed = new Set(visibleInstalledDistros().map((item) => item.name))
|
||||
const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
|
||||
return online
|
||||
.filter((item) => !installed.has(item.name))
|
||||
.filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
|
||||
})
|
||||
const installTarget = createMemo(
|
||||
() => installableDistros().find((item) => item.name === store.installTarget) ?? installableDistros()[0] ?? null,
|
||||
)
|
||||
const installingDistro = createMemo(() => current()?.job?.kind === "install-distro")
|
||||
const installingOpencode = createMemo(() => {
|
||||
const job = current()?.job
|
||||
return job?.kind === "install-opencode" && job.distro === selectedDistro()
|
||||
})
|
||||
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
|
||||
const addDisabled = createMemo(() => {
|
||||
const job = current()?.job
|
||||
if (!job) return store.adding
|
||||
return store.adding || job.kind !== "probe-opencode"
|
||||
})
|
||||
const recommendedStep = createMemo<WslServerStep>(() => {
|
||||
if (!wslReady()) return "wsl"
|
||||
if (!distroReady()) return "distro"
|
||||
return "opencode"
|
||||
})
|
||||
// activeStep falls back to recommendedStep when the user hasn't picked one.
|
||||
// Once the user clicks a step tab we respect their choice rather than snapping
|
||||
// them back when a probe result updates recommendedStep.
|
||||
const activeStep = createMemo(() => store.step ?? recommendedStep())
|
||||
|
||||
const autoProbe = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state || busy()) return null
|
||||
if (state.pendingRestart) return null
|
||||
if (!state.runtime) return { key: "runtime", run: () => api.probeRuntime() }
|
||||
if (!wslReady()) return null
|
||||
if (!state.installed.length && !state.online.length) {
|
||||
return { key: "distros", run: () => api.refreshDistros() }
|
||||
}
|
||||
const distro = selectedDistro()
|
||||
if (distro && !state.distroProbes[distro]) {
|
||||
return { key: `probe-distro:${distro}`, run: () => api.probeDistro(distro) }
|
||||
}
|
||||
if (!distro || !distroReady()) return null
|
||||
if (!state.opencodeChecks[distro]) {
|
||||
return { key: `probe-opencode:${distro}`, run: () => api.probeOpencode(distro) }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
let lastAutoProbe: string | null = null
|
||||
createEffect(() => {
|
||||
const probe = autoProbe()
|
||||
if (!probe || probe.key === lastAutoProbe) return
|
||||
const key = probe.key
|
||||
lastAutoProbe = key
|
||||
void (async () => {
|
||||
try {
|
||||
await probe.run()
|
||||
} catch (err) {
|
||||
if (disposed) return
|
||||
// Allow the same probe to run again when reactive inputs next change
|
||||
// (e.g. user reselects a distro). Without this the user would be stuck
|
||||
// on a transient wsl.exe failure until they pick a different distro.
|
||||
if (lastAutoProbe === key) lastAutoProbe = null
|
||||
requestError(language, err)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
const wslMessage = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state || state.job?.kind === "runtime") return language.t("wsl.onboarding.checkingRuntime")
|
||||
if (state.pendingRestart) return language.t("wsl.onboarding.restartRequired")
|
||||
if (state.runtime?.available) return state.runtime.version ?? language.t("wsl.onboarding.ready")
|
||||
return state.runtime?.error ?? language.t("wsl.onboarding.required")
|
||||
})
|
||||
|
||||
const distroMessage = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state) return language.t("wsl.onboarding.checkingDistros")
|
||||
const distro = selectedDistro()
|
||||
if (state.job?.kind === "install-distro")
|
||||
return language.t("wsl.onboarding.installingDistro", { distro: state.job.distro })
|
||||
if (state.job?.kind === "probe-distro")
|
||||
return language.t("wsl.onboarding.checkingDistro", { distro: state.job.distro })
|
||||
if (state.job?.kind === "distros") return language.t("wsl.onboarding.listingDistros")
|
||||
if (distroUnavailableMessage()) return distroUnavailableMessage()!
|
||||
if (selectedProbe() && distroReady())
|
||||
return language.t("wsl.onboarding.distroReady", { distro: selectedProbe()!.name })
|
||||
if (distro) return language.t("wsl.onboarding.finishingDistro", { distro })
|
||||
return language.t("wsl.onboarding.pickDistro")
|
||||
})
|
||||
|
||||
const opencodeMessage = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state) return language.t("wsl.onboarding.checkingOpencode")
|
||||
const distro = selectedDistro()
|
||||
if (state.job?.kind === "install-opencode") {
|
||||
return distro
|
||||
? language.t("wsl.onboarding.updatingOpencodeIn", { distro })
|
||||
: language.t("wsl.onboarding.updatingOpencode")
|
||||
}
|
||||
if (state.job?.kind === "probe-opencode") {
|
||||
return distro
|
||||
? language.t("wsl.onboarding.checkingOpencodeIn", { distro })
|
||||
: language.t("wsl.onboarding.checkingOpencode")
|
||||
}
|
||||
if (opencodeCheck()?.error) return opencodeCheck()!.error
|
||||
if (opencodeCheck()?.matchesDesktop === false) {
|
||||
return distro
|
||||
? language.t("wsl.onboarding.updateOpencodeIn", { distro })
|
||||
: language.t("wsl.onboarding.updateOpencode")
|
||||
}
|
||||
if (opencodeReady()) {
|
||||
return distro
|
||||
? language.t("wsl.onboarding.opencodeReadyIn", { distro })
|
||||
: language.t("wsl.onboarding.opencodeReady")
|
||||
}
|
||||
return distro
|
||||
? language.t("wsl.onboarding.installOpencodeIn", { distro })
|
||||
: language.t("wsl.onboarding.chooseDistroFirst")
|
||||
})
|
||||
|
||||
const run = async (action: () => Promise<unknown>) => {
|
||||
try {
|
||||
await action()
|
||||
} catch (err) {
|
||||
requestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
const runSelectedDistro = (action: (distro: string) => Promise<unknown>) => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return
|
||||
void run(() => action(distro))
|
||||
}
|
||||
|
||||
const selectDistro = (name: string) => {
|
||||
setStore("selectedDistro", name)
|
||||
setStore("step", undefined)
|
||||
}
|
||||
|
||||
const openOpencodeStep = () => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return
|
||||
void run(() => enterWslOpencodeStep(distro, api.probeOpencode, (step) => setStore("step", step)))
|
||||
}
|
||||
|
||||
const finish = async () => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return
|
||||
setStore("adding", true)
|
||||
try {
|
||||
await api.addServer(distro)
|
||||
if (props.onAdded) {
|
||||
await props.onAdded(distro)
|
||||
} else {
|
||||
dialog.close()
|
||||
}
|
||||
} catch (err) {
|
||||
requestError(language, err)
|
||||
} finally {
|
||||
setStore("adding", false)
|
||||
}
|
||||
}
|
||||
|
||||
const steps = createMemo(() => {
|
||||
const active = activeStep()
|
||||
const activeIndex = STEPS.indexOf(active)
|
||||
const recommendedIndex = STEPS.indexOf(recommendedStep())
|
||||
return STEPS.map((step) => {
|
||||
const index = STEPS.indexOf(step)
|
||||
return {
|
||||
step,
|
||||
title:
|
||||
step === "wsl"
|
||||
? language.t("wsl.server.label")
|
||||
: step === "distro"
|
||||
? language.t("wsl.onboarding.step.distro")
|
||||
: language.t("wsl.onboarding.step.opencode"),
|
||||
state:
|
||||
active === step
|
||||
? "current"
|
||||
: step === "wsl"
|
||||
? wslReady()
|
||||
? "done"
|
||||
: "warning"
|
||||
: step === "distro"
|
||||
? distroReady()
|
||||
? "done"
|
||||
: index > activeIndex
|
||||
? "locked"
|
||||
: "warning"
|
||||
: opencodeCheck()?.matchesDesktop === false
|
||||
? "warning"
|
||||
: opencodeReady()
|
||||
? "done"
|
||||
: index > activeIndex
|
||||
? "locked"
|
||||
: "warning",
|
||||
locked: index > recommendedIndex,
|
||||
}
|
||||
})
|
||||
})
|
||||
const loadError = createMemo(() => {
|
||||
const error = wslServers.error
|
||||
if (!error) return language.t("wsl.onboarding.loadFailed")
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="px-5 pb-5 flex flex-col gap-4">
|
||||
<Show
|
||||
when={!wslServers.isPending}
|
||||
fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{language.t("wsl.onboarding.loading")}</div>}
|
||||
>
|
||||
<Show
|
||||
when={!wslServers.isError}
|
||||
fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{loadError()}</div>}
|
||||
>
|
||||
<div class="flex gap-2 pb-1">
|
||||
<For each={steps()}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
|
||||
classList={{
|
||||
"border-border-strong-base bg-surface-base-hover": item.state === "current",
|
||||
"border-icon-success-base/40 bg-surface-base": item.state === "done",
|
||||
"border-border-weak-base bg-background-base opacity-60": item.state === "locked",
|
||||
"border-icon-warning-base/40 bg-surface-base": item.state === "warning",
|
||||
}}
|
||||
disabled={item.locked}
|
||||
onClick={() => setStore("step", item.step)}
|
||||
>
|
||||
<div class="text-13-medium text-text-strong">{item.title}</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Switch>
|
||||
<Match when={activeStep() === "wsl"}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("wsl.server.label")}</div>
|
||||
<Show when={current()?.runtime && !wslReady() && !current()?.pendingRestart}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => void run(() => api.installWsl())}
|
||||
>
|
||||
{language.t("wsl.onboarding.installWsl")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{wslMessage()}</div>
|
||||
<Show when={current()?.pendingRestart}>
|
||||
<div class="rounded-md border border-border-weak-base px-3 py-3">
|
||||
<div class="text-12-regular text-text-warning-base">
|
||||
{language.t("wsl.onboarding.windowsRestartRequired")}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy() || !wslReady()}
|
||||
onClick={() => setStore("step", "distro")}
|
||||
>
|
||||
{language.t("wsl.onboarding.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={activeStep() === "distro"}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("wsl.onboarding.step.distro")}</div>
|
||||
<Show when={selectedDistro()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
disabled={busy()}
|
||||
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
|
||||
>
|
||||
{language.t("wsl.onboarding.refresh")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{distroMessage()}</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Show
|
||||
when={addableInstalledDistros().length > 0}
|
||||
fallback={
|
||||
<div class="text-12-regular text-text-weak">
|
||||
{visibleInstalledDistros().length
|
||||
? language.t("wsl.onboarding.allDistrosAdded")
|
||||
: current()?.runtime?.available
|
||||
? language.t("wsl.onboarding.noDistros")
|
||||
: language.t("wsl.onboarding.checkingDistros")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={addableInstalledDistros()}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border-weak-base px-3 py-2 text-left transition-colors"
|
||||
classList={{ "bg-surface-raised-base": selectedDistro() === item.name }}
|
||||
onClick={() => selectDistro(item.name)}
|
||||
>
|
||||
<div class="text-13-medium text-text-strong">{item.name}</div>
|
||||
<Show when={item.isDefault}>
|
||||
<div class="text-12-regular text-text-weak">{language.t("common.default")}</div>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={installableDistros().length > 0}>
|
||||
<div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
|
||||
<div class="px-1 flex items-center justify-between gap-3">
|
||||
<div class="text-12-medium text-text-weak">{language.t("wsl.onboarding.install")}</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Show when={installingDistro()}>
|
||||
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
|
||||
</Show>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={busy() || !installTarget()}
|
||||
onClick={() => void run(() => api.installDistro(installTarget()!.name))}
|
||||
>
|
||||
{installingDistro()
|
||||
? language.t("wsl.onboarding.installing")
|
||||
: language.t("wsl.onboarding.install")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={language.t("wsl.onboarding.installDistro")}
|
||||
class="max-h-52 overflow-y-auto rounded-md bg-background-base"
|
||||
>
|
||||
<For each={installableDistros()}>
|
||||
{(item) => {
|
||||
const selected = () => installTarget()?.name === item.name
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected()}
|
||||
disabled={busy()}
|
||||
class="w-full px-3 py-2 flex items-center gap-3 text-left border-b border-border-weak-base last:border-b-0 transition-colors"
|
||||
classList={{
|
||||
"bg-surface-raised-base": selected(),
|
||||
"hover:bg-surface-base": !selected(),
|
||||
}}
|
||||
onClick={() => setStore("installTarget", item.name)}
|
||||
>
|
||||
<div
|
||||
class="mt-0.5 h-4 w-4 rounded-full border border-border-strong-base flex items-center justify-center shrink-0"
|
||||
classList={{ "border-text-strong": selected() }}
|
||||
>
|
||||
<div class="h-2 w-2 rounded-full bg-text-strong" classList={{ hidden: !selected() }} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-13-medium text-text-strong truncate">{item.label}</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={selectedInstalled()?.version === 1 || distroUnavailableMessage() || distroMissingTools()}>
|
||||
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
|
||||
<Show when={selectedInstalled()?.version === 1}>
|
||||
<div class="text-12-regular text-text-warning-base">
|
||||
{language.t("wsl.onboarding.wsl2Required")}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={distroUnavailableMessage()}>
|
||||
{(message) => <div class="text-12-regular text-text-warning-base">{message()}</div>}
|
||||
</Show>
|
||||
<Show when={distroMissingTools()}>
|
||||
<div class="text-12-regular text-text-warning-base">
|
||||
{language.t("wsl.onboarding.toolsRequired")}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy() || !selectedInstalled()}
|
||||
onClick={() => runSelectedDistro((distro) => api.openTerminal(distro))}
|
||||
>
|
||||
{language.t("wsl.onboarding.openTerminal")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
disabled={busy() || !selectedDistro()}
|
||||
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
|
||||
>
|
||||
{language.t("wsl.onboarding.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy() || !selectedDistro() || !distroReady()}
|
||||
onClick={openOpencodeStep}
|
||||
>
|
||||
{language.t("wsl.onboarding.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={activeStep() === "opencode"}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("wsl.onboarding.step.opencode")}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={selectedDistro()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => runSelectedDistro((distro) => api.probeOpencode(distro))}
|
||||
>
|
||||
{language.t("wsl.onboarding.refresh")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))}
|
||||
>
|
||||
<Show when={installingOpencode()}>
|
||||
<Spinner class="size-4 shrink-0" />
|
||||
</Show>
|
||||
{opencodeCheck()?.resolvedPath
|
||||
? language.t("wsl.onboarding.updateOpencode")
|
||||
: language.t("wsl.onboarding.installOpencode")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
|
||||
<Show when={opencodeCheck()?.matchesDesktop === false ? opencodeCheck() : null}>
|
||||
{(check) => (
|
||||
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
|
||||
<div class="text-12-regular text-text-weak">
|
||||
{language.t("wsl.onboarding.path", {
|
||||
path: check().resolvedPath ?? language.t("wsl.onboarding.notFound"),
|
||||
})}
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak">
|
||||
{language.t("wsl.onboarding.version", {
|
||||
version: check().version ?? language.t("wsl.onboarding.unknown"),
|
||||
})}
|
||||
<Show when={check().expectedVersion}>
|
||||
{(expected) => (
|
||||
<span>{` · ${language.t("wsl.onboarding.desktopVersion", { version: expected() })}`}</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-warning-base">
|
||||
{language.t("wsl.onboarding.versionMismatch")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<Show when={activeStep() === "opencode" && allReady() && selectedDistro()}>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" disabled={addDisabled()} onClick={() => void finish()}>
|
||||
{store.adding ? language.t("wsl.onboarding.adding") : language.t("wsl.server.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function requestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||
console.error("WSL servers request failed", err instanceof Error ? (err.stack ?? err.message) : String(err))
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { enterWslOpencodeStep, wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
|
||||
|
||||
describe("WSL server settings presentation", () => {
|
||||
test("retries only settled unsuccessful runtimes", () => {
|
||||
expect(wslRuntimeRetryable({ kind: "starting" })).toBe(false)
|
||||
expect(wslRuntimeRetryable({ kind: "ready", url: "http://127.0.0.1:4096", username: null, password: null })).toBe(
|
||||
false,
|
||||
)
|
||||
expect(wslRuntimeRetryable({ kind: "failed", message: "boom" })).toBe(true)
|
||||
expect(wslRuntimeRetryable({ kind: "stopped" })).toBe(true)
|
||||
})
|
||||
|
||||
test("offers install and update only when OpenCode needs attention", () => {
|
||||
expect(wslOpencodeAction(undefined)).toBeUndefined()
|
||||
expect(
|
||||
wslOpencodeAction({
|
||||
distro: "Debian",
|
||||
resolvedPath: null,
|
||||
version: null,
|
||||
expectedVersion: "1.2.3",
|
||||
matchesDesktop: null,
|
||||
error: null,
|
||||
}),
|
||||
).toBe("Install OpenCode")
|
||||
expect(
|
||||
wslOpencodeAction({
|
||||
distro: "Debian",
|
||||
resolvedPath: "/usr/local/bin/opencode",
|
||||
version: "1.2.2",
|
||||
expectedVersion: "1.2.3",
|
||||
matchesDesktop: false,
|
||||
error: null,
|
||||
}),
|
||||
).toBe("Update OpenCode")
|
||||
expect(
|
||||
wslOpencodeAction({
|
||||
distro: "Debian",
|
||||
resolvedPath: "/usr/local/bin/opencode",
|
||||
version: "1.2.3",
|
||||
expectedVersion: "1.2.3",
|
||||
matchesDesktop: true,
|
||||
error: null,
|
||||
}),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test("probes the selected distro before entering the OpenCode step", async () => {
|
||||
const calls: string[] = []
|
||||
await enterWslOpencodeStep(
|
||||
"Debian",
|
||||
async (distro) => calls.push(distro),
|
||||
(step) => calls.push(step),
|
||||
)
|
||||
expect(calls).toEqual(["Debian", "opencode"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { WslOpencodeCheck, WslServerRuntime } from "./types"
|
||||
|
||||
export const wslRuntimeRetryable = (runtime: WslServerRuntime) =>
|
||||
runtime.kind === "failed" || runtime.kind === "stopped"
|
||||
|
||||
export async function enterWslOpencodeStep(
|
||||
distro: string,
|
||||
probe: (distro: string) => Promise<unknown>,
|
||||
select: (step: "opencode") => void,
|
||||
) {
|
||||
await probe(distro)
|
||||
select("opencode")
|
||||
}
|
||||
|
||||
export function wslOpencodeAction(check?: WslOpencodeCheck) {
|
||||
if (!check) return
|
||||
if (!check.resolvedPath) return "Install OpenCode"
|
||||
if (check.matchesDesktop === false) return "Update OpenCode"
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { type Accessor, For, Show, createMemo } from "solid-js"
|
||||
import type { useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { DialogAddWslServer } from "./dialog-add-server"
|
||||
import { useWslServers } from "./context"
|
||||
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
|
||||
|
||||
type Controller = ReturnType<typeof useServerManagementController>
|
||||
|
||||
export function isWslServer(server: ServerConnection.Any) {
|
||||
return server.type === "sidecar" && server.variant === "wsl"
|
||||
}
|
||||
|
||||
export function WslAddServerButton() {
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const openAdd = () => {
|
||||
dialog.push(() => (
|
||||
<Dialog title={language.t("wsl.server.add")} size="large" fit class="settings-v2-wsl-dialog">
|
||||
<DialogAddWslServer />
|
||||
</Dialog>
|
||||
))
|
||||
}
|
||||
return (
|
||||
<Show when={platform.wslServers}>
|
||||
<ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
|
||||
{language.t("wsl.server.addShort")}
|
||||
</ButtonV2>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function useFilteredWslServers(filter: Accessor<string>) {
|
||||
const wsl = useWslServers()
|
||||
return createMemo(() => {
|
||||
const servers = wsl.data?.servers ?? []
|
||||
const query = filter().trim()
|
||||
if (!query) return servers
|
||||
return fuzzysort
|
||||
.go(query, servers, { keys: [(item) => item.config.distro, (item) => item.config.id] })
|
||||
.map((x) => x.obj)
|
||||
})
|
||||
}
|
||||
|
||||
export function WslServerSettings(props: {
|
||||
controller: Controller
|
||||
servers: ReturnType<typeof useFilteredWslServers>
|
||||
}) {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const wsl = useWslServers()
|
||||
const api = platform.wslServers
|
||||
|
||||
const request = useMutation(() => ({
|
||||
mutationFn: (action: () => Promise<unknown>) => action(),
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
}))
|
||||
|
||||
const remove = (key: ServerConnection.Key) => {
|
||||
request.mutate(() => props.controller.handleRemove(key))
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={api}>
|
||||
<For each={props.servers()}>
|
||||
{(item) => {
|
||||
const key = ServerConnection.Key.make(item.config.id)
|
||||
const check = () => wsl.data?.opencodeChecks[item.config.distro]
|
||||
const opencodeAction = () => wslOpencodeAction(check())
|
||||
const busy = () => wsl.data?.job?.kind === "install-opencode" && wsl.data.job.distro === item.config.distro
|
||||
return (
|
||||
<div class="settings-v2-servers-row">
|
||||
<div class="settings-v2-servers-lead">
|
||||
<ServerHealthIndicator health={props.controller.status()[key]} />
|
||||
<div class="settings-v2-servers-copy">
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<span class="settings-v2-servers-name">{item.config.distro}</span>
|
||||
<span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted">
|
||||
{language.t("wsl.server.label")}
|
||||
</span>
|
||||
</span>
|
||||
<span class="settings-v2-servers-meta">
|
||||
<Show when={check()?.version}>{(version) => `v${version()}`}</Show>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-v2-servers-actions">
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||
</Show>
|
||||
<Show when={opencodeAction()}>
|
||||
{(label) => (
|
||||
<ButtonV2
|
||||
size="small"
|
||||
disabled={busy() || request.isPending}
|
||||
onClick={() => api && request.mutate(() => api.installOpencode(item.config.distro))}
|
||||
>
|
||||
{busy() ? language.t("wsl.server.updating") : label()}
|
||||
</ButtonV2>
|
||||
)}
|
||||
</Show>
|
||||
<MenuV2 gutter={4} modal={false} placement="bottom-end">
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" />}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("wsl.server.menu.label")}</MenuV2.GroupLabel>
|
||||
<Show when={wslRuntimeRetryable(item.runtime)}>
|
||||
<MenuV2.Item onSelect={() => api && request.mutate(() => api.startServer(key))}>
|
||||
{language.t("wsl.server.retryStart")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => remove(key)}>
|
||||
{language.t("dialog.server.menu.delete")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
export type WslRuntimeCheck = {
|
||||
available: boolean
|
||||
version: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export type WslInstalledDistro = {
|
||||
name: string
|
||||
version: number | null
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export type WslOnlineDistro = {
|
||||
name: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type WslDistroProbe = {
|
||||
name: string
|
||||
canExecute: boolean
|
||||
hasBash: boolean
|
||||
hasCurl: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export type WslOpencodeCheck = {
|
||||
distro: string
|
||||
resolvedPath: string | null
|
||||
version: string | null
|
||||
expectedVersion: string | null
|
||||
matchesDesktop: boolean | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export type WslServerConfig = {
|
||||
id: string
|
||||
distro: string
|
||||
}
|
||||
|
||||
export type WslServerRuntime =
|
||||
| { kind: "starting" }
|
||||
| { kind: "ready"; url: string; username: string | null; password: string | null }
|
||||
| { kind: "failed"; message: string }
|
||||
| { kind: "stopped" }
|
||||
|
||||
export type WslServerItem = {
|
||||
config: WslServerConfig
|
||||
runtime: WslServerRuntime
|
||||
}
|
||||
|
||||
export type WslJob =
|
||||
| { kind: "runtime"; startedAt: number }
|
||||
| { kind: "distros"; startedAt: number }
|
||||
| { kind: "install-wsl"; startedAt: number }
|
||||
| { kind: "install-distro"; distro: string; startedAt: number }
|
||||
| { kind: "probe-distro"; distro: string; startedAt: number }
|
||||
| { kind: "probe-opencode"; distro: string; startedAt: number }
|
||||
| { kind: "install-opencode"; distro: string; startedAt: number }
|
||||
|
||||
export type WslServersState = {
|
||||
runtime: WslRuntimeCheck | null
|
||||
installed: WslInstalledDistro[]
|
||||
online: WslOnlineDistro[]
|
||||
distroProbes: Record<string, WslDistroProbe>
|
||||
opencodeChecks: Record<string, WslOpencodeCheck>
|
||||
pendingRestart: boolean
|
||||
servers: WslServerItem[]
|
||||
job: WslJob | null
|
||||
}
|
||||
|
||||
export type WslServersEvent = { type: "state"; state: WslServersState }
|
||||
|
||||
export type WslServersPlatform = {
|
||||
getState(): Promise<WslServersState>
|
||||
subscribe(cb: (event: WslServersEvent) => void): () => void
|
||||
probeRuntime(): Promise<void>
|
||||
refreshDistros(): Promise<void>
|
||||
installWsl(): Promise<void>
|
||||
installDistro(name: string): Promise<void>
|
||||
probeDistro(name: string): Promise<void>
|
||||
probeOpencode(name: string): Promise<void>
|
||||
installOpencode(name: string): Promise<void>
|
||||
openTerminal(name: string): Promise<void>
|
||||
addServer(distro: string): Promise<WslServerConfig>
|
||||
removeServer(id: string): Promise<void>
|
||||
startServer(id: string): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
preload = ["@opentui/solid/preload"]
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -20,8 +20,12 @@
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:"
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import fs from "fs"
|
||||
import { rm } from "fs/promises"
|
||||
import path from "path"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import pkg from "../package.json"
|
||||
import { modelsData } from "./generate"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
@@ -13,7 +17,9 @@ await rm("dist", { recursive: true, force: true })
|
||||
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const sourcemapsFlag = process.argv.includes("--sourcemaps")
|
||||
const plugin = createSolidTransformPlugin()
|
||||
|
||||
const allTargets: {
|
||||
os: string
|
||||
@@ -43,6 +49,12 @@ const targets = singleFlag
|
||||
})
|
||||
: allTargets
|
||||
|
||||
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
||||
|
||||
const localParserWorker = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
|
||||
const rootParserWorker = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
|
||||
const parserWorker = fs.realpathSync(fs.existsSync(localParserWorker) ? localParserWorker : rootParserWorker)
|
||||
|
||||
for (const item of targets) {
|
||||
const target = [
|
||||
binary,
|
||||
@@ -56,8 +68,9 @@ for (const item of targets) {
|
||||
const name = target.replace(binary, "cli")
|
||||
console.log(`building ${name}`)
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
entrypoints: ["./src/index.ts", parserWorker],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [plugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
@@ -79,6 +92,13 @@ for (const item of targets) {
|
||||
OPENCODE_MODELS_DEV: modelsData,
|
||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
|
||||
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
|
||||
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
|
||||
OTUI_TREE_SITTER_WORKER_PATH:
|
||||
(item.os === "win32" ? '"B:/~BUN/root/' : '"/$bunfs/root/') +
|
||||
path.relative(dir, parserWorker).replaceAll("\\", "/") +
|
||||
'"',
|
||||
...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Effect } from "effect"
|
||||
import { Daemon } from "../../services/daemon"
|
||||
|
||||
export default Runtime.handler(Commands, () =>
|
||||
Effect.gen(function* () {
|
||||
const daemon = yield* Daemon.Service
|
||||
const transport = yield* daemon.transport()
|
||||
const { runTui } = yield* Effect.promise(() => import("../../tui"))
|
||||
yield* runTui(transport)
|
||||
}),
|
||||
)
|
||||
@@ -1,4 +1,6 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { Context, Layer, Option } from "effect"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
@@ -34,6 +36,8 @@ function bind(hostname: string, port: number, password: string) {
|
||||
return Layer.build(
|
||||
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
|
||||
Layer.provide(Credential.defaultLayer),
|
||||
Layer.provide(PermissionSaved.defaultLayer),
|
||||
),
|
||||
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
|
||||
}
|
||||
|
||||
@@ -60,19 +60,19 @@ export function run(commands: Spec.Any, handlers: ReadonlyArray<LazyHandler>, op
|
||||
}
|
||||
|
||||
function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
|
||||
const spec: Command.Command.Any = Object.keys(node.commands).length
|
||||
? (node.spec as Command.Command<string, unknown>).pipe(
|
||||
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
|
||||
const handler = handlers.find((handler) => handler.spec === node.spec)
|
||||
const spec = handler
|
||||
? node.spec.pipe(
|
||||
Command.withHandler((input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
|
||||
}),
|
||||
),
|
||||
)
|
||||
: node.spec
|
||||
const handler = handlers.find((handler) => handler.spec === node.spec)
|
||||
if (!handler) return spec as ProvidedCommand
|
||||
if (!Object.keys(node.commands).length) return spec as ProvidedCommand
|
||||
return spec.pipe(
|
||||
Command.withHandler((input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
|
||||
}),
|
||||
),
|
||||
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
|
||||
) as ProvidedCommand
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Runtime } from "./framework/runtime"
|
||||
import { Daemon } from "./services/daemon"
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
},
|
||||
|
||||
@@ -5,10 +5,12 @@ import { ServerAuth } from "@opencode-ai/server/auth"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { randomBytes, randomUUID } from "crypto"
|
||||
import { spawn } from "node:child_process"
|
||||
import path from "path"
|
||||
|
||||
export interface Interface {
|
||||
readonly client: () => Effect.Effect<ReturnType<typeof createOpencodeClient>, unknown>
|
||||
readonly transport: () => Effect.Effect<{ url: string; headers: RequestInit["headers"] }, unknown>
|
||||
readonly start: () => Effect.Effect<string, Error>
|
||||
readonly status: () => Effect.Effect<string | undefined>
|
||||
readonly stop: () => Effect.Effect<void, unknown>
|
||||
@@ -108,16 +110,21 @@ export const layer = Layer.effect(
|
||||
const start = Effect.fn("cli.daemon.start")(function* () {
|
||||
const existing = yield* healthy().pipe(Effect.option)
|
||||
const found = Option.getOrUndefined(existing)
|
||||
if (found?.version === InstallationVersion) return found.url
|
||||
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
||||
if (found?.version === InstallationVersion && compiled) return found.url
|
||||
if (found) yield* stopProcess(found).pipe(Effect.ignore)
|
||||
|
||||
yield* Effect.sync(() => {
|
||||
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
||||
Bun.spawn([process.execPath, ...(compiled ? [] : [Bun.main]), "serve", "--register"], {
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
}).unref()
|
||||
const entrypoint = compiled ? undefined : process.argv[1]
|
||||
if (!compiled && entrypoint === undefined)
|
||||
return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
|
||||
yield* Effect.try({
|
||||
try: () => {
|
||||
spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
}).unref()
|
||||
},
|
||||
catch: (cause) => new Error("Failed to start server", { cause }),
|
||||
})
|
||||
|
||||
return yield* compatible().pipe(
|
||||
@@ -127,8 +134,13 @@ export const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const transport = Effect.fn("cli.daemon.transport")(function* () {
|
||||
return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) }
|
||||
})
|
||||
|
||||
const client = Effect.fn("cli.daemon.client")(function* () {
|
||||
return yield* createClient(yield* start())
|
||||
const connection = yield* transport()
|
||||
return createOpencodeClient({ baseUrl: connection.url, headers: connection.headers })
|
||||
})
|
||||
|
||||
const status = Effect.fn("cli.daemon.status")(function* () {
|
||||
@@ -173,7 +185,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({ client, start, status, stop, password, register })
|
||||
return Service.of({ client, transport, start, status, stop, password, register })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { run } from "@opencode-ai/tui"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { Effect } from "effect"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
|
||||
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||
return run({
|
||||
...transport,
|
||||
args: {},
|
||||
config,
|
||||
fetch: gracefulFetch,
|
||||
pluginHost: {
|
||||
async start() {},
|
||||
async dispose() {},
|
||||
},
|
||||
}).pipe(Effect.provide(Global.defaultLayer))
|
||||
}
|
||||
|
||||
const legacyDefaults: Record<string, unknown> = {
|
||||
"/config/providers": { providers: [], default: {} },
|
||||
"/provider": { all: [], default: {}, connected: [] },
|
||||
"/agent": [],
|
||||
"/config": {},
|
||||
}
|
||||
|
||||
const gracefulFetch = Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const response = await fetch(input, init)
|
||||
if (response.status !== 404) return response
|
||||
const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]
|
||||
if (fallback === undefined) return response
|
||||
return Response.json(fallback)
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
@@ -2,6 +2,8 @@
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "@opentui/solid",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -13,10 +13,9 @@ import copyLogoSvgLight from "../asset/lander/opencode-logo-light.svg"
|
||||
import copyLogoSvgDark from "../asset/lander/opencode-logo-dark.svg"
|
||||
import copyWordmarkSvgLight from "../asset/lander/opencode-wordmark-light.svg"
|
||||
import copyWordmarkSvgDark from "../asset/lander/opencode-wordmark-dark.svg"
|
||||
import { A, createAsync, useNavigate } from "@solidjs/router"
|
||||
import { A, useNavigate } from "@solidjs/router"
|
||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { github } from "~/lib/github"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { config } from "~/config"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
@@ -40,16 +39,6 @@ export function Header(props: { zen?: boolean; go?: boolean; hideGetStarted?: bo
|
||||
const navigate = useNavigate()
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const githubData = createAsync(() => github())
|
||||
const starCount = createMemo(() =>
|
||||
githubData()?.stars
|
||||
? new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(githubData()?.stars)
|
||||
: config.github.starsFormatted.compact,
|
||||
)
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
mobileMenuOpen: false,
|
||||
@@ -155,12 +144,15 @@ export function Header(props: { zen?: boolean; go?: boolean; hideGetStarted?: bo
|
||||
<ul>
|
||||
<li>
|
||||
<a href={config.github.repoUrl} target="_blank" style="white-space: nowrap;">
|
||||
{i18n.t("nav.github")} <span>[{starCount()}]</span>
|
||||
{i18n.t("nav.github")}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={language.route("/docs")}>{i18n.t("nav.docs")}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={language.route("/data")}>{i18n.t("nav.data")}</a>
|
||||
</li>
|
||||
<li>
|
||||
<A href={language.route("/zen")}>{i18n.t("nav.zen")}</A>
|
||||
</li>
|
||||
@@ -252,12 +244,15 @@ export function Header(props: { zen?: boolean; go?: boolean; hideGetStarted?: bo
|
||||
</li>
|
||||
<li>
|
||||
<a href={config.github.repoUrl} target="_blank" style="white-space: nowrap;">
|
||||
{i18n.t("nav.github")} <span>[{starCount()}]</span>
|
||||
{i18n.t("nav.github")}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={language.route("/docs")}>{i18n.t("nav.docs")}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={language.route("/data")}>{i18n.t("nav.data")}</a>
|
||||
</li>
|
||||
<Show when={!props.zen}>
|
||||
<li>
|
||||
<A href={language.route("/zen")}>{i18n.t("nav.zen")}</A>
|
||||
|
||||
@@ -248,8 +248,9 @@ export const dict = {
|
||||
"zen.privacy.exceptionsLink": "الاستثناءات التالية",
|
||||
|
||||
"go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع",
|
||||
"go.banner.text": "MiniMax M3: حد استخدام أكبر 3 مرات لفترة محدودة",
|
||||
"go.meta.description":
|
||||
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
|
||||
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
|
||||
"go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع",
|
||||
"go.hero.body":
|
||||
"يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.",
|
||||
@@ -298,7 +299,7 @@ export const dict = {
|
||||
"go.problem.item2": "حدود سخية ووصول موثوق",
|
||||
"go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين",
|
||||
"go.problem.item4":
|
||||
"يتضمن GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash",
|
||||
"يتضمن GLM-5.1 وGLM-5 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash",
|
||||
"go.how.title": "كيف يعمل Go",
|
||||
"go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.",
|
||||
"go.how.step1.title": "أنشئ حسابًا",
|
||||
@@ -322,7 +323,7 @@ export const dict = {
|
||||
"go.faq.a2": "يتضمن Go النماذج المدرجة أدناه، مع حدود سخية وإتاحة موثوقة.",
|
||||
"go.faq.q3": "هل Go هو نفسه Zen؟",
|
||||
"go.faq.a3":
|
||||
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
|
||||
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
|
||||
"go.faq.q4": "كم تكلفة Go؟",
|
||||
"go.faq.a4.p1.beforePricing": "تكلفة Go",
|
||||
"go.faq.a4.p1.pricingLink": "$5 للشهر الأول",
|
||||
@@ -345,7 +346,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟",
|
||||
"go.faq.a9":
|
||||
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
|
||||
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.",
|
||||
"zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم",
|
||||
|
||||
@@ -252,8 +252,9 @@ export const dict = {
|
||||
"zen.privacy.exceptionsLink": "seguintes exceções",
|
||||
|
||||
"go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos",
|
||||
"go.banner.text": "MiniMax M3 tem limite de uso 3x maior por tempo limitado",
|
||||
"go.meta.description":
|
||||
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
|
||||
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
|
||||
"go.hero.title": "Modelos de codificação de baixo custo para todos",
|
||||
"go.hero.body":
|
||||
"O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.",
|
||||
@@ -303,7 +304,7 @@ export const dict = {
|
||||
"go.problem.item2": "Limites generosos e acesso confiável",
|
||||
"go.problem.item3": "Feito para o maior número possível de programadores",
|
||||
"go.problem.item4":
|
||||
"Inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash",
|
||||
"Inclui GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash",
|
||||
"go.how.title": "Como o Go funciona",
|
||||
"go.how.body":
|
||||
"O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.",
|
||||
@@ -329,7 +330,7 @@ export const dict = {
|
||||
"go.faq.a2": "O Go inclui os modelos listados abaixo, com limites generosos e acesso confiável.",
|
||||
"go.faq.q3": "O Go é o mesmo que o Zen?",
|
||||
"go.faq.a3":
|
||||
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
|
||||
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
|
||||
"go.faq.q4": "Quanto custa o Go?",
|
||||
"go.faq.a4.p1.beforePricing": "O Go custa",
|
||||
"go.faq.a4.p1.pricingLink": "$5 no primeiro mês",
|
||||
@@ -353,7 +354,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?",
|
||||
"go.faq.a9":
|
||||
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
|
||||
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.",
|
||||
"zen.api.error.modelNotSupported": "Modelo {{model}} não suportado",
|
||||
|
||||
@@ -250,8 +250,9 @@ export const dict = {
|
||||
"zen.privacy.exceptionsLink": "følgende undtagelser",
|
||||
|
||||
"go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle",
|
||||
"go.banner.text": "MiniMax M3 får tredoblet brugsgrænse i en begrænset periode",
|
||||
"go.meta.description":
|
||||
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
|
||||
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
|
||||
"go.hero.title": "Kodningsmodeller til lav pris for alle",
|
||||
"go.hero.body":
|
||||
"Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.",
|
||||
@@ -300,7 +301,7 @@ export const dict = {
|
||||
"go.problem.item2": "Generøse grænser og pålidelig adgang",
|
||||
"go.problem.item3": "Bygget til så mange programmører som muligt",
|
||||
"go.problem.item4":
|
||||
"Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash",
|
||||
"Inkluderer GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash",
|
||||
"go.how.title": "Hvordan Go virker",
|
||||
"go.how.body":
|
||||
"Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.",
|
||||
@@ -326,7 +327,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go inkluderer modellerne nedenfor med generøse grænser og pålidelig adgang.",
|
||||
"go.faq.q3": "Er Go det samme som Zen?",
|
||||
"go.faq.a3":
|
||||
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
|
||||
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
|
||||
"go.faq.q4": "Hvad koster Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go koster",
|
||||
"go.faq.a4.p1.pricingLink": "$5 første måned",
|
||||
@@ -349,7 +350,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Hvad er forskellen på gratis modeller og Go?",
|
||||
"go.faq.a9":
|
||||
"Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).",
|
||||
"Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke",
|
||||
|
||||
@@ -252,8 +252,9 @@ export const dict = {
|
||||
"zen.privacy.exceptionsLink": "folgenden Ausnahmen",
|
||||
|
||||
"go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle",
|
||||
"go.banner.text": "MiniMax M3 erhält für begrenzte Zeit 3x Nutzungslimits",
|
||||
"go.meta.description":
|
||||
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.",
|
||||
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.",
|
||||
"go.hero.title": "Kostengünstige Coding-Modelle für alle",
|
||||
"go.hero.body":
|
||||
"Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.",
|
||||
@@ -302,7 +303,7 @@ export const dict = {
|
||||
"go.problem.item2": "Großzügige Limits und zuverlässiger Zugang",
|
||||
"go.problem.item3": "Für so viele Programmierer wie möglich gebaut",
|
||||
"go.problem.item4":
|
||||
"Beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash",
|
||||
"Beinhaltet GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash",
|
||||
"go.how.title": "Wie Go funktioniert",
|
||||
"go.how.body":
|
||||
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.",
|
||||
@@ -328,7 +329,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go umfasst die unten aufgeführten Modelle mit großzügigen Limits und zuverlässigem Zugriff.",
|
||||
"go.faq.q3": "Ist Go dasselbe wie Zen?",
|
||||
"go.faq.a3":
|
||||
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.",
|
||||
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.",
|
||||
"go.faq.q4": "Wie viel kostet Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go kostet",
|
||||
"go.faq.a4.p1.pricingLink": "$5 im ersten Monat",
|
||||
@@ -352,7 +353,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?",
|
||||
"go.faq.a9":
|
||||
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
|
||||
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.",
|
||||
"zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const dict = {
|
||||
"nav.github": "GitHub",
|
||||
"nav.docs": "Docs",
|
||||
"nav.data": "Data",
|
||||
"nav.changelog": "Changelog",
|
||||
"nav.discord": "Discord",
|
||||
"nav.x": "X",
|
||||
@@ -247,8 +248,9 @@ export const dict = {
|
||||
"zen.privacy.exceptionsLink": "following exceptions",
|
||||
|
||||
"go.title": "OpenCode Go | Low cost coding models for everyone",
|
||||
"go.banner.text": "MiniMax M3 gets 3× usage limits for a limited time",
|
||||
"go.meta.description":
|
||||
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
|
||||
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
|
||||
"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.",
|
||||
@@ -296,7 +298,7 @@ export const dict = {
|
||||
"go.problem.item2": "Generous limits and reliable access",
|
||||
"go.problem.item3": "Built for as many programmers as possible",
|
||||
"go.problem.item4":
|
||||
"Includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash",
|
||||
"Includes GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash",
|
||||
"go.how.title": "How Go works",
|
||||
"go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.",
|
||||
"go.how.step1.title": "Create an account",
|
||||
@@ -321,7 +323,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go includes the models listed below, 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 starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
|
||||
"No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
|
||||
"go.faq.q4": "How much does Go cost?",
|
||||
"go.faq.a4.p1.beforePricing": "Go costs",
|
||||
"go.faq.a4.p1.pricingLink": "$5 first month",
|
||||
@@ -345,7 +347,7 @@ export const dict = {
|
||||
|
||||
"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.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash 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).",
|
||||
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash 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}} is not supported",
|
||||
|
||||
@@ -253,8 +253,9 @@ export const dict = {
|
||||
"zen.privacy.exceptionsLink": "siguientes excepciones",
|
||||
|
||||
"go.title": "OpenCode Go | Modelos de programación de bajo coste para todos",
|
||||
"go.banner.text": "MiniMax M3 tiene límites de uso 3x mayores por tiempo limitado",
|
||||
"go.meta.description":
|
||||
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.",
|
||||
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.",
|
||||
"go.hero.title": "Modelos de programación de bajo coste para todos",
|
||||
"go.hero.body":
|
||||
"Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.",
|
||||
@@ -304,7 +305,7 @@ export const dict = {
|
||||
"go.problem.item2": "Límites generosos y acceso fiable",
|
||||
"go.problem.item3": "Creado para tantos programadores como sea posible",
|
||||
"go.problem.item4":
|
||||
"Incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash",
|
||||
"Incluye GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash",
|
||||
"go.how.title": "Cómo funciona Go",
|
||||
"go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.",
|
||||
"go.how.step1.title": "Crear una cuenta",
|
||||
@@ -329,7 +330,7 @@ export const dict = {
|
||||
"go.faq.a2": "Go incluye los modelos que se indican abajo, con límites generosos y acceso confiable.",
|
||||
"go.faq.q3": "¿Es Go lo mismo que Zen?",
|
||||
"go.faq.a3":
|
||||
"No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.",
|
||||
"No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.",
|
||||
"go.faq.q4": "¿Cuánto cuesta Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go cuesta",
|
||||
"go.faq.a4.p1.pricingLink": "$5 el primer mes",
|
||||
@@ -353,7 +354,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?",
|
||||
"go.faq.a9":
|
||||
"Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).",
|
||||
"Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).",
|
||||
|
||||
"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",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user