Compare commits

..

123 Commits

Author SHA1 Message Date
opencode 261e4bbd7a release: v1.16.2 2026-06-05 15:58:36 +00:00
Adam 81432398b0 fix(stats): add mobile chart end spacing 2026-06-05 10:22:00 -05:00
Aiden Cline 0de8af9c63 fix(workflows): serialize desktop release uploads (#30978) 2026-06-05 10:21:22 -05:00
Kit Langton 0efca52b52 feat(core): admit v2 skill guidance (#30843) 2026-06-05 11:19:55 -04:00
Aiden Cline 4e541c586a fix(opencode): gate reasoning summaries by provider (#30973) 2026-06-05 10:08:39 -05:00
Aiden Cline 664d9a3c8b test(opencode): remove shell timeout output race (#30974) 2026-06-05 10:08:26 -05:00
Aiden Cline 4257aaee7f test(opencode): remove disposal event wait race (#30971) 2026-06-05 09:58:40 -05:00
opencode-agent[bot] da08b61bcc chore: generate 2026-06-05 14:49:06 +00:00
Kit Langton 6488b2ff11 fix(core): respect v2 default agents (#30969) 2026-06-05 10:47:31 -04:00
opencode-agent[bot] a980e1c7db chore: generate 2026-06-05 14:39:44 +00:00
Shoubhit Dash d1b31e2aa0 fix(opencode): prevent destructive edit matches (#30932) 2026-06-05 20:08:05 +05:30
Adam 0e15f9a00b fix(stats): scroll model charts to latest on mobile 2026-06-05 09:33:17 -05:00
Jérôme Benoit 9b6a9520c3 fix(workflows): retry nix-hashes compute-hash on transient failure (#30743) 2026-06-05 09:32:01 -05:00
alberto e6602372d5 fix(opencode): resolve Bedrock hang by using node build conditions (#30873) 2026-06-05 09:22:58 -05:00
Aiden Cline f5504ee0f0 chore: bun install (#30968) 2026-06-05 09:16:53 -05:00
Shoubhit Dash 58fbd64b4a fix(core): recover corrupted models cache (#30947) 2026-06-05 18:07:09 +05:30
weiconghe 712beb3e59 fix: use mapError instead of orDie for context snapshot decoding (#30905)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-06-05 18:07:00 +05:30
Shoubhit Dash dc67ffa9de chore: rm fuzzy search on references (#30931) 2026-06-05 16:57:39 +05:30
Shoubhit Dash 61c485e2a7 feat(tui): add diff hunk navigation (#30935) 2026-06-05 16:56:53 +05:30
opencode-agent[bot] d9bed9e704 chore: generate 2026-06-05 11:23:14 +00:00
Jack 7ccd17a15f docs: update Go Qwen tiered pricing (#30936) 2026-06-05 19:21:46 +08:00
pcadena-lila 162beaa474 fix(tui): fall back to local cwd when editor spawns in attach mode (#30583) 2026-06-05 09:53:41 +00:00
opencode-agent[bot] 0fd2975e2b chore: generate 2026-06-05 09:48:43 +00:00
Simon Klee ce2e4ea4a9 opencode/run: refresh themes after terminal reloads (#30917) 2026-06-05 09:47:17 +00:00
Simon Klee b7d8c21f89 tui: guard path formatting inputs (#30469)
Fixes #27726, #25216, #24856, #24294, #17071, #29164, #24837, #16865, #14279, #29895
2026-06-05 09:45:03 +00:00
Brendan Allan 43eea55305 fix(app): tab overflow (#30894) 2026-06-05 07:56:44 +00:00
Brendan Allan c461480981 fix(app): handle tab overflow and scrolling in titlebar (#30886) 2026-06-05 15:42:39 +08:00
opencode-agent[bot] 56e5b171dd chore: generate 2026-06-05 06:31:37 +00:00
Luke Parker 31099b9dfb feat(app): improve desktop multi-server support (#30678)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-06-05 06:30:02 +00:00
opencode-agent[bot] af6f483485 chore: generate 2026-06-05 05:31:34 +00:00
Dax 165a74106e fix(cli): harden daemon lifecycle (#30844) 2026-06-05 01:30:19 -04:00
opencode-agent[bot] 67515a1881 fix(tui): route permission replies to session directory (#30851)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-05 00:56:06 -04:00
Aiden Cline e729962d48 fix(tui): prioritize models slash autocomplete (#30848) 2026-06-04 23:28:43 -05:00
opencode-agent[bot] eccdea2fb4 chore: generate 2026-06-05 03:42:49 +00:00
Brendan Allan dfed951ebd fix(app): improve tab handling (#30669) 2026-06-05 11:41:30 +08:00
Kit Langton 75c5d0ab83 feat(tui): allow backgrounding synchronous subagents (#30488) 2026-06-04 23:40:52 -04:00
opencode-agent[bot] 6c70fb6a94 chore: generate 2026-06-05 03:28:04 +00:00
Kit Langton df1e16a619 feat(core): persist v2 session context epochs (#30789) 2026-06-04 23:26:43 -04:00
opencode-agent[bot] d7cdbc99e3 chore: generate 2026-06-05 03:21:15 +00:00
Kamesh Sampath b551fa85c5 feat(core): add Snowflake Cortex provider (#29901)
Co-authored-by: Cortex Code <noreply@snowflake.com>
2026-06-04 22:19:55 -05:00
opencode-agent[bot] 6416368e60 chore: generate 2026-06-05 03:14:18 +00:00
Kit Langton 8f84a7cae0 feat(core): attach global native tools (#30832) 2026-06-04 23:12:17 -04:00
opencode 2a69fb0de2 sync release versions for v1.16.0 2026-06-05 03:08:13 +00:00
opencode-agent[bot] d30581c6d9 chore: generate 2026-06-05 00:54:49 +00:00
Aarav Sareen 973b9f1853 feat(app): color themes (#30824)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-06-05 00:53:36 +00:00
opencode-agent[bot] 9c3357aefd chore: generate 2026-06-05 00:50:58 +00:00
Kit Langton d9d2bd66f1 feat(core): add public native API (#30828) 2026-06-04 20:49:43 -04:00
Adam 5e08437397 fix(stats): sort metric charts by top usage 2026-06-04 19:36:29 -05:00
Adam a6aa76f5e0 feat(stats): refresh stats routes and homepage (#30419) 2026-06-05 00:32:49 +00:00
opencode-agent[bot] 53b1c718e3 chore: generate 2026-06-04 23:58:11 +00:00
Kit Langton d31a1345d5 fix(llm): normalize OpenAI function tool schemas 2026-06-04 19:56:31 -04:00
opencode-agent[bot] 8a2260d062 chore: generate 2026-06-04 23:26:09 +00:00
Kit Langton 1e35a56e25 refactor(core): make v2 session inputs event sourced (#30785) 2026-06-04 19:24:30 -04:00
Dax Raad 624113390e ci: publish 2026-06-04 19:23:41 -04:00
Luke Parker c1f3e11a76 feat(desktop): surface local server startup failures (#30822) 2026-06-04 22:45:48 +00:00
opencode-agent[bot] 8fc5613bec chore: update nix node_modules hashes 2026-06-04 20:16:53 +00:00
Sebastian c08a2f1072 upgrade opentui to 0.3.2 (#30748) 2026-06-04 22:05:59 +02:00
Aiden Cline 2d38d749fe tweak: background agent prompting to avoid polling issues (#30790) 2026-06-04 14:18:03 -05:00
opencode-agent[bot] 07477d6e28 chore: generate 2026-06-04 19:09:33 +00:00
James Long 6c068be97a feat(core): moving sessions (#30640) 2026-06-04 15:07:46 -04:00
Colin McDonnell 958c66ce3b fix: bump @openrouter/ai-sdk-provider to 2.9.0 (#30800) 2026-06-04 14:05:51 -05:00
Aiden Cline 93834b4949 fix(tui): add Vue syntax highlighting (#30802) 2026-06-04 14:02:11 -05:00
Aiden Cline 69b800958a fix(opencode): attribute task child agent on creation (#30786) 2026-06-04 13:19:46 -05:00
LIU Xinyu 7f42e079df fix(opencode): ACP.loadSession should replay all messages (#30761)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-06-04 23:14:21 +05:30
Ariane Emory 78d612a64a fix(tui): show toast when variant_list keybind used with no variants (#30724) 2026-06-04 12:14:24 -05:00
Dax Raad c88bc2eb52 make scripts executable 2026-06-04 11:19:36 -04:00
opencode-agent[bot] 658930893f chore: update nix node_modules hashes 2026-06-04 15:15:12 +00:00
Dax Raad e747f8b052 fix build 2026-06-04 11:11:23 -04:00
opencode-agent[bot] 7b988051b9 fix(enterprise): install hono standard validator peer (#30740)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-04 10:55:22 -04:00
Frank fdd3dc0eb2 zen: nemotron 3 ultra 2026-06-04 10:40:58 -04:00
Shoubhit Dash 1e121b416f fix(opencode): respect disabled auto compaction on overflow (#30749) 2026-06-04 19:47:49 +05:30
Shoubhit Dash 5d07c66e38 feat(tui): improve experimental session switcher (#30738) 2026-06-04 19:22:01 +05:30
Kit Langton 0fcc27f8b8 fix(core): reset pre-launch session projections (#30728) 2026-06-04 09:43:10 -04:00
opencode-agent[bot] 34e1aabcea fix(acp): replay loaded session transcript (#30645)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-06-04 12:35:57 +05:30
opencode-agent[bot] 14e2b0d799 chore: generate 2026-06-04 06:59:09 +00:00
Dax 9e11b45908 feat(core): add command registry (#30624) 2026-06-04 02:57:43 -04:00
Aiden Cline 4db9d0a0dc refactor(opencode): clean up task tool prompts (#30687) 2026-06-04 00:44:00 -05:00
opencode-agent[bot] c0c0319b1f chore: update nix node_modules hashes 2026-06-04 05:25:53 +00:00
Aiden Cline b9755cdfca test: wait for shell truncation readiness (#30679) 2026-06-04 00:20:35 -05:00
Aiden Cline 59d31f4a7e feat: bump bedrock and add proper mantle support for openai models through aws bedrock (#30464) 2026-06-04 00:09:12 -05:00
Aiden Cline 4356b4249b fix(opencode): avoid shell cancel race (#30641) 2026-06-03 22:44:25 -05:00
Aiden Cline 2f858757e9 docs: correct compaction prune default (#30670) 2026-06-03 22:37:39 -05:00
opencode-agent[bot] 096f53b765 chore: update nix node_modules hashes 2026-06-04 03:15:13 +00:00
opencode-agent[bot] 1e882a15c3 chore: generate 2026-06-04 03:03:39 +00:00
Kit Langton 540e83b6c8 feat(core): add embedded v2 session runtime and tool foundation (#30632) 2026-06-03 23:02:17 -04:00
Brendan Allan e4ff16d7ec fix(app,ui): session review reactivity and VCS query cache (#30660) 2026-06-04 02:59:56 +00:00
Luke Parker c7839423a0 feat(app): v2 thinking level selector (#30646) 2026-06-04 11:35:30 +10:00
Luke Parker d032313928 fix(app): hide unavailable titlebar update (#30642) 2026-06-03 23:51:40 +00:00
opencode-agent[bot] 5c32b2de33 chore: generate 2026-06-03 23:42:15 +00:00
Jérôme Benoit 514691e2ae fix(opencode): route SAP AI Core reasoning variants through modelParams (#30482) 2026-06-03 18:40:39 -05:00
Frank dd1b75eb45 zen: update nvidia tos 2026-06-03 19:09:02 -04:00
Aiden Cline c017524141 fix(opencode): preserve variant for delegated tasks (#30630) 2026-06-03 17:43:47 -05:00
Aiden Cline eb4f9bb4c4 chore: remove zed extension and automation (#30628) 2026-06-03 17:16:21 -05:00
Adam b0930b4a6c fix(stats): count all go usage 2026-06-03 17:06:33 -05:00
opencode-agent[bot] d92ecc5db6 chore: update nix node_modules hashes 2026-06-03 21:16:06 +00:00
opencode-agent[bot] 050c2463c8 chore: generate 2026-06-03 21:00:33 +00:00
Dax c7cc459436 feat(core): add skill registry and file agent loading (#30617) 2026-06-03 16:58:34 -04:00
Aiden Cline 373fe67d55 fix(core): expose azure openai xhigh efforts (#30620) 2026-06-03 15:27:23 -05:00
Dax ff0744b4af refactor(core): nest model api id (#30603) 2026-06-03 14:11:38 -04:00
opencode-agent[bot] 7019ffcaf7 chore: generate 2026-06-03 17:51:22 +00:00
Dax 84806a8689 test(core): cover v1 provider option lowering (#30599) 2026-06-03 17:49:28 +00:00
opencode-agent[bot] 82f03dd036 chore: generate 2026-06-03 17:35:02 +00:00
Dax f60ac3b450 refactor(core): remove ai sdk option fields (#30581) 2026-06-03 17:32:58 +00:00
Aiden Cline 138c0b9bdf fix(openai): preserve websocket idle state (#30586) 2026-06-03 12:23:00 -05:00
Jack a22c0a2d29 docs(go): add Qwen3.7 Plus model (#30594) 2026-06-04 00:40:19 +08:00
Adam 3f040cc3ea fix(stats): serve stats og image from banner 2026-06-03 10:53:07 -05:00
James Long 8a401e518b fix(tui): route question responses by session directory (#30578) 2026-06-03 15:11:55 +00:00
Shoubhit Dash 4160755572 fix(acp): clean read tool display content (#30569) 2026-06-03 19:36:27 +05:30
Shoubhit Dash 4dc3061602 fix(acp): include external directory permission context (#30567) 2026-06-03 19:05:28 +05:30
Shoubhit Dash 3e057a1a5a fix(acp): classify task as think (#30565) 2026-06-03 18:55:55 +05:30
Shoubhit Dash ef0956ac8c fix(acp): classify apply_patch as edit (#30564) 2026-06-03 18:51:41 +05:30
opencode-agent[bot] 8e05e4eb22 chore: update nix node_modules hashes 2026-06-03 12:21:13 +00:00
opencode-agent[bot] cc0fe907d7 chore: generate 2026-06-03 12:04:03 +00:00
Adam a5a65247c4 feat(stats): add geo breakdown (#30456) 2026-06-03 12:02:37 +00:00
opencode-agent[bot] 97de07397a chore: update nix node_modules hashes 2026-06-03 10:05:12 +00:00
Simon Klee a604d75993 tui: truncate sidebar file paths (#30531) 2026-06-03 11:54:24 +02:00
opencode-agent[bot] c579c0795a chore: generate 2026-06-03 09:49:08 +00:00
Shoubhit Dash de2b08543b refactor(core): consolidate pty service (#30537) 2026-06-03 15:17:46 +05:30
Brendan Allan acc6f5e884 feat(app): add servers tab to settings dialog (#29675) 2026-06-03 08:25:45 +00:00
opencode-agent[bot] 645765296f chore: generate 2026-06-03 07:30:40 +00:00
Brendan Allan 0b0d3b9e07 fix(ui): render whole-file patches as complete diffs (#30516) 2026-06-03 07:29:23 +00:00
Ulises Jeremias ca7865861a fix(opencode): fallback to sh for curl upgrade (#30499)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-06-03 12:27:20 +05:30
Aarav Sareen 8890161b86 feat(app): new update button (#30460)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-06-03 06:40:12 +00:00
787 changed files with 69098 additions and 14307 deletions
+15 -5
View File
@@ -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
+35 -6
View File
@@ -90,6 +90,7 @@ jobs:
id: build
run: |
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@@ -107,6 +108,12 @@ jobs:
with:
name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows*
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
path: packages/cli/dist/cli-*
outputs:
version: ${{ needs.version.outputs.version }}
@@ -327,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:
@@ -349,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"
@@ -371,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'
@@ -446,12 +450,24 @@ jobs:
name: opencode-cli-signed-windows
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-preview-cli
path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: needs.version.outputs.release
with:
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
@@ -478,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 }}
-35
View File
@@ -1,35 +0,0 @@
name: "sync-zed-extension"
on:
workflow_dispatch:
release:
types: [published]
jobs:
zed:
name: Release Zed Extension
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
- uses: ./.github/actions/setup-bun
- name: Get version tag
id: get_tag
run: |
if [ "${{ github.event_name }}" = "release" ]; then
TAG="${{ github.event.release.tag_name }}"
else
TAG=$(git tag --list 'v[0-9]*.*' --sort=-version:refname | head -n 1)
fi
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "Using tag: ${TAG}"
- name: Sync Zed extension
run: |
./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }}
env:
ZED_EXTENSIONS_PAT: ${{ secrets.ZED_EXTENSIONS_PAT }}
ZED_PR_PAT: ${{ secrets.ZED_PR_PAT }}
+2 -1
View File
@@ -64,7 +64,8 @@ jobs:
turbo-${{ runner.os }}-
- name: Run unit tests
run: bun turbo test:ci
timeout-minutes: 20
run: bun turbo test:ci --log-order=stream --log-prefix=task
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
+2
View File
@@ -15,6 +15,8 @@ ts-dist
.turbo
**/.serena
.serena/
**/.omo
.omo/
/result
refs
Session.vim
+1 -1
View File
@@ -1,6 +1,6 @@
---
description: translate English to other languages
model: opencode/claude-opus-4-7
model: opencode/claude-opus-4-8
---
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
+12
View File
@@ -138,3 +138,15 @@ const table = sqliteTable("session", {
## Type Checking
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
## V2 Session Core
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
- Keep `SessionExecution` process-global and Session-ID based. It discovers placement through the read-side `SessionStore` and `LocationServiceMap.get(session.location)`; no layer should take a Session ID.
- 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.
+98
View File
@@ -0,0 +1,98 @@
# OpenCode Session Runtime
OpenCode sessions preserve durable conversational history while assembling the runtime context an agent needs to act correctly in its current environment.
## Language
**System Context**:
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
**System Context Registry**:
The Location-scoped registry of ordered, scoped producers that contribute to the current **System Context**.
**Mid-Conversation System Message**:
A durable chronological instruction that tells the model the newly effective state of a changed **Context Source**.
_Avoid_: System update, system notification, raw text diff
**Context Epoch**:
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**.
_Avoid_: Live system prompt
**Context Snapshot**:
The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a provider turn.
**Unavailable Context**:
An expected temporary inability to observe a **Context Source** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded.
**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.
## Relationships
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
- **Session History** contains projected conversational messages and admitted **Mid-Conversation System Messages**; the active **Baseline System Context** remains separate provider-request state.
- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Provider-Turn Boundary**.
- A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state.
- A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model.
- The current **Context Snapshot** advances atomically with the corresponding durable **Mid-Conversation System Message**.
- A **Context Snapshot** stores one codec-encoded JSON value and, for removable dynamic sources, a pre-rendered removal message per stable **Context Source** key.
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Provider-Turn Boundary**.
- **Context Source** keys are stable and namespaced; duplicate keys fail composition. `SystemContext.combine(...)` preserves caller order; the **System Context Registry** evaluates producers concurrently and combines them in stable contribution-key order so rendered context remains deterministic.
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
- `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context.
- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run.
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move.
- 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.
- 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.
## Example dialogue
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current **System Context**."
## Flagged ambiguities
- Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Sources**, or narrow its semantics.
+696 -1044
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-x64", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider"]
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"]
[test]
root = "./do-not-run-tests-from-root"
+1 -1
View File
@@ -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://stats.${domain}/stats`,
PUBLIC_URL: `https://${domain}/stats`,
},
})
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-fzplLKjzYefVCRhAgfCWWIa3me2JiI2d9kDBACnz4fA=",
"aarch64-linux": "sha256-gccipj2CAau/lZ7KTQhdeU8n6fcXDuUt4X4XbNufCko=",
"aarch64-darwin": "sha256-r0QEetxKUf6HrtMOozyvb4jjEJjVtp+DE8aiKKhRrVI=",
"x86_64-darwin": "sha256-bU+5WudkiIflFgoqgLSf30iqeNNLuEbScI5oMFPu9eM="
"x86_64-linux": "sha256-mXTzANDuuy+BY4vzhuuL5Q6JVVTJCKdHuD/Fo8pSfgI=",
"aarch64-linux": "sha256-t1Uf+PIDvj9bogsSo2Dg1e+zJM2CHQ8lpA/I3vFQA1Q=",
"aarch64-darwin": "sha256-HKpMwzpYhCQOu0xHugi4ZIC/Va2BSiQpM2TbA6BEZDU=",
"x86_64-darwin": "sha256-m5h7h9KxkcIrdTO2QzQftq68d0Ru0IsCfu3WzMp4P68="
}
}
+5 -4
View File
@@ -15,7 +15,7 @@
"lint": "oxlint",
"typecheck": "bun turbo typecheck",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
@@ -37,10 +37,11 @@
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.3.1",
"@opentui/keymap": "0.3.1",
"@opentui/solid": "0.3.1",
"@opentui/core": "0.3.2",
"@opentui/keymap": "0.3.2",
"@opentui/solid": "0.3.2",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -0,0 +1,87 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/PromptThinkingLevelRegression"
const projectID = "proj_prompt_thinking_level_regression"
const sessionID = "ses_prompt_thinking_level_regression"
test("shows the V2 thinking level control while relevant", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "prompt-thinking-level-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"thinking-model": {
id: "thinking-model",
name: "Thinking Model",
limit: { context: 200_000 },
variants: { high: {} },
},
},
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "thinking-model" },
},
sessions: [
{
id: sessionID,
slug: "prompt-thinking-level-regression",
projectID,
directory,
title: "Prompt thinking level regression",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
const composer = page.locator('[data-component="session-composer"]')
const input = composer.locator('[data-component="prompt-input"]')
const control = composer.locator('[data-component="prompt-variant-control"]')
await expectAppVisible(composer)
await idleComposer(page)
await expect(control).toBeHidden()
await composer.hover()
await expect(control).toBeVisible()
await control.locator('[data-action="prompt-model-variant"]').click()
const high = page.getByRole("option", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
await expect(high).toBeVisible()
await high.click()
await idleComposer(page)
await input.focus()
await expect(control).toBeVisible()
await idleComposer(page)
await expect(control).toBeVisible()
})
async function idleComposer(page: Page) {
await page.mouse.move(0, 0)
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
}
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test"
import { test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
test("shows loaded sessions before the directory path request resolves", async ({ page }) => {
await mockOpenCodeServer(page, {
@@ -33,7 +34,7 @@ test("shows loaded sessions before the directory path request resolves", async (
await page.goto("/")
try {
await expect(page.getByText(fixture.expected.sourceTitle).first()).toBeVisible({ timeout: 5_000 })
await expectAppVisible(page.getByText(fixture.expected.sourceTitle).first())
} finally {
releasePath()
}
@@ -1,5 +1,6 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/TimelineStateRegression"
const projectID = "proj_timeline_state_regression"
@@ -106,10 +107,10 @@ test.describe("regression: session timeline local row state", () => {
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expect(page.getByRole("heading", { name: title })).toBeVisible()
await expectSessionTitle(page, title)
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
await expect(wrapper).toBeVisible()
await expectAppVisible(wrapper)
await expectExpanded(wrapper, true)
await wrapper.evaluate((element) => {
@@ -142,11 +143,11 @@ test.describe("regression: session timeline local row state", () => {
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expect(page.getByRole("heading", { name: title })).toBeVisible()
await expectSessionTitle(page, title)
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
await expect(wrapper).toBeVisible()
await expect(wrapper.locator('[data-component="file"][data-mode="diff"]').first()).toBeVisible()
await expectAppVisible(wrapper)
await expectAppVisible(wrapper.locator('[data-component="file"][data-mode="diff"]').first())
await markDiffProbe(page)
events.push({
@@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ContextResizeRegression"
const projectID = "proj_context_resize_regression"
@@ -23,9 +24,9 @@ test.describe("regression: session timeline context group resize", () => {
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expect(page.getByRole("heading", { name: title })).toBeVisible()
await expect(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()).toBeVisible()
await expectSessionTitle(page, title)
await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first())
await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first())
await settle(page)
const samples = await sampleExpansion(page)
@@ -3,6 +3,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { fixture, pageMessages } from "./session-timeline.fixture"
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
import { mockOpenCodeServer } from "../utils/mock-server"
import { APP_READY_TIMEOUT, expectAppVisible, expectSessionTitle } from "../utils/waits"
const forbiddenText = ["Load details", "Show earlier steps"]
@@ -411,18 +412,21 @@ function expectCompleteScroll(
async function selectHomeProject(page: Page, projectName: string) {
await page.goto("/")
await page
const row = page
.locator('[data-component="home-project-row"]')
.filter({ hasText: new RegExp(projectName, "i") })
.click()
.first()
await expectAppVisible(row)
await row.click()
await expect(row).toHaveAttribute("data-selected", "", { timeout: APP_READY_TIMEOUT })
await expect(page).toHaveURL(/\/$/)
}
async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
await expectSessionTitle(page, expectedTitle)
}
async function expectSessionReady(page: Page) {
await expect(page.getByRole("textbox", { name: /Ask anything/i })).toBeVisible()
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
}
+11
View File
@@ -0,0 +1,11 @@
import { expect, type Locator, type Page } from "@playwright/test"
export const APP_READY_TIMEOUT = 30_000
export async function expectAppVisible(locator: Locator) {
await expect(locator).toBeVisible({ timeout: APP_READY_TIMEOUT })
}
export async function expectSessionTitle(page: Page, title: string) {
await expectAppVisible(page.getByRole("heading", { name: title }))
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.15.13",
"version": "1.16.2",
"description": "",
"type": "module",
"exports": {
+39 -36
View File
@@ -25,7 +25,6 @@ import {
onCleanup,
type ParentProps,
Show,
Suspense,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command"
@@ -33,6 +32,7 @@ import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
@@ -43,11 +43,11 @@ 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 Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { ServersProvider } from "./context/servers"
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -211,26 +211,21 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
Effect.runPromise,
),
)
const checking = createMemo(
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
)
return (
<Suspense
<Show
when={!checking()}
fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>
{/*<Show
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>*/}
{checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest}
<Show
when={startupHealthCheck()}
when={startupHealthCheck.latest}
fallback={
<ConnectionError
onRetry={() => {
@@ -246,8 +241,7 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
>
{props.children}
</Show>
{/*</Show>*/}
</Suspense>
</Show>
)
}
@@ -310,34 +304,43 @@ function ServerKey(props: ParentProps) {
export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
}) {
return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServersProvider>
<ServerProvider
defaultServer={props.defaultServer}
canonicalLocalServer={props.canonicalLocalServer}
servers={props.servers}
>
<GlobalProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
<QueryProvider>
<ServerSDKProvider>
<ServerSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</ServerSyncProvider>
</ServerSDKProvider>
</QueryProvider>
</ServerKey>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<ServerKey>
<QueryProvider>
<ServerSDKProvider>
<ServerSyncProvider>
<RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>
</ServerSyncProvider>
</ServerSDKProvider>
</QueryProvider>
</ServerKey>
</TabsProvider>
)}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</ConnectionGate>
</ServersProvider>
</GlobalProvider>
</ServerProvider>
)
}
@@ -277,6 +277,7 @@ export function DialogConnectProvider(props: { provider: string }) {
<div class="text-14-regular text-text-base">{select()?.message}</div>
<div>
<List
class="px-3"
items={select()?.options ?? []}
key={(x) => x.value}
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
@@ -364,6 +365,7 @@ export function DialogConnectProvider(props: { provider: string }) {
</div>
<div>
<List
class="px-3"
ref={(ref) => {
listRef = ref
}}
@@ -6,21 +6,23 @@ import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/core/util/path"
import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export function DialogEditProject(props: { project: LocalProject }) {
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
const dialog = useDialog()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const global = useGlobal()
const language = useLanguage()
const serverCtx = createMemo(() => global.createServerCtx(props.server))
const serverSDK = () => serverCtx().sdk
const serverSync = () => serverCtx().sync
const folderName = createMemo(() => getFilename(props.project.worktree))
const defaultName = createMemo(() => props.project.name || folderName())
@@ -78,19 +80,19 @@ export function DialogEditProject(props: { project: LocalProject }) {
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await serverSDK.client.project.update({
await serverSDK().client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
serverSync.project.icon(props.project.worktree, store.iconOverride || undefined)
serverSync().project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
}
serverSync.project.meta(props.project.worktree, {
serverSync().project.meta(props.project.worktree, {
name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
+1 -1
View File
@@ -88,7 +88,7 @@ export const DialogFork: Component = () => {
return (
<Dialog title={language.t("command.session.fork")}>
<List
class="flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
class="flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.fork.empty")}
key={(x) => x.id}
@@ -39,6 +39,7 @@ export const DialogManageModels: Component = () => {
}
>
<List
class="px-3"
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x?.provider?.id}:${x?.id}`}
@@ -6,15 +6,16 @@ import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import fuzzysort from "fuzzysort"
import { createMemo, createResource, createSignal } from "solid-js"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLayout } from "@/context/layout"
import { ServerSDK } from "@/context/server-sdk"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global"
interface DialogSelectDirectoryProps {
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
server: ServerConnection.Any
}
type Row = {
@@ -127,11 +128,7 @@ function uniqueRows(rows: Row[]) {
})
}
function useDirectorySearch(args: {
sdk: ReturnType<typeof useServerSDK>
start: () => string | undefined
home: () => string
}) {
function useDirectorySearch(args: { sdk: ServerSDK; start: () => string | undefined; home: () => string }) {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0
@@ -246,9 +243,8 @@ function useDirectorySearch(args: {
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const sync = useServerSync()
const sdk = useServerSDK()
const layout = useLayout()
const global = useGlobal()
const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server)
const dialog = useDialog()
const language = useLanguage()
@@ -279,7 +275,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
})
const recentProjects = createMemo(() => {
const projects = layout.projects.list()
const projects = serverCtx.projects.list()
const byProject = new Map<string, number>()
for (const project of projects) {
@@ -324,6 +320,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return (
<Dialog title={props.title ?? language.t("command.project.open")}>
<List
class="px-3"
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.directory.empty")}
loadingMessage={language.t("common.loading")}
@@ -386,6 +386,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
return (
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
<List
class="px-3"
search={{
placeholder: filesOnly()
? language.t("session.header.searchFiles")
@@ -55,6 +55,7 @@ export const DialogSelectMcp: Component = () => {
description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })}
>
<List
class="px-3"
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.mcp.empty")}
key={(x) => x?.name ?? ""}
@@ -45,7 +45,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
<List
class="[&_[data-slot=list-scroll]]:overflow-visible"
class="px-3 [&_[data-slot=list-scroll]]:overflow-visible"
ref={(ref) => (listRef = ref)}
items={model.list}
current={model.current()}
@@ -90,7 +90,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
<div class="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div>
<div class="w-full">
<List
class="w-full px-0"
class="w-full px-3"
key={(p) => p.id}
items={providers.popular}
activeIcon="plus-small"
@@ -37,7 +37,7 @@ const ModelList: Component<{
return (
<List
class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
class={`flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true, action: props.action }}
emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x.provider.id}:${x.id}`}
@@ -29,6 +29,7 @@ export const DialogSelectProvider: Component = () => {
return (
<Dialog title={language.t("command.provider.connect")} transition>
<List
class="px-3"
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.provider.empty")}
activeIcon="plus-small"
@@ -9,13 +9,16 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { createEffect, createMemo, createResource, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
const DEFAULT_USERNAME = "opencode"
@@ -71,7 +74,7 @@ function useDefaultServer() {
}
}
return { defaultKey, canDefault, setDefault }
return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
}
function useServerPreview() {
@@ -121,7 +124,7 @@ function ServerForm(props: ServerFormProps) {
}
return (
<div class="px-5">
<div>
<div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<TextField
@@ -172,16 +175,31 @@ function ServerForm(props: ServerFormProps) {
}
export function DialogSelectServer() {
const navigate = useNavigate()
const dialog = useDialog()
const controller = useServerManagementController({ onSelect: dialog.close })
return (
<Dialog title={controller.formTitle()}>
<div class="flex flex-1 min-h-0 flex-col px-5">
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}>
<ServerConnectionForm controller={controller} />
</Show>
</div>
</Dialog>
)
}
export function useServerManagementController(options: { onSelect?: () => void } = {}) {
const navigate = useNavigate()
const server = useServer()
const tabs = useTabs()
const global = useGlobal()
const platform = usePlatform()
const language = useLanguage()
const { defaultKey, canDefault, setDefault } = useDefaultServer()
const { previewStatus } = useServerPreview()
const checkServerHealth = useCheckServerHealth()
const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: {
url: "",
name: "",
@@ -295,12 +313,14 @@ export function DialogSelectServer() {
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const originalKey = ServerConnection.key(original)
const active = server.key
tabs.removeServer(originalKey)
const newConn = server.add(next)
if (!newConn) return
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive)
server.remove(ServerConnection.key(original))
server.remove(originalKey)
}
const items = createMemo(() => {
@@ -311,7 +331,12 @@ export function DialogSelectServer() {
return [current, ...list.filter((x) => x !== current)]
})
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
const settings = useSettings()
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
)
const sortedItems = createMemo(() => {
const list = items()
@@ -326,32 +351,16 @@ export function DialogSelectServer() {
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)])
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
async function refreshHealth() {
const results: Record<ServerConnection.Key, ServerHealth> = {}
await Promise.all(
items().map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
setStore("status", reconcile(results))
}
createEffect(() => {
items()
void refreshHealth()
const interval = setInterval(refreshHealth, 10_000)
onCleanup(() => clearInterval(interval))
})
async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
dialog.close()
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
options.onSelect?.()
if (persist && conn.type === "http") {
server.add(conn)
navigate("/")
@@ -457,7 +466,7 @@ export function DialogSelectServer() {
username: conn.http.username ?? "",
password: conn.http.password ?? "",
error: "",
status: store.status[ServerConnection.key(conn)]?.healthy,
status: global.servers.health[ServerConnection.key(conn)]?.healthy,
})
}
@@ -496,154 +505,190 @@ export function DialogSelectServer() {
})
async function handleRemove(url: ServerConnection.Key) {
tabs.removeServer(url)
server.remove(url)
if ((await platform.getDefaultServer?.()) === url) {
void platform.setDefaultServer?.(null)
}
}
return {
defaultKey,
canDefault,
current,
sortedItems,
status: () => global.servers.health,
isFormMode,
isAddMode,
formTitle,
formBusy,
formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
select,
setDefault,
startAdd,
startEdit,
resetForm,
submitForm,
handleRemove,
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
}
}
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
const language = useLanguage()
const settings = useSettings()
return (
<Dialog title={formTitle()}>
<div class="flex flex-1 min-h-0 flex-col gap-2">
<Show
when={!isFormMode()}
fallback={
<ServerForm
value={isAddMode() ? store.addServer.url : store.editServer.value}
name={isAddMode() ? store.addServer.name : store.editServer.name}
username={isAddMode() ? store.addServer.username : store.editServer.username}
password={isAddMode() ? store.addServer.password : store.editServer.password}
placeholder={language.t("dialog.server.add.placeholder")}
busy={formBusy()}
error={isAddMode() ? store.addServer.error : store.editServer.error}
status={isAddMode() ? store.addServer.status : store.editServer.status}
onChange={isAddMode() ? handleAddChange : handleEditChange}
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
onSubmit={submitForm}
onBack={resetForm}
/>
}
<div class="flex flex-1 min-h-0 flex-col gap-4">
<List
class="flex-1 min-h-0 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
search={{
placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false,
}}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={props.controller.sortedItems}
key={(x) => x.http.url}
onSelect={(x) => {
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x)
}}
divider={true}
>
{(i) => {
const key = ServerConnection.key(i)
return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-center w-5">
<ServerHealthIndicator health={props.controller.status()[key]} />
</div>
<ServerRow
conn={i}
dimmed={props.controller.status()[key]?.healthy === false}
status={props.controller.status()[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}>
<Icon name="check" class="h-6" />
</Show>
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
props.controller.startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</div>
</div>
)
}}
</List>
<div class="shrink-0 pb-5">
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={props.controller.startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
<List
search={{
placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false,
}}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => x.http.url}
onSelect={(x) => {
if (x) void select(x)
}}
divider={true}
class="flex-1 min-h-0 px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
>
{(i) => {
const key = ServerConnection.key(i)
return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-start w-5">
<ServerHealthIndicator health={store.status[key]} />
</div>
<ServerRow
conn={i}
dimmed={store.status[key]?.healthy === false}
status={store.status[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<Show when={ServerConnection.key(current()) === key}>
<Icon name="check" class="h-6" />
</Show>
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultKey() === key}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</div>
</div>
)
}}
</List>
</Show>
<div class="shrink-0 px-5 pb-5">
<Show
when={isFormMode()}
fallback={
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
}
>
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
{formBusy()
? language.t("dialog.server.add.checking")
: isAddMode()
? language.t("dialog.server.add.button")
: language.t("common.save")}
</Button>
</Show>
</div>
{language.t("dialog.server.add.button")}
</Button>
</div>
</Dialog>
</div>
)
}
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) {
const language = useLanguage()
return (
<div class="flex flex-1 min-h-0 flex-col gap-4">
<ServerForm
value={props.controller.formValue()}
name={props.controller.formName()}
username={props.controller.formUsername()}
password={props.controller.formPassword()}
placeholder={language.t("dialog.server.add.placeholder")}
busy={props.controller.formBusy()}
error={props.controller.formError()}
status={props.controller.formStatus()}
onChange={props.controller.handleFormChange()}
onNameChange={props.controller.handleFormNameChange()}
onUsernameChange={props.controller.handleFormUsernameChange()}
onPasswordChange={props.controller.handleFormPasswordChange()}
onSubmit={props.controller.submitForm}
onBack={props.controller.resetForm}
/>
<div class="shrink-0 pb-5">
<Button
variant="primary"
size="large"
onClick={props.controller.submitForm}
disabled={props.controller.formBusy()}
class="px-3 py-1.5"
>
{props.controller.formBusy()
? language.t("dialog.server.add.checking")
: props.controller.isAddMode()
? language.t("dialog.server.add.button")
: language.t("common.save")}
</Button>
</div>
</div>
)
}
@@ -8,6 +8,7 @@ import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"
import { SettingsServers } from "./settings-servers"
export const DialogSettings: Component = () => {
const language = useLanguage()
@@ -17,7 +18,7 @@ export const DialogSettings: Component = () => {
<Dialog size="x-large" transition>
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
<Tabs.List>
<div class="flex flex-col justify-between h-full w-full">
<div class="flex flex-col justify-between h-full w-full gap-4">
<div class="flex flex-col gap-3 w-full pt-3">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
@@ -31,6 +32,10 @@ export const DialogSettings: Component = () => {
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</Tabs.Trigger>
<Tabs.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
</div>
</div>
@@ -61,6 +66,9 @@ export const DialogSettings: Component = () => {
<Tabs.Content value="shortcuts" class="no-scrollbar">
<SettingsKeybinds />
</Tabs.Content>
<Tabs.Content value="servers" class="no-scrollbar">
<SettingsServers />
</Tabs.Content>
<Tabs.Content value="providers" class="no-scrollbar">
<SettingsProviders />
</Tabs.Content>
+41 -2
View File
@@ -277,6 +277,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
variantOpen: boolean
}>({
popover: null,
historyIndex: -1,
@@ -285,6 +286,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: null,
mode: "normal",
applyingHistory: false,
variantOpen: false,
})
const [picker, setPicker] = createStore({
projectOpen: false,
@@ -1101,6 +1103,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)
const variants = createMemo(() => ["default", ...local.model.variant.list()])
// Check provider variants directly: `variants` also includes the UI-only default option.
const showVariantControl = createMemo(() => local.model.variant.list().length > 0)
const accepting = createMemo(() => {
const id = params.id
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
@@ -1363,6 +1367,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
navigate(`/${base64Encode(worktree)}/session`)
}
const addProject = async () => {
const conn = server.current
if (!conn) return
const select = (result: string | string[] | null) => {
const directory = Array.isArray(result) ? result[0] : result
if (!directory) return
@@ -1374,7 +1380,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
void import("@/components/dialog-select-directory").then((x) => {
dialog.show(
() => <x.DialogSelectDirectory onSelect={select} />,
() => <x.DialogSelectDirectory onSelect={select} server={conn} />,
() => select(null),
)
})
@@ -1569,6 +1575,39 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<ComposerPickerTrigger state={newProjectTriggerState()} />
</Show>
<ComposerModelControl state={modelControlState()} />
<Show when={store.mode !== "shell" && showVariantControl()}>
<div
data-component="prompt-variant-control"
classList={{
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
!local.model.variant.current() && !store.variantOpen,
}}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onOpenChange={(open) => setStore("variantOpen", open)}
onSelect={(value) => {
local.model.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
valueClass="truncate text-[13px] font-[440] leading-5 text-v2-text-text-faint"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
</Show>
</div>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton
@@ -1888,7 +1927,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</TooltipKeybind>
</Show>
</div>
<Show when={variants().length > 2}>
<Show when={showVariantControl()}>
<div
data-component="prompt-variant-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
@@ -127,6 +127,7 @@ beforeAll(async () => {
mock.module("@/context/sdk", () => ({
useSDK: () => {
const sdk = {
scope: "local",
directory: "/repo/main",
client: rootClient,
url: "http://localhost:4096",
@@ -18,6 +18,7 @@ import { Worktree as WorktreeState } from "@/utils/worktree"
import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope"
type PendingPrompt = {
abort: AbortController
@@ -212,6 +213,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const layout = useLayout()
const language = useLanguage()
const params = useParams()
const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID)
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
@@ -232,11 +234,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.onAbort?.()
const queued = pending.get(sessionID)
const key = pendingKey(sessionID)
const queued = pending.get(key)
if (queued) {
queued.abort.abort()
queued.cleanup()
pending.delete(sessionID)
pending.delete(key)
return Promise.resolve()
}
return sdk.client.session
@@ -341,7 +344,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
})
return
}
WorktreeState.pending(createdWorktree.directory)
WorktreeState.pending(sdk.scope, createdWorktree.directory)
sessionDirectory = createdWorktree.directory
}
@@ -500,7 +503,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
clearInput()
const waitForWorktree = async () => {
const worktree = WorktreeState.get(sessionDirectory)
const worktree = WorktreeState.get(sdk.scope, sessionDirectory)
if (!worktree || worktree.status !== "pending") return true
if (sessionDirectory === projectDirectory) {
@@ -517,7 +520,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
restoreInput()
}
pending.set(session.id, { abort: controller, cleanup })
pending.set(pendingKey(session.id), { abort: controller, cleanup })
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
if (controller.signal.aborted) {
@@ -544,11 +547,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}, timeoutMs)
})
const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => {
if (timer.id === undefined) return
clearTimeout(timer.id)
})
pending.delete(session.id)
const result = await Promise.race([WorktreeState.wait(sdk.scope, sessionDirectory), abortWait, timeout]).finally(
() => {
if (timer.id === undefined) return
clearTimeout(timer.id)
},
)
pending.delete(pendingKey(session.id))
if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message)
return true
@@ -563,7 +568,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
optimisticBusy: sessionDirectory === projectDirectory,
before: waitForWorktree,
}).catch((err) => {
pending.delete(session.id)
pending.delete(pendingKey(session.id))
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })
}
@@ -4,7 +4,7 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
export function NewSessionDesignView(props: { children: JSX.Element }) {
return (
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep">
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
@@ -178,11 +178,11 @@ export const SettingsGeneral: Component = () => {
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync()
const globalSdk = useServerSDK()
const serverSdk = useServerSDK()
const [shells] = createResource(
() =>
globalSdk.client.pty
serverSdk.client.pty
.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
@@ -9,6 +9,7 @@ import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers"
import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
@@ -32,6 +33,14 @@ const ListEmptyState: Component<{ message: string; filter: string }> = (props) =
}
export const SettingsModels: Component = () => {
return (
<SettingsServerScope>
<SettingsModelsContent />
</SettingsServerScope>
)
}
const SettingsModelsContent: Component = () => {
const language = useLanguage()
const models = useModels()
@@ -61,7 +70,10 @@ export const SettingsModels: Component = () => {
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
<SettingsServerPicker />
</div>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
@@ -12,6 +12,7 @@ import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
@@ -28,6 +29,14 @@ const PROVIDER_NOTES = [
] as const
export const SettingsProviders: Component = () => {
return (
<SettingsServerScope>
<SettingsProvidersContent />
</SettingsServerScope>
)
}
const SettingsProvidersContent: Component = () => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
@@ -129,8 +138,9 @@ export const SettingsProviders: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
<div class="flex items-center justify-between gap-4 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
<SettingsServerPicker />
</div>
</div>
@@ -0,0 +1,106 @@
import { Button } from "@opencode-ai/ui/button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { QueryClientProvider } from "@tanstack/solid-query"
import { createMemo, For, type ParentProps, Show } from "solid-js"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { ModelsProvider } from "@/context/models"
import { ServerConnection } from "@/context/server"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings"
export function SettingsServerScope(props: ParentProps) {
const global = useGlobal()
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={props.children}>
<Show when={global.settings.server.selected()}>
{(server) => <SettingsServerDataProviders server={server()}>{props.children}</SettingsServerDataProviders>}
</Show>
</Show>
)
}
function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) {
const global = useGlobal()
const serverCtx = () => global.createServerCtx(props.server)
return (
<QueryClientProvider client={serverCtx().queryClient}>
<ServerSDKProvider server={props.server}>
<ServerSyncProvider>
<ModelsProvider>{props.children}</ModelsProvider>
</ServerSyncProvider>
</ServerSDKProvider>
</QueryClientProvider>
)
}
export function SettingsServerPicker() {
const global = useGlobal()
const settings = useSettings()
const selected = createMemo(() =>
settings.general.newLayoutDesigns() ? global.settings.server.selected() : undefined,
)
return (
<Show when={selected()}>
{(conn) => (
<DropdownMenu gutter={4} placement="bottom-end">
<DropdownMenu.Trigger
as={Button}
variant="secondary"
size="large"
class="h-8 max-w-[260px] gap-2 px-2 py-1.5 data-[expanded]:bg-surface-base-active"
>
<ServerHealthIndicator health={global.servers.health[ServerConnection.key(conn())]} />
<ServerRow
conn={conn()}
status={global.servers.health[ServerConnection.key(conn())]}
class="flex items-center gap-2 min-w-0 flex-1"
nameClass="text-14-regular text-text-base truncate"
versionClass="hidden"
/>
<Icon name="chevron-down" size="small" class="text-icon-weak shrink-0" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="w-[320px] mt-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-2 [&_[data-slot=dropdown-menu-radio-item]]:pr-2">
<DropdownMenu.RadioGroup
value={global.settings.server.key}
onChange={(key) => {
if (typeof key === "string") global.settings.server.set(ServerConnection.Key.make(key))
}}
>
<For each={global.servers.list()}>
{(item) => {
const key = ServerConnection.key(item)
const blocked = () => global.servers.health[key]?.healthy === false
return (
<DropdownMenu.RadioItem value={key} disabled={blocked()}>
<ServerHealthIndicator health={global.servers.health[key]} />
<ServerRow
conn={item}
dimmed={blocked()}
status={global.servers.health[key]}
class="flex items-center gap-2 min-w-0 flex-1"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
/>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)
}}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
)}
</Show>
)
}
@@ -0,0 +1,33 @@
import { Show, type Component } from "solid-js"
import { useLanguage } from "@/context/language"
import { ServerConnectionForm, ServerConnectionList, useServerManagementController } from "./dialog-select-server"
export const SettingsServers: Component = () => {
const language = useLanguage()
const controller = useServerManagementController()
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="flex flex-col flex-1 min-h-0 max-w-[720px]">
<Show
when={controller.isFormMode()}
fallback={
<>
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8">
<h2 class="text-16-medium text-text-strong">{language.t("status.popover.tab.servers")}</h2>
</div>
</div>
<ServerConnectionList controller={controller} />
</>
}
>
<div class="flex flex-1 min-h-0 flex-col gap-4 pt-6">
<div class="text-16-medium text-text-strong">{controller.formTitle()}</div>
<ServerConnectionForm controller={controller} />
</div>
</Show>
</div>
</div>
)
}
@@ -9,20 +9,15 @@ import { SettingsKeybinds } from "../settings-keybinds"
import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models"
import "./settings-v2.css"
import { SettingsServers } from "../settings-servers"
export const DialogSettings: Component = () => {
const language = useLanguage()
const platform = usePlatform()
return (
<Dialog size="x-large" class="settings-v2-dialog" data-component="settings-v2-dialog">
<TabsV2
orientation="vertical"
variant="settings"
defaultValue="general"
class="settings-v2"
data-component="settings-v2"
>
<Dialog size="x-large" variant="settings" class="settings-v2-dialog">
<TabsV2 orientation="vertical" variant="settings" defaultValue="general" class="settings-v2">
<TabsV2.List>
<div class="flex flex-col justify-between h-full w-full">
<div class="flex flex-col gap-3 w-full">
@@ -38,6 +33,10 @@ 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>
@@ -68,6 +67,9 @@ export const DialogSettings: Component = () => {
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds v2 />
</TabsV2.Content>
<TabsV2.Content value="servers" class="settings-v2-panel">
<SettingsServers />
</TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 />
</TabsV2.Content>
@@ -179,12 +179,12 @@ export const SettingsGeneralV2: Component = () => {
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const globalSync = useServerSync()
const globalSdk = useServerSDK()
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const [shells] = createResource(
() =>
globalSdk.client.pty
serverSdk.client.pty
.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
@@ -208,11 +208,11 @@ export const SettingsGeneralV2: Component = () => {
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => globalSync.data.config.shell ?? "")
const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = globalSync.data.config.shell
const current = serverSync.data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
@@ -347,7 +347,7 @@ export const SettingsGeneralV2: Component = () => {
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
globalSync.updateConfig({ shell: option.value })
serverSync.updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>
@@ -33,8 +33,8 @@ const PROVIDER_ICON_SIZE = 16
export const SettingsProvidersV2: Component = () => {
const dialog = useDialog()
const language = useLanguage()
const globalSDK = useServerSDK()
const globalSync = useServerSync()
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const providers = useProviders()
const connected = createMemo(() => {
@@ -77,7 +77,7 @@ export const SettingsProvidersV2: Component = () => {
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
const provider = globalSync.data.config.provider?.[providerID]
const provider = serverSync.data.config.provider?.[providerID]
if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false
@@ -85,11 +85,11 @@ export const SettingsProvidersV2: Component = () => {
}
const disableProvider = async (providerID: string, name: string) => {
const before = globalSync.data.config.disabled_providers ?? []
const before = serverSync.data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
globalSync.set("config", "disabled_providers", next)
serverSync.set("config", "disabled_providers", next)
await globalSync
await serverSync
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
@@ -100,7 +100,7 @@ export const SettingsProvidersV2: Component = () => {
})
})
.catch((err: unknown) => {
globalSync.set("config", "disabled_providers", before)
serverSync.set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
@@ -108,14 +108,14 @@ export const SettingsProvidersV2: Component = () => {
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await globalSDK.client.auth.remove({ providerID }).catch(() => undefined)
await serverSdk.client.auth.remove({ providerID }).catch(() => undefined)
await disableProvider(providerID, name)
return
}
await globalSDK.client.auth
await serverSdk.client.auth
.remove({ providerID })
.then(async () => {
await globalSDK.client.global.dispose()
await serverSdk.client.global.dispose()
showToast({
variant: "success",
icon: "circle-check",
@@ -1,11 +1,15 @@
@import "@opencode-ai/ui/v2/text-input-v2.css";
@import "@opencode-ai/ui/v2/button-v2.css";
[data-component="settings-v2"] {
[data-component="tabs-v2"][data-variant="settings"] {
height: 100%;
}
[data-component="settings-v2-dialog"] [data-slot="dialog-body"] {
[data-component="dialog-v2"][data-variant="settings"] [data-slot="dialog-container"] {
background: var(--v2-background-bg-base);
}
[data-component="dialog-v2"][data-variant="settings"] [data-slot="dialog-body"] {
padding: 0;
overflow: hidden;
}
@@ -81,10 +85,6 @@
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
}
[data-slot="dialog-container"]:has(.settings-v2-dialog) {
box-shadow: var(--v2-elevation-overlay);
}
[data-component="settings-v2-row"] {
display: flex;
flex-wrap: wrap;
@@ -153,12 +153,12 @@
width: 100%;
}
[data-component="settings-v2-dialog"] [data-component="select-v2-root"] {
[data-component="dialog-v2"][data-variant="settings"] [data-component="select-v2-root"] {
width: fit-content;
max-width: 100%;
}
[data-component="settings-v2-dialog"] [data-component="button-v2"] {
[data-component="dialog-v2"][data-variant="settings"] [data-component="button-v2"] {
width: fit-content;
max-width: 100%;
}
@@ -17,7 +17,8 @@ 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 { useServers } from "@/context/servers"
import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings"
const pollMs = 10_000
@@ -153,7 +154,7 @@ type ServerStatusItem = {
}
export function StatusPopoverServerBody() {
const servers = useServers()
const global = useGlobal()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
@@ -167,7 +168,7 @@ export function StatusPopoverServerBody() {
dialogRun += 1
})
const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health))
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const serverItems = createMemo(() =>
sortedServers().map((conn) => {
@@ -175,8 +176,8 @@ export function StatusPopoverServerBody() {
return {
key,
conn,
health: servers.health[key],
blocked: servers.health[key]?.healthy === false,
health: global.servers.health[key],
blocked: global.servers.health[key]?.healthy === false,
active: !!server.current && key === ServerConnection.key(server.current),
onSelect: () => {
navigate("/")
@@ -288,12 +289,13 @@ function ServerStatusList(props: { state: ServerStatusState }) {
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync()
const servers = useServers()
const global = useGlobal()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
const settings = useSettings()
const fail = (err: unknown) => {
showToast({
@@ -313,7 +315,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
dialogDead = true
dialogRun += 1
})
const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health))
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
@@ -333,15 +335,17 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
aria-label={language.t("status.popover.ariaLabel")}
class="tabs bg-background-strong rounded-xl overflow-hidden"
data-component="tabs"
data-active="servers"
defaultValue="servers"
data-active={settings.general.newLayoutDesigns() ? "mcp" : "servers"}
defaultValue={settings.general.newLayoutDesigns() ? "mcp" : "servers"}
variant="alt"
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{servers.list().length > 0 ? `${servers.list().length} ` : ""}
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
{!settings.general.newLayoutDesigns() && (
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{global.servers.list().length > 0 ? `${global.servers.list().length} ` : ""}
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
)}
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
{language.t("status.popover.tab.mcp")}
@@ -356,70 +360,72 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="servers">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}>
{(s) => {
const key = ServerConnection.key(s)
const blocked = () => servers.health[key]?.healthy === false
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
classList={{
"hover:bg-surface-raised-base-hover": !blocked(),
"cursor-not-allowed": blocked(),
}}
aria-disabled={blocked()}
onClick={() => {
if (blocked()) return
navigate("/")
queueMicrotask(() => server.setActive(key))
}}
>
<ServerHealthIndicator health={servers.health[key]} />
<ServerRow
conn={s}
dimmed={blocked()}
status={servers.health[key]}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={key === defaultServer.key()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")}
</span>
</Show>
}
{!settings.general.newLayoutDesigns() && (
<Tabs.Content value="servers">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}>
{(s) => {
const key = ServerConnection.key(s)
const blocked = () => global.servers.health[key]?.healthy === false
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
classList={{
"hover:bg-surface-raised-base-hover": !blocked(),
"cursor-not-allowed": blocked(),
}}
aria-disabled={blocked()}
onClick={() => {
if (blocked()) return
navigate("/")
queueMicrotask(() => server.setActive(key))
}}
>
<div class="flex-1" />
<Show when={server.current && key === ServerConnection.key(server.current)}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</ServerRow>
</button>
)
}}
</For>
<ServerHealthIndicator health={global.servers.health[key]} />
<ServerRow
conn={s}
dimmed={blocked()}
status={global.servers.health[key]}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={key === defaultServer.key()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")}
</span>
</Show>
}
>
<div class="flex-1" />
<Show when={server.current && key === ServerConnection.key(server.current)}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</ServerRow>
</button>
)
}}
</For>
<Button
variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5"
onClick={() => {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
})
}}
>
{language.t("status.popover.action.manageServers")}
</Button>
<Button
variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5"
onClick={() => {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
})
}}
>
{language.t("status.popover.action.manageServers")}
</Button>
</div>
</div>
</div>
</Tabs.Content>
</Tabs.Content>
)}
<Tabs.Content value="mcp">
<div class="flex flex-col px-2 pb-2">
@@ -7,7 +7,7 @@ import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-
import { useLanguage } from "@/context/language"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useServers } from "@/context/servers"
import { useGlobal } from "@/context/global"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
@@ -15,10 +15,10 @@ const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ def
export function StatusPopover() {
const language = useLanguage()
const server = useServer()
const servers = useServers()
const global = useGlobal()
const sync = useSync()
const [shown, setShown] = createSignal(false)
const ready = createMemo(() => servers.health[server.key]?.healthy === false || sync.data.mcp_ready)
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync.data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync.data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
@@ -26,8 +26,8 @@ export function StatusPopover() {
if (failed) return "critical" as const
if (warn) return "warning" as const
})
const serverHealthy = () => servers.health[server.key]?.healthy === true
const healthy = createMemo(() => servers.health[server.key]?.healthy === true && !mcpIssue())
const serverHealthy = () => global.servers.health[server.key]?.healthy === true
const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue())
return (
<Popover
@@ -82,10 +82,10 @@ export function StatusPopoverV2(props: { scope?: "server" }) {
function DirectoryStatusPopover() {
const language = useLanguage()
const server = useServer()
const servers = useServers()
const global = useGlobal()
const sync = useSync()
const [shown, setShown] = createSignal(false)
const serverHealth = () => servers.health[server.key]?.healthy
const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || sync.data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync.data.mcp ?? {})
@@ -116,9 +116,9 @@ function DirectoryStatusPopover() {
function ServerStatusPopover() {
const language = useLanguage()
const server = useServer()
const servers = useServers()
const global = useGlobal()
const [shown, setShown] = createSignal(false)
const serverHealth = () => servers.health[server.key]?.healthy
const serverHealth = () => global.servers.health[server.key]?.healthy
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: serverHealth() !== undefined,
+270 -217
View File
@@ -1,5 +1,17 @@
import { createEffect, createMemo, For, mapArray, Match, Show, startTransition, Switch, untrack } from "solid-js"
import { createStore, produce } from "solid-js/store"
import {
createEffect,
createMemo,
createResource,
createSignal,
For,
Match,
onMount,
Show,
startTransition,
Switch,
untrack,
} from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
@@ -9,7 +21,7 @@ import { useTheme } from "@opencode-ai/ui/theme/context"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout"
import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
@@ -17,18 +29,16 @@ import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useServerSync } from "@/context/server-sync"
import { decodeDirectory } from "@/pages/directory-layout"
import { iife } from "@opencode-ai/core/util/iife"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { makeEventListener } from "@solid-primitives/event-listener"
import {
readSessionTabsRemovedDetail,
SESSION_TABS_REMOVED_EVENT,
type SessionTabsRemovedDetail,
} from "@/components/titlebar-session-events"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
import { useGlobal } from "@/context/global"
import { decode64 } from "@/utils/base64"
import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs, type Tab } from "@/context/tabs"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
@@ -56,8 +66,6 @@ const v2TitlebarHeight = 36
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
export type TitlebarUpdate = {
version: () => string | undefined
installing: () => boolean
@@ -71,6 +79,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const language = useLanguage()
const settings = useSettings()
const theme = useTheme()
const server = useServer()
const navigate = useNavigate()
const location = useLocation()
const params = useParams()
@@ -121,10 +130,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
const updateState = createMemo<TitlebarUpdatePillState>(() => {
const installing = props.update?.installing() ?? false
const version = props.update?.version()
return {
visible: version !== undefined,
installing: props.update?.installing() ?? false,
visible: version !== undefined || installing,
installing,
label: "Update",
ariaLabel: language.t("toast.update.action.installRestart"),
title: version ? `Update ${version}` : undefined,
@@ -219,9 +229,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return (
<header
classList={{
"shrink-0 relative overflow-hidden flex flex-row": true,
"h-9 bg-v2-background-bg-deep": useV2Titlebar(),
"h-10 bg-background-base": !useV2Titlebar(),
"shrink-0 relative flex flex-row": true,
"h-9 bg-v2-background-bg-deep overflow-visible": useV2Titlebar(),
"h-10 bg-background-base overflow-hidden": !useV2Titlebar(),
}}
style={{
"min-height": minHeight(),
@@ -242,6 +252,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const serverSync = useServerSync()
const navigate = useNavigate()
const homeMatch = useMatch(() => "/")
const layout = useLayout()
const newSessionHref = () => {
if (params.dir) return `/${params.dir}/session`
@@ -252,77 +263,63 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return `/${base64Encode(project.worktree)}/session`
}
type Tab = { dir: string; sessionId: string; href: string }
const [tabsStore, tabsStoreActions] = iife(() => {
const [store, setStore] = createStore<Tab[]>(
iife(() => {
if (!params.dir || !params.id) return []
return [
{
dir: decodeDirectory(params.dir) ?? "",
sessionId: params.id,
href: makeSessionHref(params.dir, params.id),
},
]
}),
)
const actions = {
addTab: (tab: Tab) => {
setStore(
produce((tabs) => {
if (tabs.some((t) => t.href === tab.href)) return
tabs.push(tab)
}),
)
},
removeTab: (href: string) => {
void startTransition(() => {
setStore(
produce((tabs) => {
const index = tabs.findIndex((t) => t.href === href)
if (index === -1) return
tabs.splice(index, 1)
const nextTab = tabs[index] ?? tabs[tabs.length - 1]
if (nextTab) navigate(nextTab.href)
else navigate("/")
}),
)
})
},
removeSessions: (input: SessionTabsRemovedDetail) => {
void startTransition(() => {
setStore(
produce((tabs) => {
const sessionIDs = new Set(input.sessionIDs)
const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined
const currentIndex = currentHref ? tabs.findIndex((tab) => tab.href === currentHref) : -1
const removedCurrent =
currentIndex !== -1 &&
tabs[currentIndex]?.dir === input.directory &&
sessionIDs.has(tabs[currentIndex]?.sessionId ?? "")
for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i]
if (!tab) continue
if (tab.dir !== input.directory) continue
if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1)
}
if (!removedCurrent) return
const nextTab = tabs[currentIndex] ?? tabs[tabs.length - 1]
if (nextTab) navigate(nextTab.href)
else navigate("/")
}),
)
})
},
const tabs = useTabs()
const tabsStore = tabs.store
const tabsStoreActions = tabs
const navigateTab = (tab: Tab) => {
const href = tabHref(tab)
if (tab.server === server.key) {
navigate(href)
return
}
void startTransition(() => {
server.setActive(tab.server)
navigate(href)
})
}
return [store, actions]
const matchRoute = (route: LayoutRoute) => {
if (route.type === "home") return
if (route.type === "dir-new-sesssion") {
}
if (route.type === "session") {
const main = tabsStore.find(
(item) =>
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
)
if (main) return main
const sync = serverSync.createDirSyncContext(route.dir)
const session = sync.session.get(route.sessionId)
if (session?.parentID) {
const parentID = session.parentID
const parent = tabsStore.find(
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
)
if (parent) return parent
}
}
}
const currentTab = () => matchRoute(layout.route())
createEffect(() => {
const route = layout.route()
if (!tabs.ready()) return
const tab = currentTab()
if (tab) return
if (route.type === "session") {
const sync = serverSync.createDirSyncContext(route.dir)
const session = sync.session.get(route.sessionId)
if (!session) return
const sessionId = session.parentID ?? session.id
const next = {
server: route.server ?? server.key,
dirBase64: route.dirBase64,
sessionId,
}
tabsStoreActions.addSessionTab(next)
}
})
makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => {
@@ -331,49 +328,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
tabsStoreActions.removeSessions(detail)
})
createEffect(() => {
const params = useParams()
if (!(params.dir && params.id)) return
tabsStoreActions.addTab({
dir: decodeDirectory(params.dir) ?? "",
sessionId: params.id,
href: makeSessionHref(params.dir, params.id),
})
})
const projects = createMemo(() => layout.projects.list())
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const currentSessionTab = () => {
if (!params.dir || !params.id) return
const href = makeSessionHref(params.dir, params.id)
return tabsStore.find((tab) => tab.href === href)
}
const closeCurrentSessionTab = () => {
const tab = currentSessionTab()
if (!tab) return false
tabsStoreActions.removeTab(tab.href)
return true
}
const closeNewSessionTab = () => {
if (!(params.dir && !params.id)) return false
const last = tabsStore[tabsStore.length - 1]
if (last) navigate(last.href)
else navigate("/")
return true
}
const openNewTab = () => navigate(newSessionHref())
const closeActiveTab = () => closeCurrentSessionTab() || closeNewSessionTab()
command.register("tabs", () => {
const current = currentTab()
command.register(() => {
const commands = [
return [
{
id: "tab.new",
category: "tab",
@@ -382,13 +342,15 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
hidden: true,
onSelect: openNewTab,
},
{
current && {
id: "tab.close",
category: "tab",
title: language.t("command.tab.close"),
keybind: "mod+w",
hidden: true,
onSelect: closeActiveTab,
onSelect: () => {
tabsStoreActions.removeTab(tabsStore.findIndex((tab) => current === tab))
},
},
{
id: `tab.prev`,
@@ -397,14 +359,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
keybind: `mod+option+ArrowLeft`,
hidden: true,
onSelect: () => {
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href)
let index = tabsStore.findIndex((tab) => tab === currentTab())
if (index === -1) return
index -= 1
if (index === -1) index = tabsStore.length - 1
const next = tabsStore[index]
if (next) navigate(next.href)
if (next) navigateTab(next)
},
},
{
@@ -414,14 +376,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
keybind: `mod+option+ArrowRight`,
hidden: true,
onSelect: () => {
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href)
let index = tabsStore.findIndex((tab) => tab === currentTab())
if (index === -1) return
index += 1
if (index === tabsStore.length) index = 0
const next = tabsStore[index]
if (next) navigate(next.href)
if (next) navigateTab(next)
},
},
...Array.from({ length: 9 }, (_, i) => {
@@ -436,31 +398,23 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
hidden: true,
onSelect: () => {
const tab = tabsStore[index]
if (tab) navigate(tab.href)
if (tab) navigateTab(tab)
},
}
}),
]
return commands
].filter((v) => v !== undefined)
})
const tabsEnriched = iife(() => {
const base = mapArray(
() => tabsStore,
(tab) => {
const sync = serverSync.createDirSyncContext(tab.dir)
const session = sync.session.get(tab.sessionId)
return session ? { ...tab, info: session } : null
},
)
const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false)
let tabScrollRef!: HTMLDivElement
return () => base().flatMap((s) => (s ? [s] : []))
})
function refreshTabsAreOverflowing() {
setTabsAreOverflowing(tabScrollRef.scrollWidth > tabScrollRef.clientWidth)
}
return (
<div
class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3 pt-2"
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 pr-3 pt-2"
classList={{
"pl-2": mac(),
"pl-4": !mac(),
@@ -475,54 +429,89 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
size="large"
as="a"
href="/"
class="!w-9"
class="!w-9 shrink-0"
icon={<IconV2 name="grid-plus" />}
state={!!homeMatch() ? "pressed" : undefined}
/>
<div class="flex min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden">
<div class="flex min-w-0 flex-row items-center gap-1.5 overflow-hidden">
<For each={tabsEnriched()}>
{(tab, i) => (
<>
{i() !== 0 && (
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
)}
<TabNavItem
href={tab.href}
title={tab.info.title}
project={projectForSession(tab.info, projects(), projectByID())}
directory={tab.dir}
sessionId={tab.info.id}
onClose={() => tabsStoreActions.removeTab(tab.href)}
/>
</>
)}
<div
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
ref={tabScrollRef}
>
<div class="flex min-w-0 flex-row items-center gap-1.5">
<For each={tabsStore}>
{(tab, i) => {
let ref!: HTMLDivElement
onMount(() => {
refreshTabsAreOverflowing()
})
return (
<>
{i() !== 0 && (
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
)}
<TabNavItem
ref={ref}
href={tabHref(tab)}
server={tab.server}
directory={decode64(tab.dirBase64)!}
sessionId={tab.sessionId}
onNavigate={() => {
navigateTab(tab)
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
/>
</>
)
}}
</For>
<Show when={creating() && params.dir}>
{(_) => {
let ref!: HTMLDivElement
onMount(() => {
ref.scrollIntoView({ behavior: "instant" })
})
return (
<>
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
<NewSessionTabItem
ref={ref}
href={`/${params.dir}/session`}
title={language.t("command.session.new")}
onClose={() => {
const tab = tabsStore.at(-1)
if (tab) navigateTab(tab)
else navigate("/")
}}
/>
</>
)
}}
</Show>
</div>
<Show
when={creating() && params.dir}
fallback={
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
as="a"
href={newSessionHref()}
aria-label={language.t("command.session.new")}
/>
}
>
<NewSessionTabItem
href={`/${params.dir}/session`}
title={language.t("command.session.new")}
onClose={() => navigate(tabsEnriched().at(-1)?.href ?? "/")}
/>
</Show>
<div class="min-w-0 flex-1" />
</div>
<Show when={!(creating() && params.dir)}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
as="a"
href={newSessionHref()}
aria-label={language.t("command.session.new")}
/>
</Show>
<div class="flex-1" />
<TitlebarV2Right state={v2RightState()} />
<Show when={windows() && !electronWindows()}>
<div data-tauri-decorum-tb class="flex flex-row" />
@@ -705,66 +694,124 @@ type TitlebarV2RightState = {
function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
return (
<div class="flex shrink-0 items-center justify-end gap-0">
<TitlebarUpdatePill state={props.state.update} />
<div class="relative z-20 flex shrink-0 items-center justify-end gap-0 overflow-visible">
<Show when={props.state.update.visible}>
<TitlebarUpdateIconButton state={props.state.update} />
</Show>
<div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
</div>
)
}
function TitlebarUpdatePill(props: { state: TitlebarUpdatePillState }) {
function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
return (
<Show when={props.state.visible}>
<div class="relative isolate mr-3 size-5 shrink-0">
<button
type="button"
class="h-5 shrink-0 rounded-[27px] bg-[var(--v2-background-bg-layer-03)] px-2.5 text-[11px] font-[530] leading-4 tracking-[0.05px] text-[var(--v2-text-text-base)] disabled:opacity-60"
class="group absolute right-0 top-0 z-10 flex h-5 w-5 items-center justify-end overflow-hidden rounded-full bg-v2-icon-icon-accent/20 text-v2-icon-icon-accent transition-[width,background-color] duration-150 ease-out hover:z-30 hover:w-[68px] hover:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:z-30 focus-visible:w-[68px] focus-visible:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:outline-none disabled:opacity-60 motion-reduce:transition-none"
onClick={props.state.onInstall}
disabled={props.state.installing}
aria-busy={props.state.installing}
aria-label={props.state.ariaLabel}
title={props.state.title}
>
{props.state.label}
<span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-visible:opacity-100 group-focus-visible:translate-x-0 motion-reduce:translate-x-0">
Update
</span>
<span class="flex size-5 shrink-0 items-center justify-center">
<Show
when={!props.state.installing}
fallback={<span data-slot="titlebar-update-loader" aria-hidden="true" />}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M7 11V3M3.5 7.63128L7 11L10.5 7.63128" stroke="currentColor" />
</svg>
</Show>
</span>
</button>
</Show>
</div>
)
}
function TabNavItem(props: {
ref?: HTMLDivElement
href: string
title: string
project?: LocalProject
server: ServerConnection.Key
directory: string
sessionId: string
sessionId?: string
hideClose?: boolean
onClose: () => void
onNavigate: () => void
active?: boolean
activeServer: boolean
forceTruncate?: boolean
}) {
const match = useMatch(() => props.href)
const isActive = () => !!match()
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
const global = useGlobal()
const serverCtx = createMemo(() => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.createServerCtx(conn)
})
const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory))
const [session] = createResource(
() => {
const ctx = dirSyncCtx()
if (!ctx || !props.sessionId) return
return [props.sessionId, ctx] as const
},
async ([sessionId, dirSyncCtx]) => {
await dirSyncCtx.session.sync(sessionId).catch(() => {})
return dirSyncCtx.session.get(sessionId)
},
{ initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined },
)
return (
<div
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={isActive()}
data-active={props.active}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<a
href={props.href}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
>
<span data-slot="project-avatar-slot">
<ProjectTabAvatar project={props.project} directory={props.directory} sessionId={props.sessionId} />
</span>
<span class="min-w-0 flex-1">{props.title}</span>
</a>
<Show when={session.latest}>
{(session) => {
console.log({ session: session() })
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
<div class="absolute not-group-hover:not-group-data-[active=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
return (
<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 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
>
<span data-slot="project-avatar-slot">
<ProjectTabAvatar
project={project()}
directory={props.directory}
sessionId={session().id}
activeServer={props.activeServer}
/>
</span>
<span class="min-w-0 flex-1">{session().title}</span>
</a>
)
}}
</Show>
<div
class="absolute not-group-hover:not-group-data-[active=true]:not-data-[truncate=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 data-[truncate=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2"
data-truncate={props.forceTruncate}
>
<div
class="absolute inset-0 rounded-r-[6px] bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
style={{
@@ -784,10 +831,15 @@ function TabNavItem(props: {
)
}
function ProjectTabAvatar(props: { project?: LocalProject; directory: string; sessionId: string }) {
function ProjectTabAvatar(props: {
project?: LocalProject
directory: string
sessionId: string
activeServer: boolean
}) {
const directory = () => props.directory
const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId)
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
return (
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
@@ -799,7 +851,7 @@ function ProjectTabAvatar(props: { project?: LocalProject; directory: string; se
)
}
function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) {
function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: string; onClose: () => void }) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
@@ -807,7 +859,8 @@ function NewSessionTabItem(props: { href: string; title: string; onClose: () =>
}
return (
<div
class="group relative flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
ref={props.ref}
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap 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)
+10 -3
View File
@@ -3,6 +3,8 @@ import { createStore, reconcile, type SetStoreFunction, type Store } from "solid
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router"
import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
import { createScopedCache } from "@/utils/scoped-cache"
import { uuid } from "@/utils/uuid"
import type { SelectedLineRange } from "@/context/file"
@@ -166,11 +168,11 @@ export function createCommentSessionForTest(comments: Record<string, LineComment
return createCommentSessionState(store, setStore)
}
function createCommentSession(dir: string, id: string | undefined) {
function createCommentSession(scope: ServerScope, dir: string, id: string | undefined) {
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "comments", [legacy]),
Persist.serverScoped(scope, dir, id, "comments", [legacy]),
createStore<CommentStore>({
comments: {},
}),
@@ -200,11 +202,16 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
gate: false,
init: () => {
const params = useParams()
const serverSDK = useServerSDK()
const cache = createScopedCache(
(key) => {
const decoded = decodeSessionKey(key)
return createRoot((dispose) => ({
value: createCommentSession(decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id),
value: createCommentSession(
serverSDK.scope,
decoded.dir,
decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
),
dispose,
}))
},
+12 -8
View File
@@ -8,11 +8,11 @@ import {
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import { createServerSyncContext } from "./server-sync"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
import { useServerSDK } from "./server-sdk"
import { createServerSdkContext, useServerSDK } from "./server-sdk"
import { type createServerSyncContextInner } from "./server-sync"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -171,8 +171,11 @@ function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: Opti
})
}
export const createDirSyncContext = (directory: string, serverSync: ReturnType<typeof createServerSyncContext>) => {
const serverSDK = useServerSDK()
export const createDirSyncContext = (
directory: string,
serverSync: ReturnType<typeof createServerSyncContextInner>,
serverSDK: ReturnType<typeof createServerSdkContext> = useServerSDK(),
) => {
const client = serverSDK.createClient({ directory, throwOnError: true })
type Child = ReturnType<(typeof serverSync)["child"]>
@@ -273,7 +276,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
clearSessionPrefetch(directory, sessionIDs)
clearSessionPrefetch(serverSDK.scope, directory, sessionIDs)
for (const sessionID of sessionIDs) {
serverSync.todo.set(sessionID, undefined)
}
@@ -345,6 +348,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
setMeta("cursor", key, next.cursor)
setMeta("complete", key, next.complete)
setSessionPrefetch({
scope: serverSDK.scope,
directory: input.directory,
sessionID: input.sessionID,
limit: message.length,
@@ -435,7 +439,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
touch(directory, setStore, sessionID)
const seeded = getSessionPrefetch(directory, sessionID)
const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
@@ -446,10 +450,10 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
}
return runInflight(inflight, key, async () => {
const pending = getSessionPrefetchPromise(directory, sessionID)
const pending = getSessionPrefetchPromise(serverSDK.scope, directory, sessionID)
if (pending) {
await pending
const seeded = getSessionPrefetch(directory, sessionID)
const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
+7 -2
View File
@@ -21,6 +21,8 @@ import {
touchFileContent,
} from "./file/content-cache"
import { createFileViewCache } from "./file/view-cache"
import { useServerSDK } from "./server-sdk"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
import { createFileTreeStore } from "./file/tree-store"
import { invalidateFromWatcher } from "./file/watcher"
import {
@@ -56,12 +58,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const sdk = useSDK()
useSync()
const params = useParams()
const serverSDK = useServerSDK()
const language = useLanguage()
const layout = useLayout()
const scope = createMemo(() => sdk.directory)
const path = createPathHelpers(scope)
const tabs = layout.tabs(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = layout.tabs(() =>
SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(params.dir, params.id)),
)
const inflight = new Map<string, Promise<void>>()
const [store, setStore] = createStore<{
@@ -107,7 +112,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
})
})
const viewCache = createFileViewCache()
const viewCache = createFileViewCache(serverSDK.scope)
const view = createMemo(() => viewCache.load(scope(), params.id))
const ensure = (file: string) => {
+5 -4
View File
@@ -3,6 +3,7 @@ import { createStore, produce } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { createScopedCache } from "@/utils/scoped-cache"
import type { FileViewState, SelectedLineRange } from "./types"
import type { ServerScope } from "@/utils/server-scope"
const WORKSPACE_KEY = "__workspace__"
const MAX_FILE_VIEW_SESSIONS = 20
@@ -33,11 +34,11 @@ function equalSelectedLines(a: SelectedLineRange | null | undefined, b: Selected
)
}
function createViewSession(dir: string, id: string | undefined) {
function createViewSession(scope: ServerScope, dir: string, id: string | undefined) {
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
const [view, setView, _, ready] = persisted(
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
Persist.serverScoped(scope, dir, id, "file-view", [legacyViewKey]),
createStore<{
file: Record<string, FileViewState>
}>({
@@ -119,14 +120,14 @@ function createViewSession(dir: string, id: string | undefined) {
}
}
export function createFileViewCache() {
export function createFileViewCache(scope: ServerScope) {
const cache = createScopedCache(
(key) => {
const split = key.lastIndexOf("\n")
const dir = split >= 0 ? key.slice(0, split) : key
const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
return createRoot((dispose) => ({
value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
value: createViewSession(scope, dir, id === WORKSPACE_KEY ? undefined : id),
dispose,
}))
},
@@ -3,8 +3,9 @@ import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query"
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { bootstrapDirectory } from "./bootstrap"
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
import type { State, VcsCache } from "./types"
import { ServerScope } from "@/utils/server-scope"
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
@@ -45,6 +46,7 @@ describe("bootstrapDirectory", () => {
await bootstrapDirectory({
directory: "/project",
scope: ServerScope.local,
mcp: false,
global: {
config: {} satisfies Config,
@@ -89,3 +91,18 @@ describe("bootstrapDirectory", () => {
expect(mcpReads).toEqual([])
})
})
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
const client = {} as OpencodeClient
const remote = "https://debian.example" as typeof ServerScope.local
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([
"https://debian.example",
null,
"providers",
])
})
})
@@ -20,6 +20,7 @@ import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
type GlobalStore = {
ready: boolean
@@ -59,8 +60,8 @@ function errors(list: PromiseSettledResult<unknown>[]) {
const providerRev = new Map<string, number>()
export function clearProviderRev(directory: string) {
providerRev.delete(directory)
export function clearProviderRev(scope: ServerScope, directory: string) {
providerRev.delete(ScopedKey.from(scope, directory))
}
function runAll(list: Array<() => Promise<unknown>>) {
@@ -83,15 +84,15 @@ function showErrors(input: {
})
}
export const loadGlobalConfigQuery = (sdk: OpencodeClient) =>
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
queryOptions({
queryKey: ["config"],
queryKey: [scope, "config"],
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
})
export const loadProjectsQuery = (sdk: OpencodeClient) =>
export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
queryOptions({
queryKey: ["project"],
queryKey: [scope, "project"],
queryFn: () =>
retry(() =>
sdk.project.list().then((x) => {
@@ -106,6 +107,7 @@ export const loadProjectsQuery = (sdk: OpencodeClient) =>
export async function bootstrapGlobal(input: {
serverSDK: OpencodeClient
scope: ServerScope
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
@@ -113,12 +115,12 @@ export async function bootstrapGlobal(input: {
queryClient: QueryClient
}) {
const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.serverSDK)),
() => input.queryClient.fetchQuery(loadProvidersQuery(null, input.serverSDK)),
() => input.queryClient.fetchQuery(loadPathQuery(null, input.serverSDK)),
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.serverSDK))
.fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
.then((data) => input.setGlobalStore("project", data)),
]
await runAll(slow)
@@ -178,26 +180,27 @@ function warmSessions(input: {
).then(() => undefined)
}
export const loadProvidersQuery = (directory: string | null, sdk: OpencodeClient) =>
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
queryOptions({
queryKey: [directory, "providers"],
queryKey: [scope, directory, "providers"],
queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
})
export const loadAgentsQuery = (directory: string | null, sdk: OpencodeClient) =>
export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
queryOptions({
queryKey: [directory, "agents"],
queryKey: [scope, directory, "agents"],
queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
})
export const loadPathQuery = (directory: string | null, sdk: OpencodeClient) =>
export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
queryOptions<Path>({
queryKey: [directory, "path"],
queryKey: [scope, directory, "path"],
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
})
export async function bootstrapDirectory(input: {
directory: string
scope: ServerScope
mcp: boolean
sdk: OpencodeClient
store: Store<State>
@@ -223,14 +226,15 @@ export async function bootstrapDirectory(input: {
}
if (loading) input.setStore("status", "partial")
const rev = (providerRev.get(input.directory) ?? 0) + 1
providerRev.set(input.directory, rev)
const revKey = ScopedKey.from(input.scope, input.directory)
const rev = (providerRev.get(revKey) ?? 0) + 1
providerRev.set(revKey, rev)
;(async () => {
const slow = [
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
input.queryClient
.ensureQueryData(loadAgentsQuery(input.directory, input.sdk))
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
.then((data) => input.setStore("agent", data)),
() =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
@@ -239,7 +243,7 @@ export async function bootstrapDirectory(input: {
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
!seededPath &&
(() =>
input.queryClient.ensureQueryData(loadPathQuery(input.directory, input.sdk)).then((data) => {
input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
@@ -305,9 +309,9 @@ export async function bootstrapDirectory(input: {
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk))),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
() =>
input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => {
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
const project = getFilename(input.directory)
showToast({
variant: "error",
@@ -4,9 +4,16 @@ import { createStore } from "solid-js/store"
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import type { State } from "./types"
import type { QueryOptionsApi } from "../server-sync"
import { ServerScope } from "@/utils/server-scope"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
store[0],
store[1],
null,
Object.assign(() => true, { promise: undefined }),
]
const child = () => createStore({} as State)
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
@@ -42,12 +49,6 @@ function createOwner(callback: (owner: Owner) => void) {
}
beforeAll(async () => {
mock.module("@/utils/persist", () => ({
Persist: {
workspace: (...parts: string[]) => parts.join(":"),
},
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
}))
mock.module("@tanstack/solid-query", () => ({
useQuery: (options: () => { queryKey?: unknown[]; enabled?: boolean }) => {
querySingles.push(options)
@@ -80,6 +81,8 @@ describe("createChildStoreManager", () => {
const manager = createChildStoreManager({
owner,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
@@ -109,6 +112,8 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap(directory) {
@@ -140,6 +145,8 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
@@ -171,6 +178,8 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
@@ -18,9 +18,12 @@ import { useQuery } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../server-sync"
import { directoryKey, type DirectoryKey } from "./utils"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import type { ServerScope } from "@/utils/server-scope"
export function createChildStoreManager(input: {
owner: Owner
scope: ServerScope
persist: typeof persisted
isBooting: (directory: string) => boolean
isLoadingSessions: (directory: string) => boolean
onBootstrap: (directory: string) => void
@@ -147,8 +150,8 @@ export function createChildStoreManager(input: {
if (!key) console.error("No directory provided")
if (!children[key]) {
const vcs = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "vcs", ["vcs.v1"]),
input.persist(
Persist.serverWorkspace(input.scope, directory, "vcs", ["vcs.v1"]),
createStore({ value: undefined as VcsInfo | undefined }),
),
)
@@ -157,8 +160,8 @@ export function createChildStoreManager(input: {
vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
const meta = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "project", ["project.v1"]),
input.persist(
Persist.serverWorkspace(input.scope, directory, "project", ["project.v1"]),
createStore({ value: undefined as ProjectMeta | undefined }),
),
)
@@ -166,8 +169,8 @@ export function createChildStoreManager(input: {
metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] })
const icon = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "icon", ["icon.v1"]),
input.persist(
Persist.serverWorkspace(input.scope, directory, "icon", ["icon.v1"]),
createStore({ value: undefined as string | undefined }),
),
)
@@ -7,14 +7,18 @@ import {
setSessionPrefetch,
shouldSkipSessionPrefetch,
} from "./session-prefetch"
import { ServerScope } from "@/utils/server-scope"
const scope = ServerScope.local
describe("session prefetch", () => {
test("stores and clears message metadata by directory", () => {
clearSessionPrefetch("/tmp/a", ["ses_1"])
clearSessionPrefetch("/tmp/b", ["ses_1"])
clearSessionPrefetch(scope, "/tmp/a", ["ses_1"])
clearSessionPrefetch(scope, "/tmp/b", ["ses_1"])
setSessionPrefetch({
directory: "/tmp/a",
scope,
sessionID: "ses_1",
limit: 200,
cursor: "abc",
@@ -22,21 +26,27 @@ describe("session prefetch", () => {
at: 123,
})
expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, cursor: "abc", complete: false, at: 123 })
expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined()
expect(getSessionPrefetch(scope, "/tmp/a", "ses_1")).toEqual({
limit: 200,
cursor: "abc",
complete: false,
at: 123,
})
expect(getSessionPrefetch(scope, "/tmp/b", "ses_1")).toBeUndefined()
clearSessionPrefetch("/tmp/a", ["ses_1"])
clearSessionPrefetch(scope, "/tmp/a", ["ses_1"])
expect(getSessionPrefetch("/tmp/a", "ses_1")).toBeUndefined()
expect(getSessionPrefetch(scope, "/tmp/a", "ses_1")).toBeUndefined()
})
test("dedupes inflight work", async () => {
clearSessionPrefetch("/tmp/c", ["ses_2"])
clearSessionPrefetch(scope, "/tmp/c", ["ses_2"])
let calls = 0
const run = () =>
runSessionPrefetch({
directory: "/tmp/c",
scope,
sessionID: "ses_2",
task: async () => {
calls += 1
@@ -52,15 +62,48 @@ describe("session prefetch", () => {
})
test("clears a whole directory", () => {
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, cursor: "a", complete: true, at: 1 })
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, cursor: "b", complete: false, at: 2 })
setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, cursor: "c", complete: true, at: 3 })
setSessionPrefetch({
scope,
directory: "/tmp/d",
sessionID: "ses_1",
limit: 10,
cursor: "a",
complete: true,
at: 1,
})
setSessionPrefetch({
scope,
directory: "/tmp/d",
sessionID: "ses_2",
limit: 20,
cursor: "b",
complete: false,
at: 2,
})
setSessionPrefetch({
scope,
directory: "/tmp/e",
sessionID: "ses_1",
limit: 30,
cursor: "c",
complete: true,
at: 3,
})
clearSessionPrefetchDirectory("/tmp/d")
clearSessionPrefetchDirectory(scope, "/tmp/d")
expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined()
expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined()
expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 })
expect(getSessionPrefetch(scope, "/tmp/d", "ses_1")).toBeUndefined()
expect(getSessionPrefetch(scope, "/tmp/d", "ses_2")).toBeUndefined()
expect(getSessionPrefetch(scope, "/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 })
})
test("isolates identical directories and sessions by server scope", () => {
const remote = "https://debian.example" as ServerScope
setSessionPrefetch({ scope, directory: "/repo", sessionID: "ses_1", limit: 10, complete: true, at: 1 })
setSessionPrefetch({ scope: remote, directory: "/repo", sessionID: "ses_1", limit: 20, complete: true, at: 2 })
expect(getSessionPrefetch(scope, "/repo", "ses_1")?.limit).toBe(10)
expect(getSessionPrefetch(remote, "/repo", "ses_1")?.limit).toBe(20)
})
test("refreshes stale first-page prefetched history", () => {
@@ -1,4 +1,6 @@
const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}`
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
const key = (scope: ServerScope, directory: string, sessionID: string) => ScopedKey.from(scope, directory, sessionID)
export const SESSION_PREFETCH_TTL = 15_000
@@ -27,28 +29,32 @@ const rev = new Map<string, number>()
const version = (id: string) => rev.get(id) ?? 0
export function getSessionPrefetch(directory: string, sessionID: string) {
return cache.get(key(directory, sessionID))
export function getSessionPrefetch(scope: ServerScope, directory: string, sessionID: string) {
return cache.get(key(scope, directory, sessionID))
}
export function getSessionPrefetchPromise(directory: string, sessionID: string) {
return inflight.get(key(directory, sessionID))
export function getSessionPrefetchPromise(scope: ServerScope, directory: string, sessionID: string) {
return inflight.get(key(scope, directory, sessionID))
}
export function clearSessionPrefetchInflight() {
inflight.clear()
export function clearSessionPrefetchInflight(scope: ServerScope) {
const prefix = ScopedKey.prefix(scope)
for (const id of inflight.keys()) {
if (id.startsWith(prefix)) inflight.delete(id)
}
}
export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) {
return version(key(directory, sessionID)) === value
export function isSessionPrefetchCurrent(scope: ServerScope, directory: string, sessionID: string, value: number) {
return version(key(scope, directory, sessionID)) === value
}
export function runSessionPrefetch(input: {
directory: string
scope: ServerScope
sessionID: string
task: (value: number) => Promise<Meta | undefined>
}) {
const id = key(input.directory, input.sessionID)
const id = key(input.scope, input.directory, input.sessionID)
const pending = inflight.get(id)
if (pending) return pending
@@ -64,13 +70,14 @@ export function runSessionPrefetch(input: {
export function setSessionPrefetch(input: {
directory: string
scope: ServerScope
sessionID: string
limit: number
cursor?: string
complete: boolean
at?: number
}) {
cache.set(key(input.directory, input.sessionID), {
cache.set(key(input.scope, input.directory, input.sessionID), {
limit: input.limit,
cursor: input.cursor,
complete: input.complete,
@@ -78,18 +85,18 @@ export function setSessionPrefetch(input: {
})
}
export function clearSessionPrefetch(directory: string, sessionIDs: Iterable<string>) {
export function clearSessionPrefetch(scope: ServerScope, directory: string, sessionIDs: Iterable<string>) {
for (const sessionID of sessionIDs) {
if (!sessionID) continue
const id = key(directory, sessionID)
const id = key(scope, directory, sessionID)
rev.set(id, version(id) + 1)
cache.delete(id)
inflight.delete(id)
}
}
export function clearSessionPrefetchDirectory(directory: string) {
const prefix = `${directory}\n`
export function clearSessionPrefetchDirectory(scope: ServerScope, directory: string) {
const prefix = ScopedKey.prefix(scope, directory)
const keys = new Set([...cache.keys(), ...inflight.keys()])
for (const id of keys) {
if (!id.startsWith(prefix)) continue
+151
View File
@@ -0,0 +1,151 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createEffect, createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerProjects, ServerConnection, useServer } from "./server"
import { useServerHealth } from "@/utils/server-health"
import { createServerSdkContext } from "./server-sdk"
import { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web"
import { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
init: () => {
const server = useServer()
const serverHealth = useServerHealth(
() => server.list,
() => true,
)
const [store, setStore] = createStore({
settings: {
serverKey: undefined as ServerConnection.Key | undefined,
},
})
const settingsServer = createMemo(() => {
const list = server.list
return list.find((conn) => ServerConnection.key(conn) === store.settings.serverKey) ?? list[0]
})
createEffect(() => {
const conn = settingsServer()
const key = conn ? ServerConnection.key(conn) : undefined
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
})
const serverCtxs = new Map<
ServerConnection.Key,
{ dispose: () => void; serverCtx: ReturnType<typeof createServerCtx> }
>()
const owner = getOwner()
const ensureServerCtx = (conn: ServerConnection.Any) => {
const key = ServerConnection.key(conn)
const existing = serverCtxs.get(key)
if (existing) return existing.serverCtx
const root = createRoot((dispose) => {
const serverCtx = createServerCtx(conn, server.scope(key), server.projects.forServer(key))
return { dispose, serverCtx }
}, owner as any)
serverCtxs.set(key, root)
return root.serverCtx
}
createMemo(() => {
for (const conn of server.list) {
ensureServerCtx(conn)
}
})
createEffect(() => {
for (const [key] of serverCtxs) {
if (!server.list.find((conn) => ServerConnection.key(conn) === key)) {
const { dispose } = serverCtxs.get(key)!
dispose()
serverCtxs.delete(key)
}
}
})
return {
servers: {
list: () => server.list,
health: serverHealth,
},
settings: {
server: {
get key() {
return store.settings.serverKey
},
selected: settingsServer,
set(key: ServerConnection.Key) {
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
},
},
},
createServerCtx(conn: ServerConnection.Any) {
return ensureServerCtx(conn)
},
}
},
})
function createServerCtx(
conn: ServerConnection.Any,
scope: ServerScope,
projects: ReturnType<typeof createServerProjects>,
) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnReconnect: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
},
})
const sdk = createServerSdkContext(conn, scope)
const sync = createServerSyncContext(sdk)
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = sync.child(project.worktree, { bootstrap: false })
const projectID = childStore.project
const metadata = projectID
? sync.data.project.find((x) => x.id === projectID)
: sync.data.project.find((x) => x.worktree === project.worktree)
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// Without this, different subdirectories of the same git repo would share the same
// icon from the database instead of using their individual overrides.
const base = { ...metadata, ...project }
if (childStore.icon) {
return { ...base, icon: { ...base.icon, override: childStore.icon } }
}
return base
}
const projectsList = createMemo(() => projects.list().map(enrich))
const isLocal =
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
return {
queryClient,
sdk,
sync,
isLocal,
projects: {
...projects,
list: projectsList,
},
}
}
export type ServerCtx = ReturnType<typeof createServerCtx>
function isLocalHost(url: string) {
const host = url.replace(/^https?:\/\//, "").split(":")[0]
if (host === "localhost" || host === "127.0.0.1") return "local"
}
+1
View File
@@ -198,6 +198,7 @@ if (warm !== "en") void loadDict(warm)
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language",
gate: false,
init: (props: { locale?: Locale }) => {
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
const [store, setStore, _, ready] = persisted(
@@ -0,0 +1,38 @@
import type { Accessor } from "solid-js"
export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) {
touch(key)
seed(key)
return key
}
export function createSessionKeyReader(sessionKey: string | Accessor<string>, ensure: (key: string) => void) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
return () => {
const value = key()
ensure(value)
return value
}
}
export function pruneSessionKeys(input: {
keep?: string
max: number
used: Map<string, number>
view: string[]
tabs: string[]
}) {
if (!input.keep) return []
const keys = new Set<string>([...input.view, ...input.tabs])
if (keys.size <= input.max) return []
const score = (key: string) => {
if (key === input.keep) return Number.MAX_SAFE_INTEGER
return input.used.get(key) ?? 0
}
return Array.from(keys)
.sort((a, b) => score(b) - score(a))
.slice(input.max)
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
describe("layout session-key helpers", () => {
test("couples touch and scroll seed in order", () => {
+50 -46
View File
@@ -1,10 +1,11 @@
import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { useLocation } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useServerSync } from "./server-sync"
import { useServerSDK } from "./server-sdk"
import { useServer } from "./server"
import { ServerConnection, useServer } from "./server"
import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist"
@@ -13,6 +14,10 @@ import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
import { createPathHelpers } from "./file/path"
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
export type { ProjectAvatarVariant }
@@ -60,6 +65,7 @@ type SessionView = {
}
type TabHandoff = {
scope: ServerScope
dir: string
id: string
at: number
@@ -69,42 +75,10 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
export type ReviewDiffStyle = "unified" | "split"
export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) {
touch(key)
seed(key)
return key
}
export function createSessionKeyReader(sessionKey: string | Accessor<string>, ensure: (key: string) => void) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
return () => {
const value = key()
ensure(value)
return value
}
}
export function pruneSessionKeys(input: {
keep?: string
max: number
used: Map<string, number>
view: string[]
tabs: string[]
}) {
if (!input.keep) return []
const keys = new Set<string>([...input.view, ...input.tabs])
if (keys.size <= input.max) return []
const score = (key: string) => {
if (key === input.keep) return Number.MAX_SAFE_INTEGER
return input.used.get(key) ?? 0
}
return Array.from(keys)
.sort((a, b) => score(b) - score(a))
.slice(input.max)
}
export type LayoutRoute =
| { type: "home" }
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
| { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key }
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
const all = current?.all ?? []
@@ -115,7 +89,7 @@ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string):
}
const sessionPath = (key: string) => {
const dir = key.split("/")[0]
const dir = SessionStateKey.route(key).split("/")[0]
if (!dir) return
const root = decode64(dir)
if (!root) return
@@ -146,13 +120,35 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
}
}
const currentRoute = (pathname: string): LayoutRoute => {
const parts = pathname.split("/").filter(Boolean)
if (parts.length === 0) return { type: "home" }
const dirBase64 = parts[0]
const dir = decode64(dirBase64)
if (!dir) return { type: "home" }
if (parts[1] !== "session") return { type: "home" }
const id = parts[2]
if (id) return { type: "session", dir, dirBase64, sessionId: id }
return { type: "dir-new-sesssion", dir, dirBase64 }
}
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
gate: false,
init: () => {
const globalSdk = useServerSDK()
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const server = useServer()
const platform = usePlatform()
const location = useLocation()
const route = createMemo(() => {
const value = currentRoute(location.pathname)
if (value.type === "home") return value
return { ...value, server: server.key }
})
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
@@ -197,7 +193,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
})()
const sessionTabs = value.sessionTabs
const sessionTabs = migrateLegacySessionStateKeys(value.sessionTabs)
const sessionView = migrateLegacySessionStateKeys(value.sessionView)
const migratedSessionTabs = (() => {
if (!isRecord(sessionTabs)) return sessionTabs
@@ -226,7 +223,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
migratedSidebar === sidebar &&
migratedReview === review &&
migratedFileTree === fileTree &&
migratedSessionTabs === sessionTabs
migratedSessionTabs === value.sessionTabs &&
sessionView === value.sessionView
) {
return value
}
@@ -237,10 +235,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
review: migratedReview,
fileTree: migratedFileTree,
sessionTabs: migratedSessionTabs,
sessionView,
}
}
const target = Persist.global("layout", ["layout.v6"])
const target = Persist.serverGlobal(serverSdk.scope, "layout", ["layout.v6"])
const [store, setStore, _, ready] = persisted(
{ ...target, migrate },
createStore({
@@ -293,15 +292,19 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const dropSessionState = (keys: string[]) => {
for (const key of keys) {
const parts = key.split("/")
const scope = SessionStateKey.scope(key)
const parts = SessionStateKey.route(key).split("/")
const dir = parts[0]
const session = parts[1]
if (!dir) continue
for (const entry of SESSION_STATE_KEYS) {
const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
const target = session
? Persist.serverSession(scope, dir, session, entry.key)
: Persist.serverWorkspace(scope, dir, entry.key)
void removePersisted(target, platform)
if (scope !== ServerScope.local) continue
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
void removePersisted({ key: legacyKey }, platform)
}
@@ -526,7 +529,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
continue
}
void globalSdk.client.project
void serverSdk.client.project
.update({ projectID: project.id, directory: worktree, icon: { color } })
.catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
@@ -557,11 +560,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
})
return {
route,
ready,
handoff: {
tabs: createMemo(() => store.handoff?.tabs),
setTabs(dir: string, id: string) {
setStore("handoff", "tabs", { dir, id, at: Date.now() })
setStore("handoff", "tabs", { scope: server.scope(), dir, id, at: Date.now() })
},
clearTabs() {
if (!store.handoff?.tabs) return
+9 -6
View File
@@ -9,6 +9,8 @@ import { Persist, persisted } from "@/utils/persist"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { useServerSDK } from "./server-sdk"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
export type ModelKey = { providerID: string; modelID: string; variant?: string }
@@ -25,7 +27,7 @@ type Saved = {
const WORKSPACE_KEY = "__workspace__"
const handoff = new Map<string, State>()
const handoffKey = (dir: string, id: string) => `${dir}\n${id}`
const handoffKey = (scope: ServerScope, dir: string, id: string) => ScopedKey.from(scope, dir, id)
const migrate = (value: unknown) => {
if (!value || typeof value !== "object") return { session: {} }
@@ -57,6 +59,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const params = useParams()
const sdk = useSDK()
const sync = useSync()
const serverSDK = useServerSDK()
const providers = useProviders()
const models = useModels()
@@ -66,7 +69,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [saved, setSaved] = persisted(
{
...Persist.workspace(sdk.directory, "model-selection", ["model-selection.v1"]),
...Persist.serverWorkspace(serverSDK.scope, sdk.directory, "model-selection", ["model-selection.v1"]),
migrate,
},
createStore<Saved>({
@@ -121,14 +124,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const scope = createMemo<State | undefined>(() => {
const session = id()
if (!session) return store.draft
return saved.session[session] ?? handoff.get(handoffKey(sdk.directory, session))
return saved.session[session] ?? handoff.get(handoffKey(serverSDK.scope, sdk.directory, session))
})
createEffect(() => {
const session = id()
if (!session) return
const key = handoffKey(sdk.directory, session)
const key = handoffKey(serverSDK.scope, sdk.directory, session)
const next = handoff.get(key)
if (!next) return
if (saved.session[session] !== undefined) {
@@ -377,7 +380,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return
}
handoff.set(handoffKey(dir, session), next)
handoff.set(handoffKey(serverSDK.scope, dir, session), next)
setStore("draft", undefined)
},
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
@@ -385,7 +388,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!session) return
if (msg.sessionID !== session) return
if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(sdk.directory, session))) return
if (handoff.has(handoffKey(serverSDK.scope, sdk.directory, session))) return
setSaved("session", session, {
agent: msg.agent,
+1
View File
@@ -24,6 +24,7 @@ function modelKey(model: ModelKey) {
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
name: "Models",
gate: false,
init: () => {
const providers = useProviders()
+2 -1
View File
@@ -107,6 +107,7 @@ function buildNotificationIndex(list: Notification[]) {
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification",
gate: false,
init: () => {
const params = useParams()
const serverSDK = useServerSDK()
@@ -124,7 +125,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const currentSession = createMemo(() => params.id)
const [store, setStore, _, ready] = persisted(
Persist.global("notification", ["notification.v1"]),
Persist.serverGlobal(serverSDK.scope, "notification", ["notification.v1"]),
createStore({
list: [] as Notification[],
}),
+2 -1
View File
@@ -46,6 +46,7 @@ function hasPermissionPromptRules(permission: unknown) {
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission",
gate: false,
init: () => {
const params = useParams()
const serverSDK = useServerSDK()
@@ -60,7 +61,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const [store, setStore, _, ready] = persisted(
{
...Persist.global("permission", ["permission.v3"]),
...Persist.serverGlobal(serverSDK.scope, "permission", ["permission.v3"]),
migrate(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value
+6 -3
View File
@@ -5,6 +5,8 @@ import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
interface PartBase {
content: string
@@ -161,11 +163,11 @@ type PromptCacheEntry = {
dispose: VoidFunction
}
function createPromptSession(dir: string, id: string | undefined) {
function createPromptSession(scope: ServerScope, dir: string, id: string | undefined) {
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "prompt", [legacy]),
Persist.serverScoped(scope, dir, id, "prompt", [legacy]),
createStore<{
prompt: Prompt
cursor?: number
@@ -229,6 +231,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
gate: false,
init: () => {
const params = useParams()
const serverSDK = useServerSDK()
const cache = new Map<string, PromptCacheEntry>()
const disposeAll = () => {
@@ -262,7 +265,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const entry = createRoot(
(dispose) => ({
value: createPromptSession(dir, id),
value: createPromptSession(serverSDK.scope, dir, id),
dispose,
}),
owner,
@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test"
import { resumeStreamAfterPageShow } from "./server-sdk"
describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => {
let starts = 0
const start = () => starts++
resumeStreamAfterPageShow({ persisted: false } as PageTransitionEvent, start)
resumeStreamAfterPageShow({ persisted: true } as PageTransitionEvent, start)
expect(starts).toBe(1)
})
})
+38 -16
View File
@@ -8,11 +8,20 @@ import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server"
import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
function createServerSdkContext(server: ServerConnection.Any) {
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
if (!event.persisted) return
start()
}
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope) {
const platform = usePlatform()
const abort = new AbortController()
@@ -95,11 +104,10 @@ function createServerSdkContext(server: ServerConnection.Any) {
let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const aborted = isAbortError
let attempt: AbortController | undefined
let run: Promise<void> | undefined
let started = false
let generation = 0
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
@@ -119,9 +127,12 @@ function createServerSdkContext(server: ServerConnection.Any) {
const start = () => {
if (started) return run
started = true
run = (async () => {
const active = ++generation
const previous = run
const current = (async () => {
if (previous) await previous
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started) {
while (!abort.signal.aborted && started && generation === active) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
@@ -132,7 +143,7 @@ function createServerSdkContext(server: ServerConnection.Any) {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (aborted(error)) return
if (isStreamClosed(error, attempt?.signal)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
@@ -175,7 +186,7 @@ function createServerSdkContext(server: ServerConnection.Any) {
await wait(0)
}
} catch (error) {
if (!aborted(error) && !streamErrorLogged) {
if (!isStreamClosed(error, attempt?.signal) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: server.http.url,
@@ -189,23 +200,28 @@ function createServerSdkContext(server: ServerConnection.Any) {
clearHeartbeat()
}
if (abort.signal.aborted || !started) return
if (abort.signal.aborted || !started || generation !== active) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => {
if (run !== current) return
run = undefined
flush()
})
run = current
return run
}
const stop = () => {
started = false
generation++
attempt?.abort()
clearHeartbeat()
}
onMount(() => {
makeEventListener(window, "pagehide", stop)
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
makeEventListener(document, "visibilitychange", () => {
if (document.visibilityState !== "visible") return
if (!started) return
@@ -227,6 +243,7 @@ function createServerSdkContext(server: ServerConnection.Any) {
})
return {
scope,
url: server.http.url,
client: sdk,
event: {
@@ -244,18 +261,22 @@ function createServerSdkContext(server: ServerConnection.Any) {
}
}
export type ServerSDK = ReturnType<typeof createServerSdkContext>
export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
name: "ServerSDK",
init: () => {
init: (props: { server?: ServerConnection.Any }) => {
const global = useGlobal()
const language = useLanguage()
const server = useServer()
if (!server.current) throw new Error(language.t("error.serverSDK.noServerAvailable"))
const sdk = createServerSdkContext(server.current)
return {
...sdk,
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
}
const conn = props.server ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
const ctx = global.createServerCtx(conn)
return Object.assign(ctx.sdk, {
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, ctx.sdk)),
})
},
})
@@ -263,7 +284,7 @@ type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
}
function createDirSdkContext(directory: string, serverSDK: ReturnType<typeof createServerSdkContext>) {
function createDirSdkContext(directory: string, serverSDK: ServerSDK) {
const client = serverSDK.createClient({
directory,
throwOnError: true,
@@ -277,6 +298,7 @@ function createDirSdkContext(directory: string, serverSDK: ReturnType<typeof cre
onCleanup(unsub)
return {
scope: serverSDK.scope,
directory,
client,
event: emitter,
+62 -35
View File
@@ -1,11 +1,11 @@
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
import { batch, getOwner, onCleanup, onMount, untrack } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error"
import { useServerSDK } from "./server-sdk"
import { ServerSDK, useServerSDK } from "./server-sdk"
import {
bootstrapDirectory,
bootstrapGlobal,
@@ -31,7 +31,11 @@ import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync"
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
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"
type GlobalStore = {
ready: boolean
@@ -47,35 +51,40 @@ type GlobalStore = {
reload: undefined | "pending" | "complete"
}
export const loadMcpQuery = (directory: string, sdk: OpencodeClient) =>
export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions({
queryKey: [directory, "mcp"] as const,
queryKey: [scope, directory, "mcp"] as const,
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
})
export const loadLspQuery = (directory: string, sdk: OpencodeClient) =>
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions({
queryKey: [directory, "lsp"] as const,
queryKey: [scope, directory, "lsp"] as const,
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
})
function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
function makeQueryOptionsApi(
scope: ServerScope,
serverSDK: () => OpencodeClient,
sdkFor: (dir: PathKey) => OpencodeClient,
) {
return {
globalConfig: () => loadGlobalConfigQuery(serverSDK()),
projects: () => loadProjectsQuery(serverSDK()),
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
projects: () => loadProjectsQuery(scope, serverSDK()),
providers: (directory: PathKey | null) =>
loadProvidersQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
agents: (directory: PathKey) => loadAgentsQuery(directory, sdkFor(directory)),
mcp: (directory: PathKey) => loadMcpQuery(directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(directory, sdkFor(directory)),
sessions: (directory: PathKey) => ({ queryKey: [directory, "loadSessions"] as const }),
loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
path: (directory: PathKey | null) =>
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
}
}
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
export function createServerSyncContext() {
const serverSDK = useServerSDK()
export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
const serverSDK: ServerSDK = _serverSDK ?? useServerSDK()
const language = useLanguage()
const owner = getOwner()
if (!owner) throw new Error("ServerSync must be created within owner")
@@ -97,7 +106,7 @@ export function createServerSyncContext() {
return sdk
}
const queryOptionsApi = makeQueryOptionsApi(() => serverSDK.client, sdkFor)
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
@@ -105,7 +114,7 @@ export function createServerSyncContext() {
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
get ready() {
return bootstrap.isPending
return !bootstrap.isPending
},
project: [],
session_todo: {},
@@ -128,6 +137,7 @@ export function createServerSyncContext() {
return updateConfigMutation.isPending ? "pending" : undefined
},
})
const queryClient = useQueryClient()
let bootedAt = 0
@@ -153,10 +163,11 @@ export function createServerSyncContext() {
}) as typeof setGlobalStore
const bootstrap = useQuery(() => ({
queryKey: ["bootstrap"],
queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => {
await bootstrapGlobal({
serverSDK: serverSDK.client,
scope: serverSDK.scope,
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
@@ -195,12 +206,14 @@ export function createServerSyncContext() {
const queue = createRefreshQueue({
paused,
key: directoryKey,
bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }),
bootstrap: () => queryClient.fetchQuery({ queryKey: [serverSDK.scope, "bootstrap"] }),
bootstrapInstance,
})
const children = createChildStoreManager({
owner,
scope: serverSDK.scope,
persist: persisted,
isBooting: (directory) => booting.has(directory),
isLoadingSessions: (directory) => sessionLoads.has(directory),
onBootstrap: (directory) => {
@@ -224,8 +237,8 @@ export function createServerSyncContext() {
queue.clear(key)
sessionMeta.delete(key)
sdkCache.delete(key)
clearProviderRev(key)
clearSessionPrefetchDirectory(key)
clearProviderRev(serverSDK.scope, key)
clearSessionPrefetchDirectory(serverSDK.scope, key)
},
translate: language.t,
queryOptions: queryOptionsApi,
@@ -325,6 +338,7 @@ export function createServerSyncContext() {
const sdk = sdkFor(directory)
await bootstrapDirectory({
directory,
scope: serverSDK.scope,
mcp: children.mcp(key),
global: {
config: globalStore.config,
@@ -436,8 +450,10 @@ export function createServerSyncContext() {
bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers
// appear immediately in the available provider list across all directories.
queryClient.invalidateQueries({ queryKey: [null, "providers"] })
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[1] === "providers" })
queryClient.invalidateQueries({ queryKey: [serverSDK.scope, null, "providers"] })
queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
})
},
}))
@@ -463,19 +479,30 @@ export function createServerSyncContext() {
}
}
export function createServerSyncContext(_serverSDK?: ServerSDK) {
const inner = createServerSyncContextInner(_serverSDK)
return Object.assign(inner, {
createDirSyncContext: createRefCountMap(
(dir) => createDirSyncContext(dir, inner, _serverSDK),
(dir) => inner.disableMcp(dir),
directoryKey,
),
})
}
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
name: "ServerSync",
init: () => {
const sync = createServerSyncContext()
gate: false,
init: (props: { server?: ServerConnection.Any }) => {
const global = useGlobal()
const language = useLanguage()
const server = useServer()
return {
...sync,
createDirSyncContext: createRefCountMap(
(dir) => createDirSyncContext(dir, sync),
(dir) => sync.disableMcp(dir),
directoryKey,
),
}
const conn = props.server ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
const ctx = global.createServerCtx(conn)
return ctx.sync
},
})
+71 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, test } from "bun:test"
import { resolveServerList, ServerConnection } from "./server"
import { createRoot, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerProjects, migrateCanonicalLocalServerState, resolveServerList, ServerConnection } from "./server"
import { ServerScope } from "@/utils/server-scope"
describe("resolveServerList", () => {
test("lets startup auth_token credentials override a persisted same-url server", () => {
@@ -51,3 +54,70 @@ describe("resolveServerList", () => {
expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined()
})
})
describe("createServerProjects", () => {
test("keeps active and explicit server buckets in one reactive store", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {} })
const active = createServerProjects({ scope, store, setStore })
const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
remote.open("/repo")
expect(remote.list()).toEqual([{ worktree: "/repo", expanded: true }])
expect(active.list()).toEqual([])
const adopted = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
expect(adopted.list()).toEqual([{ worktree: "/repo", expanded: true }])
adopted.close("/repo")
expect(remote.list()).toEqual([])
dispose()
})
})
})
describe("migrateCanonicalLocalServerState", () => {
test("moves an existing canonical web bucket into local scope", () => {
expect(
migrateCanonicalLocalServerState(
{
list: [],
projects: { "https://opencode.example.com": [{ worktree: "/remote", expanded: true }] },
lastProject: { "https://opencode.example.com": "/remote" },
},
ServerConnection.Key.make("https://opencode.example.com"),
),
).toEqual({
list: [],
projects: { local: [{ worktree: "/remote", expanded: true }] },
lastProject: { local: "/remote" },
})
})
test("preserves existing local state while merging a canonical web bucket", () => {
expect(
migrateCanonicalLocalServerState(
{
projects: {
local: [{ worktree: "/local", expanded: false }],
"https://opencode.example.com": [
{ worktree: "/local", expanded: true },
{ worktree: "/remote", expanded: true },
],
},
lastProject: { local: "/local", "https://opencode.example.com": "/remote" },
},
ServerConnection.Key.make("https://opencode.example.com"),
),
).toEqual({
projects: {
local: [
{ worktree: "/local", expanded: false },
{ worktree: "/remote", expanded: true },
],
},
lastProject: { local: "/local" },
})
})
})
+110 -65
View File
@@ -1,10 +1,12 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { ServerScope } from "@/utils/server-scope"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
type ServerProjectState = { projects: Record<string, StoredProject[]>; lastProject: Record<string, string> }
const HEALTH_POLL_INTERVAL_MS = 10_000
export function normalizeServerUrl(input: string) {
@@ -20,18 +22,95 @@ export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = fals
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
}
function projectsKey(key: ServerConnection.Key) {
if (!key) return ""
if (key === "sidecar") return "local"
if (isLocalHost(key)) return "local"
return key
}
function isLocalHost(url: string) {
const host = url.replace(/^https?:\/\//, "").split(":")[0]
if (host === "localhost" || host === "127.0.0.1") return "local"
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
export function migrateCanonicalLocalServerState(value: unknown, canonicalLocalServer?: ServerConnection.Key) {
if (!canonicalLocalServer || canonicalLocalServer === "local") return value
if (!isRecord(value)) return value
const projects = isRecord(value.projects) ? value.projects : undefined
const lastProject = isRecord(value.lastProject) ? value.lastProject : undefined
const previousProjects = projects?.[canonicalLocalServer]
const previousLastProject = lastProject?.[canonicalLocalServer]
if (!Array.isArray(previousProjects) && typeof previousLastProject !== "string") return value
const next = { ...value }
if (projects && Array.isArray(previousProjects)) {
const local = Array.isArray(projects.local) ? projects.local : []
const worktrees = new Set(
local.flatMap((project) => (isRecord(project) && typeof project.worktree === "string" ? [project.worktree] : [])),
)
const migrated = previousProjects.filter((project) => {
if (!isRecord(project) || typeof project.worktree !== "string") return true
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)
return true
})
const nextProjects: Record<string, unknown> = { ...projects, local: [...local, ...migrated] }
delete nextProjects[canonicalLocalServer]
next.projects = nextProjects
}
if (lastProject && typeof previousLastProject === "string") {
const nextLastProject = { ...lastProject }
if (typeof nextLastProject.local !== "string") nextLastProject.local = previousLastProject
delete nextLastProject[canonicalLocalServer]
next.lastProject = nextLastProject
}
return next
}
export function createServerProjects<T extends ServerProjectState>(input: {
scope: Accessor<ServerScope>
store: Store<T>
setStore: SetStoreFunction<T>
}) {
const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
const current = () => input.store.projects[input.scope()] ?? []
return {
list: current,
open(directory: string) {
const scope = input.scope()
if (current().some((project) => project.worktree === directory)) return
setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
},
close(directory: string) {
setStore(
"projects",
input.scope(),
current().filter((project) => project.worktree !== directory),
)
},
expand(directory: string) {
const index = current().findIndex((project) => project.worktree === directory)
if (index !== -1) setStore("projects", input.scope(), index, "expanded", true)
},
collapse(directory: string) {
const index = current().findIndex((project) => project.worktree === directory)
if (index !== -1) setStore("projects", input.scope(), index, "expanded", false)
},
move(directory: string, toIndex: number) {
const fromIndex = current().findIndex((project) => project.worktree === directory)
if (fromIndex === -1 || fromIndex === toIndex) return
const next = [...current()]
const [item] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, item)
setStore("projects", input.scope(), next)
},
last() {
return input.store.lastProject[input.scope()]
},
touch(directory: string) {
setStore("lastProject", input.scope(), directory)
},
}
}
export function resolveServerList(input: {
props?: Array<ServerConnection.Any>
stored: StoredServer[]
@@ -127,9 +206,17 @@ export namespace ServerConnection {
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
gate: true,
init: (props: {
defaultServer: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
}) => {
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
{
...Persist.global("server", ["server.v3"]),
migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
},
createStore({
list: [] as StoredServer[],
projects: {} as Record<string, StoredProject[]>,
@@ -180,8 +267,16 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const isReady = createMemo(() => ready() && !!state.active)
const origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? [])
const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
const projects = createServerProjects({ scope, store, setStore })
const projectStores = new Map<ServerConnection.Key, ReturnType<typeof createServerProjects>>()
const projectsForServer = (key: ServerConnection.Key) => {
const existing = projectStores.get(key)
if (existing) return existing
const next = createServerProjects({ scope: () => scope(key), store, setStore })
projectStores.set(key, next)
return next
}
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
)
@@ -208,60 +303,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
setActive,
add,
remove,
scope,
projects: {
list: projectsList,
open(directory: string) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
if (current.find((x) => x.worktree === directory)) return
setStore("projects", key, [{ worktree: directory, expanded: true }, ...current])
},
close(directory: string) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
setStore(
"projects",
key,
current.filter((x) => x.worktree !== directory),
)
},
expand(directory: string) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
const index = current.findIndex((x) => x.worktree === directory)
if (index !== -1) setStore("projects", key, index, "expanded", true)
},
collapse(directory: string) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
const index = current.findIndex((x) => x.worktree === directory)
if (index !== -1) setStore("projects", key, index, "expanded", false)
},
move(directory: string, toIndex: number) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
const fromIndex = current.findIndex((x) => x.worktree === directory)
if (fromIndex === -1 || fromIndex === toIndex) return
const result = [...current]
const [item] = result.splice(fromIndex, 1)
result.splice(toIndex, 0, item)
setStore("projects", key, result)
},
last() {
const key = origin()
if (!key) return
return store.lastProject[key]
},
touch(directory: string) {
const key = origin()
if (!key) return
setStore("lastProject", key, directory)
},
...projects,
forServer: projectsForServer,
},
}
},
-20
View File
@@ -1,20 +0,0 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useServer } from "./server"
import { useServerHealth } from "@/utils/server-health"
export const { use: useServers, provider: ServersProvider } = createSimpleContext({
name: "Servers",
init: () => {
const server = useServer()
const health = useServerHealth(
() => server.list,
() => true,
)
return {
list: () => server.list,
health,
}
},
})
+1
View File
@@ -156,6 +156,7 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
name: "Settings",
gate: false,
init: () => {
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
+140
View File
@@ -0,0 +1,140 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createStore, produce } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { ServerConnection, useServer } from "./server"
import { createEffect, startTransition } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
export type SessionTab = {
type: "session"
server: ServerConnection.Key
dirBase64: string
sessionId: string
}
export type Tab = SessionTab
export const tabHref = (tab: Tab) => `/${tab.dirBase64}/session/${tab.sessionId}`
export const tabKey = (tab: Tab) => `${tab.server}\n${tabHref(tab)}`
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
name: "Tabs",
gate: false,
init: () => {
const server = useServer()
const fallback = server.key
const [store, setStore, _, ready] = persisted(
{
...Persist.global("tabs"),
migrate: (value: unknown) => {
if (!Array.isArray(value)) return value
return value.map((tab) => {
if (!tab || typeof tab !== "object" || "server" in tab) return tab
return { ...tab, server: fallback }
})
},
},
createStore<Tab[]>([]),
)
const params = useParams()
const navigate = useNavigate()
const closing = new Set<string>()
createEffect(() => {
if (!ready()) return
const servers = new Set(server.list.map(ServerConnection.key))
if (store.every((tab) => servers.has(tab.server))) return
setStore((tabs) => tabs.filter((tab) => servers.has(tab.server)))
})
const navigateTab = (tab: Tab) => {
const href = tabHref(tab)
if (tab.server === server.key) {
navigate(href)
return
}
void startTransition(() => {
server.setActive(tab.server)
navigate(href)
})
}
const actions = {
addSessionTab: (tab: Omit<SessionTab, "type">) => {
const next = { type: "session" as const, ...tab }
if (closing.has(tabKey(next))) return
setStore(
produce((tabs) => {
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
tabs.push(next)
}),
)
},
removeTab: (index: number) => {
const tab = store[index]
if (!tab) return
const key = tabKey(tab)
const nextTab = store[index + 1] ?? store[index - 1]
closing.add(key)
void startTransition(() => {
setStore(
produce((tabs) => {
tabs.splice(index, 1)
}),
)
if (nextTab) navigateTab(nextTab)
else navigate("/")
}).finally(() => closing.delete(key))
},
removeServer(key: ServerConnection.Key) {
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
if (server.key === key) navigate("/")
},
removeSessions: (input: SessionTabsRemovedDetail) => {
void startTransition(() => {
setStore(
produce((tabs) => {
const sessionIDs = new Set(input.sessionIDs)
const currentHref =
params.dir && params.id
? tabHref({ type: "session", server: server.key, dirBase64: params.dir, sessionId: params.id })
: undefined
const currentIndex = currentHref
? tabs.findIndex(
(tab) => tab.type === "session" && tab.server === server.key && tabHref(tab) === currentHref,
)
: -1
const currentTab = tabs[currentIndex]
const removedCurrent =
currentTab?.type === "session" &&
currentTab.server === server.key &&
atob(currentTab.dirBase64) === input.directory &&
sessionIDs.has(currentTab.sessionId)
for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i]
if (!tab || tab.type !== "session") continue
if (tab.server !== server.key) continue
if (atob(tab.dirBase64) !== input.directory) continue
if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1)
}
if (!removedCurrent) return
const nextTab =
tabs.slice(currentIndex).find((tab) => tab.type === "session") ??
tabs.slice(0, currentIndex).findLast((tab) => tab.type === "session")
if (nextTab) navigateTab(nextTab)
else navigate("/")
}),
)
})
},
}
return { ...actions, store, ready }
},
})
+6 -42
View File
@@ -1,9 +1,7 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
import { ServerScope } from "@/utils/server-scope"
type ServerKey = Parameters<typeof import("./terminal").getTerminalServerScope>[1]
let getWorkspaceTerminalCacheKey: (dir: string, scope?: string) => string
let getTerminalServerScope: typeof import("./terminal").getTerminalServerScope
let getWorkspaceTerminalCacheKey: typeof import("./terminal").getWorkspaceTerminalCacheKey
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
let migrateTerminalState: (value: unknown) => unknown
@@ -20,53 +18,19 @@ beforeAll(async () => {
}))
const mod = await import("./terminal")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
getTerminalServerScope = mod.getTerminalServerScope
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
migrateTerminalState = mod.migrateTerminalState
})
describe("getWorkspaceTerminalCacheKey", () => {
test("uses workspace-only directory cache key", () => {
expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
expect(String(getWorkspaceTerminalCacheKey("/repo"))).toBe("local\u0000/repo\u0000__workspace__")
})
test("can include a server scope", () => {
expect(getWorkspaceTerminalCacheKey("/repo", "wsl:Debian")).toBe("wsl:Debian:/repo:__workspace__")
})
})
describe("getTerminalServerScope", () => {
test("preserves local server keys", () => {
expect(
getTerminalServerScope(
{ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } },
"sidecar" as ServerKey,
),
).toBeUndefined()
expect(
getTerminalServerScope(
{ type: "http", http: { url: "http://localhost:4096" } },
"http://localhost:4096" as ServerKey,
),
).toBeUndefined()
expect(
getTerminalServerScope({ type: "http", http: { url: "http://[::1]:4096" } }, "http://[::1]:4096" as ServerKey),
).toBeUndefined()
})
test("scopes non-local server keys", () => {
expect(
getTerminalServerScope(
{ type: "sidecar", variant: "wsl", distro: "Debian", http: { url: "http://127.0.0.1:4096" } },
"wsl:Debian" as ServerKey,
),
).toBe("wsl:Debian" as ServerKey)
expect(
getTerminalServerScope(
{ type: "http", http: { url: "https://example.com" } },
"https://example.com" as ServerKey,
),
).toBe("https://example.com" as ServerKey)
expect(String(getWorkspaceTerminalCacheKey("/repo", "ssh:debian" as ServerScope))).toBe(
"ssh:debian\u0000/repo\u0000__workspace__",
)
})
})
+24 -37
View File
@@ -4,9 +4,10 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import type { Platform } from "./platform"
import { ServerConnection, useServer } from "./server"
import { useServer } from "./server"
import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
export type LocalPTY = {
id: string
@@ -83,29 +84,8 @@ export function migrateTerminalState(value: unknown) {
}
}
export function getWorkspaceTerminalCacheKey(dir: string, scope?: string) {
if (scope) return `${scope}:${dir}:${WORKSPACE_KEY}`
return `${dir}:${WORKSPACE_KEY}`
}
export function getTerminalServerScope(conn: ServerConnection.Any | undefined, key: ServerConnection.Key) {
if (!conn) return
if (conn.type === "sidecar" && conn.variant === "base") return
if (conn.type === "http") {
try {
const url = new URL(conn.http.url)
if (
url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
url.hostname === "::1" ||
url.hostname === "[::1]"
)
return
} catch {
return key
}
}
return key
export function getWorkspaceTerminalCacheKey(dir: string, scope: ServerScopeValue = ServerScope.local) {
return ScopedKey.from(scope, dir, WORKSPACE_KEY)
}
export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
@@ -132,16 +112,25 @@ const trimTerminal = (pty: LocalPTY) => {
}
}
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform, scope?: string) {
function terminalPersistTarget(scope: ServerScopeValue, dir: string, legacy?: string[]) {
return Persist.serverWorkspace(scope, dir, "terminal", legacy)
}
export function clearWorkspaceTerminals(
dir: string,
sessionIDs?: string[],
platform?: Platform,
scope: ServerScopeValue = ServerScope.local,
) {
const key = getWorkspaceTerminalCacheKey(dir, scope)
for (const cache of caches) {
const entry = cache.get(key)
entry?.value.clear()
}
void removePersisted(Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal"), platform)
void removePersisted(terminalPersistTarget(scope, dir), platform)
if (scope) return
if (scope !== ServerScope.local) return
const legacy = new Set(getLegacyTerminalStorageKeys(dir))
for (const id of sessionIDs ?? []) {
for (const key of getLegacyTerminalStorageKeys(dir, id)) {
@@ -156,14 +145,14 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
function createWorkspaceTerminalSession(
sdk: ReturnType<typeof useSDK>,
dir: string,
scope: ServerScopeValue,
legacySessionID?: string,
scope?: string,
) {
const legacy = scope ? [] : getLegacyTerminalStorageKeys(dir, legacySessionID)
const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
const [store, setStore, _, ready] = persisted(
{
...Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal", legacy),
...terminalPersistTarget(scope, dir, legacy),
migrate: migrateTerminalState,
},
createStore<{
@@ -388,9 +377,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
const server = useServer()
const params = useParams()
const cache = new Map<string, TerminalCacheEntry>()
const scope = createMemo(() => {
return getTerminalServerScope(server.current, server.key)
})
const scope = server.scope()
caches.add(cache)
onCleanup(() => caches.delete(cache))
@@ -414,7 +401,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
}
const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: string | undefined) => {
const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: ServerScopeValue) => {
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
const key = getWorkspaceTerminalCacheKey(dir, serverScope)
const existing = cache.get(key)
@@ -425,7 +412,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
const entry = createRoot((dispose) => ({
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID, serverScope),
value: createWorkspaceTerminalSession(sdk, dir, serverScope, legacySessionID),
dispose,
}))
@@ -434,11 +421,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value
}
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope()))
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope))
createEffect(
on(
() => ({ dir: params.dir, id: params.id, scope: scope() }),
() => ({ dir: params.dir, id: params.id, scope }),
(next, prev) => {
if (!prev?.dir) return
if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return
+1
View File
@@ -170,6 +170,7 @@ if (root instanceof HTMLElement) {
<AppBaseProviders>
<AppInterface
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
canonicalLocalServer={ServerConnection.key(server)}
servers={[server]}
disableHealthCheck
/>
+1
View File
@@ -467,6 +467,7 @@ export const dict = {
"error.page.title": "Something went wrong",
"error.page.description": "An error occurred while loading the application.",
"error.page.description.localServerStartup": "An error occurred while starting the local server.",
"error.page.details.label": "Error Details",
"error.page.action.restart": "Restart",
"error.page.action.report": "Report Error",
+26 -1
View File
@@ -11,7 +11,7 @@
@font-face {
font-family: "Inter";
src: url("/assets/Inter.ttf") format("truetype");
font-weight: normal;
font-weight: 100 900;
font-style: normal;
}
@@ -137,6 +137,31 @@
}
}
[data-slot="titlebar-update-loader"] {
display: block;
flex-shrink: 0;
margin: 1px;
width: 12px;
height: 12px;
border-radius: 9999px;
border: 2px solid color-mix(in srgb, var(--v2-icon-icon-accent) 30%, transparent);
border-top-color: var(--v2-icon-icon-accent);
animation: titlebar-update-loader-spin 0.67s linear infinite;
transform-origin: 50% 50%;
}
@media (prefers-reduced-motion: reduce) {
[data-slot="titlebar-update-loader"] {
animation: none;
}
}
@keyframes titlebar-update-loader-spin {
to {
transform: rotate(360deg);
}
}
@keyframes fade-in {
from {
opacity: 0;
+1 -1
View File
@@ -26,7 +26,7 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
createResource(
() => params.id,
(id) => sync.session.sync(id),
(id) => sync.session.sync(id).catch(() => {}),
)
return (
@@ -0,0 +1,17 @@
import { describe, expect, test } from "bun:test"
import { errorDescriptionKey } from "./error-description"
describe("error description", () => {
test("describes local server startup errors", () => {
expect(errorDescriptionKey(Object.assign(new Error("migration failed"), { localServerStartup: true }))).toBe(
"error.page.description.localServerStartup",
)
})
test("uses the generic description for other errors", () => {
expect(errorDescriptionKey(new Error("unknown"))).toBe("error.page.description")
expect(errorDescriptionKey(Object.assign(new Error("unknown"), { localServerStartup: false }))).toBe(
"error.page.description",
)
})
})
@@ -0,0 +1,11 @@
export function errorDescriptionKey(error: unknown) {
if (
typeof error === "object" &&
error !== null &&
"localServerStartup" in error &&
error.localServerStartup === true
) {
return "error.page.description.localServerStartup" as const
}
return "error.page.description" as const
}
+2 -1
View File
@@ -7,6 +7,7 @@ import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Icon } from "@opencode-ai/ui/icon"
import { errorDescriptionKey } from "./error-description"
export type InitError = {
name: string
@@ -289,7 +290,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
<Logo class="w-58.5 opacity-12 shrink-0" />
<div class="flex flex-col items-center gap-2 text-center">
<h1 class="text-lg font-medium text-text-strong">{language.t("error.page.title")}</h1>
<p class="text-sm text-text-weak">{language.t("error.page.description")}</p>
<p class="text-sm text-text-weak">{language.t(errorDescriptionKey(props.error))}</p>
</div>
<TextField
value={formattedError()}
+390 -251
View File
@@ -1,5 +1,5 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch } from "solid-js"
import { batch, createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createStore } from "solid-js/store"
import { useQuery } from "@tanstack/solid-query"
@@ -23,15 +23,26 @@ import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { ServerConnection, useServer } from "@/context/server"
import { useServerSync } from "@/context/server-sync"
import { useServers } from "@/context/servers"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { displayName, getProjectAvatarSource, projectForSession, sortedRootSessions } from "@/pages/layout/helpers"
import {
closeHomeProject,
displayName,
getProjectAvatarSource,
homeProjectDirectories,
homeProjectNavigation,
homeSessionServerStatus,
type HomeProjectSelection,
projectForSession,
sortedRootSessions,
toggleHomeProjectSelection,
} from "@/pages/layout/helpers"
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 { ServerHealthIndicator } from "@/components/server/server-row"
@@ -51,6 +62,8 @@ type HomeSessionRecord = {
projectName: string
}
type HomeSessionSync = Pick<ReturnType<typeof useServerSync>, "child">
type HomeSessionGroup = {
id: "today" | "yesterday" | "older"
title: string
@@ -65,8 +78,10 @@ const HOME_SEARCH_RESULT_TITLE =
const HOME_SEARCH_RESULT_META =
"min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]"
let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
function buildHomeSessionRecords(input: {
sync: ReturnType<typeof useServerSync>
sync: Pick<ReturnType<typeof useServerSync>, "child">
projectDirectories: () => string[]
projects: () => LocalProject[]
projectByID: () => Map<string, LocalProject>
@@ -95,6 +110,53 @@ 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}`
}
@@ -116,17 +178,33 @@ function HomeDesign() {
const navigate = useNavigate()
const server = useServer()
const language = useLanguage()
const global = useGlobal()
const command = useCommand()
const notification = useNotification()
let focusSessionSearch: (() => void) | undefined
const [state, setState] = createStore({
search: "",
project: undefined as string | undefined,
selection: { server: server.key } as HomeProjectSelection,
searchFocused: false,
})
const projects = createMemo(() => layout.projects.list())
const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.project))
const focusedServer = createMemo(
() => global.servers.list().find((conn) => ServerConnection.key(conn) === state.selection.server) ?? server.current,
)
const focusedServerCtx = createMemo(() => {
const conn = focusedServer()
if (!conn) return
return global.createServerCtx(conn)
})
const focusedSync = () => focusedServerCtx()?.sync ?? sync
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.selection.directory))
const newSessionProject = createMemo(
() =>
selectedProject() ??
projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ??
projects()[0],
)
const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
const projectDirectories = createMemo(() => {
const project = selectedProject()
@@ -135,9 +213,9 @@ function HomeDesign() {
})
const search = createMemo(() => state.search.trim())
const sessionLoad = useQuery(() => ({
queryKey: ["home", "sessions", ...projectDirectories()] as const,
queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const,
queryFn: async () => {
await Promise.all(projectDirectories().map((directory) => sync.project.loadSessions(directory)))
await Promise.all(projectDirectories().map((directory) => focusedSync().project.loadSessions(directory)))
return null
},
}))
@@ -147,7 +225,7 @@ function HomeDesign() {
)
const allRecords = createMemo(() =>
buildHomeSessionRecords({
sync,
sync: focusedSync(),
projectDirectories,
projects,
projectByID,
@@ -162,6 +240,13 @@ function HomeDesign() {
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
const groups = createMemo(() => groupSessions(records(), language))
function setSelection(next: HomeProjectSelection) {
batch(() => {
if (state.selection.server !== next.server) setState("selection", "server", next.server)
if (state.selection.directory !== next.directory) setState("selection", "directory", next.directory)
})
}
function closeSearch() {
setState("search", "")
setState("searchFocused", false)
@@ -182,45 +267,82 @@ function HomeDesign() {
},
])
function selectProject(directory: string) {
if (!projects().some((project) => project.worktree === directory)) return
setState("project", state.project === directory ? undefined : directory)
createEffect(() => {
const list = global.servers.list()
if (list.some((conn) => ServerConnection.key(conn) === state.selection.server)) return
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
if (conn) setSelection({ server: ServerConnection.key(conn) })
})
createEffect(() => {
const pending = pendingHomeNavigation
if (!pending || pending.server !== server.key) return
pendingHomeNavigation = undefined
navigate(pending.href)
})
function focusServer(conn: ServerConnection.Any) {
setSelection({ server: ServerConnection.key(conn) })
}
function addProject(directory: string) {
layout.projects.open(directory)
server.projects.touch(directory)
setState("project", directory)
function selectProject(conn: ServerConnection.Any, directory: string) {
const key = ServerConnection.key(conn)
if (
!global
.createServerCtx(conn)
.projects.list()
.some((project) => project.worktree === directory)
)
return
setSelection(toggleHomeProjectSelection(state.selection, key, directory))
}
function addProjects(conn: ServerConnection.Any, directories: string[]) {
const directory = directories[0]
if (!directory) return
const ctx = global.createServerCtx(conn)
directories.forEach(ctx.projects.open)
ctx.projects.touch(directory)
setSelection({ server: ServerConnection.key(conn), directory })
}
function openNewSession() {
const project = selectedProject()
if (!project) {
void chooseProject()
const conn = focusedServer()
const project = newSessionProject()
if (!conn || !project) return
openProjectNewSession(conn, project.worktree)
}
function navigateOnServer(conn: ServerConnection.Any, href: string) {
const next = homeProjectNavigation(server.key, ServerConnection.key(conn), href)
if (!next.server) {
navigate(next.href)
return
}
layout.projects.open(project.worktree)
server.projects.touch(project.worktree)
navigate(`/${base64Encode(project.worktree)}/session`)
pendingHomeNavigation = next
server.setActive(next.server)
}
function openProjectNewSession(directory: string) {
layout.projects.open(directory)
server.projects.touch(directory)
navigate(`/${base64Encode(directory)}/session`)
function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
const ctx = global.createServerCtx(conn)
ctx.projects.open(directory)
ctx.projects.touch(directory)
navigateOnServer(conn, `/${base64Encode(directory)}/session`)
}
function editProject(project: LocalProject) {
function editProject(conn: ServerConnection.Any, project: LocalProject) {
void import("@/components/dialog-edit-project").then((x) => {
dialog.show(() => <x.DialogEditProject project={project} />)
dialog.show(() => <x.DialogEditProject server={conn} project={project} />)
})
}
function unseenCount(project: LocalProject) {
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
if (ServerConnection.key(conn) !== server.key) return 0
return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
}
function clearNotifications(project: LocalProject) {
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
if (ServerConnection.key(conn) !== server.key) return
directories(project)
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
@@ -228,22 +350,23 @@ function HomeDesign() {
function openSession(session: Session) {
const project = projectForSession(session, projects(), projectByID())
layout.projects.open(project?.worktree ?? session.directory)
server.projects.touch(project?.worktree ?? session.directory)
navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
const conn = focusedServer()
if (!conn) return
const directory = project?.worktree ?? session.directory
const ctx = global.createServerCtx(conn)
ctx.projects.open(directory)
ctx.projects.touch(directory)
navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`)
}
async function chooseProject() {
async function chooseProject(conn: ServerConnection.Any) {
function resolve(result: string | string[] | null) {
if (Array.isArray(result)) {
result.forEach(addProject)
if (result[0]) setState("project", result[0])
return
}
if (result) addProject(result)
addProjects(conn, homeProjectDirectories(result))
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
const server = global.createServerCtx(conn)
if (platform.openDirectoryPickerDialog && server.isLocal) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
@@ -253,7 +376,7 @@ function HomeDesign() {
}
dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => <DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
() => resolve(null),
)
}
@@ -265,134 +388,168 @@ function HomeDesign() {
}
return (
<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()}
selected={selectedProject()?.worktree}
selectProject={selectProject}
openNewSession={openProjectNewSession}
chooseProject={() => void chooseProject()}
editProject={editProject}
closeProject={(directory) => {
layout.projects.close(directory)
if (state.project === directory) setState("project", undefined)
}}
clearNotifications={clearNotifications}
unseenCount={unseenCount}
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
<section class="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()}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
bindFocus={(focus) => {
focusSessionSearch = focus
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 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()}
selected={state.selection}
focusServer={focusServer}
selectProject={selectProject}
openNewSession={openProjectNewSession}
chooseProject={(conn) => void chooseProject(conn)}
editProject={editProject}
closeProject={(conn, directory) => {
const next = closeHomeProject(
state.selection,
ServerConnection.key(conn),
global.createServerCtx(conn).projects,
directory,
)
if (next) setSelection(next)
}}
onInput={(value) => setState("search", value)}
onFocus={() => setState("searchFocused", true)}
onClose={closeSearch}
onSelect={selectSearchSession}
clearNotifications={clearNotifications}
unseenCount={unseenCount}
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
<div class="mt-3 min-h-0 flex-1 overflow-y-auto">
<div class="pt-3 flex flex-col gap-6">
<Show when={!sessionLoad.isLoading} fallback={<HomeSessionSkeleton label={language.t("common.loading")} />}>
<section class="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()}
activeServer={state.selection.server === server.key}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
bindFocus={(focus) => {
focusSessionSearch = focus
}}
onInput={(value) => setState("search", value)}
onFocus={() => setState("searchFocused", true)}
onClose={closeSearch}
onSelect={selectSearchSession}
/>
<div class="mt-3 min-h-0 flex-1 overflow-y-auto">
<div class="pt-3 flex flex-col gap-6">
<Show
when={groups().length > 0}
fallback={
<div class="flex min-w-0 flex-col gap-4">
<HomeSessionGroupHeader title={language.t("home.sessions.empty")} onNewSession={openNewSession} />
</div>
}
when={!sessionLoad.isLoading}
fallback={<HomeSessionSkeleton label={language.t("common.loading")} />}
>
<For each={groups()}>
{(group, index) => (
<Show
when={groups().length > 0}
fallback={
<div class="flex min-w-0 flex-col gap-4">
<HomeSessionGroupHeader
title={group.title}
onNewSession={index() === 0 ? openNewSession : undefined}
title={language.t("home.sessions.empty")}
onNewSession={newSessionProject() ? openNewSession : undefined}
/>
<div class="flex min-w-0 flex-col gap-px">
<For each={group.sessions}>
{(record) => <HomeSessionRow record={record} openSession={openSession} />}
</For>
</div>
</div>
)}
</For>
}
>
<For each={groups()}>
{(group, index) => (
<div class="flex min-w-0 flex-col gap-4">
<HomeSessionGroupHeader
title={group.title}
onNewSession={index() === 0 && newSessionProject() ? openNewSession : undefined}
/>
<div class="flex min-w-0 flex-col gap-px">
<For each={group.sessions}>
{(record) => (
<HomeSessionRow
record={record}
sync={focusedSync()}
activeServer={state.selection.server === server.key}
openSession={openSession}
/>
)}
</For>
</div>
</div>
)}
</For>
</Show>
</Show>
</Show>
</div>
</div>
</div>
</section>
</section>
</div>
</div>
)
}
function HomeProjectColumn(props: {
projects: LocalProject[]
selected?: string
selectProject: (directory: string) => void
openNewSession: (directory: string) => void
chooseProject: () => void
editProject: (project: LocalProject) => void
closeProject: (directory: string) => void
clearNotifications: (project: LocalProject) => void
unseenCount: (project: LocalProject) => number
selected: HomeProjectSelection
focusServer: (server: ServerConnection.Any) => void
selectProject: (server: ServerConnection.Any, directory: string) => void
openNewSession: (server: ServerConnection.Any, directory: string) => void
chooseProject: (server: ServerConnection.Any) => void
editProject: (server: ServerConnection.Any, project: LocalProject) => void
closeProject: (server: ServerConnection.Any, directory: string) => void
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
openSettings: () => void
openHelp: () => void
language: ReturnType<typeof useLanguage>
}) {
const servers = useServers()
const global = useGlobal()
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">
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<IconButtonV2
data-action="home-add-project"
variant="ghost-muted"
size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
icon={<IconV2 name="folder-add-left" />}
onClick={props.chooseProject}
aria-label={props.language.t("home.project.add")}
/>
<Show when={global.servers.list().length === 1}>
<IconButtonV2
data-action="home-add-project"
variant="ghost-muted"
size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
icon={<IconV2 name="folder-add-left" />}
onClick={() => props.chooseProject(global.servers.list()[0]!)}
aria-label={props.language.t("home.project.add")}
/>
</Show>
</div>
<Show when={servers.list().length > 1} fallback={<HomeProjectList {...props} />}>
<For each={servers.list()}>
<Show
when={global.servers.list().length > 1}
fallback={<HomeProjectList {...props} server={global.servers.list()[0]!} />}
>
<For each={global.servers.list()}>
{(item) => {
const key = ServerConnection.key(item)
const healthy = () => !!servers.health[key]?.healthy
const [state, setState] = createStore({ open: true })
const healthy = () => !!global.servers.health[key]?.healthy
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">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60`}
disabled={!healthy()}
onClick={() => setState("open", !state.open)}
>
<div class="flex size-4 shrink-0 items-center justify-center">
<ServerHealthIndicator health={servers.health[key]} />
</div>
<span class={HOME_PROJECT_NAV_LABEL}>{item.displayName ?? new URL(item.http.url).host}</span>
<Show when={healthy()}>
<IconV2
name="outline-chevron-down"
class="shrink-0 text-v2-icon-icon-muted data-[open=false]:-rotate-90"
data-open={state.open}
/>
</Show>
</button>
<Show when={healthy() && state.open}>
<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>
<Show when={healthy()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList {...props} />
<HomeProjectList {...props} server={item} projects={serverCtx.projects.list()} />
</Show>
</div>
)
@@ -422,61 +579,52 @@ function HomeProjectColumn(props: {
}
function HomeProjectList(props: {
server: ServerConnection.Any
projects: LocalProject[]
selected?: string
selectProject: (directory: string) => void
openNewSession: (directory: string) => void
chooseProject: () => void
editProject: (project: LocalProject) => void
closeProject: (directory: string) => void
clearNotifications: (project: LocalProject) => void
unseenCount: (project: LocalProject) => number
selected: HomeProjectSelection
selectProject: (server: ServerConnection.Any, directory: string) => void
openNewSession: (server: ServerConnection.Any, directory: string) => void
editProject: (server: ServerConnection.Any, project: LocalProject) => void
closeProject: (server: ServerConnection.Any, directory: string) => void
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
language: ReturnType<typeof useLanguage>
}) {
return (
<Show
when={props.projects.length > 0}
fallback={
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.chooseProject}
>
<IconV2 name="folder-add-left" size="small" />
<span>{props.language.t("home.project.add")}</span>
</button>
}
>
<div class="flex min-w-0 flex-col gap-1">
<For each={props.projects}>
{(project) => (
<HomeProjectRow
project={project}
selected={props.selected === project.worktree}
unseenCount={props.unseenCount(project)}
selectProject={props.selectProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
language={props.language}
/>
)}
</For>
</div>
</Show>
<div class="flex min-w-0 flex-col gap-1">
<For each={props.projects}>
{(project) => (
<HomeProjectRow
project={project}
server={props.server}
selected={
props.selected.server === ServerConnection.key(props.server) &&
props.selected.directory === project.worktree
}
unseenCount={props.unseenCount(props.server, project)}
selectProject={props.selectProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
language={props.language}
/>
)}
</For>
</div>
)
}
function HomeProjectRow(props: {
project: LocalProject
server: ServerConnection.Any
selected: boolean
unseenCount: number
selectProject: (directory: string) => void
openNewSession: (directory: string) => void
editProject: (project: LocalProject) => void
closeProject: (directory: string) => void
clearNotifications: (project: LocalProject) => void
selectProject: (server: ServerConnection.Any, directory: string) => void
openNewSession: (server: ServerConnection.Any, directory: string) => void
editProject: (server: ServerConnection.Any, project: LocalProject) => void
closeProject: (server: ServerConnection.Any, directory: string) => void
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
language: ReturnType<typeof useLanguage>
}) {
const [state, setState] = createStore({ menuOpen: false })
@@ -488,7 +636,7 @@ function HomeProjectRow(props: {
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined}
onClick={() => props.selectProject(props.project.worktree)}
onClick={() => props.selectProject(props.server, props.project.worktree)}
>
<HomeProjectAvatar project={props.project} />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
@@ -503,7 +651,7 @@ function HomeProjectRow(props: {
size="small"
icon={<IconV2 name="edit" />}
aria-label={props.language.t("command.session.new")}
onClick={() => props.openNewSession(props.project.worktree)}
onClick={() => props.openNewSession(props.server, props.project.worktree)}
/>
<MenuV2
gutter={4}
@@ -522,17 +670,20 @@ function HomeProjectRow(props: {
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
<MenuV2.Item onSelect={() => props.openNewSession(props.server, props.project.worktree)}>
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
<MenuV2.Item onSelect={() => props.editProject(props.server, props.project)}>
{props.language.t("common.edit")}
</MenuV2.Item>
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
<MenuV2.Item
disabled={props.unseenCount === 0}
onSelect={() => props.clearNotifications(props.server, props.project)}
>
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
<MenuV2.Item onSelect={() => props.closeProject(props.server, props.project.worktree)}>
{props.language.t("common.close")}
</MenuV2.Item>
</MenuV2.Content>
@@ -560,6 +711,8 @@ function HomeSessionSearch(props: {
open: boolean
loading: boolean
results: HomeSessionRecord[]
sync: HomeSessionSync
activeServer: boolean
noResultsLabel: string
bindFocus: (focus: () => void) => void
onInput: (value: string) => void
@@ -674,6 +827,8 @@ function HomeSessionSearch(props: {
{(record) => (
<HomeSessionSearchResultRow
record={record}
sync={props.sync}
activeServer={props.activeServer}
selected={store.active === homeSessionSearchKey(record)}
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
onSelect={(session) => props.onSelect(session)}
@@ -758,29 +913,18 @@ function HomeSessionSearch(props: {
function HomeSessionSearchResultRow(props: {
record: HomeSessionRecord
sync: HomeSessionSync
activeServer: boolean
selected: boolean
onHighlight: () => void
onSelect: (session: Session) => void
}) {
const globalSync = useServerSync()
const notification = useNotification()
const permission = usePermission()
const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false })
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id))
const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id))
const hasPermissions = createMemo(
() =>
!!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => {
return !permission.autoResponds(item, props.record.session.directory)
}),
)
const isWorking = createMemo(() => {
if (hasPermissions()) return false
return sessionStore.session_working(props.record.session.id)
const status = createHomeSessionStatus({
record: () => props.record,
sync: () => props.sync,
activeServer: () => props.activeServer,
})
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent))
const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0)
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const key = () => homeSessionSearchKey(props.record)
@@ -800,7 +944,7 @@ function HomeSessionSearchResultRow(props: {
onClick={() => props.onSelect(props.record.session)}
>
<Show
when={showStatus()}
when={status.show()}
fallback={
<div class="flex size-4 shrink-0 items-center justify-center">
<TabStateIndicator />
@@ -809,19 +953,19 @@ function HomeSessionSearchResultRow(props: {
>
<div
class="flex size-4 shrink-0 items-center justify-center"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
style={{ color: status.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch>
<Match when={isWorking()}>
<Match when={status.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={hasPermissions()}>
<Match when={status.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<Match when={status.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={unseenCount() > 0}>
<Match when={status.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
@@ -864,26 +1008,18 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
)
}
function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (session: Session) => void }) {
const globalSync = useServerSync()
const notification = useNotification()
const permission = usePermission()
const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false })
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id))
const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id))
const hasPermissions = createMemo(
() =>
!!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => {
return !permission.autoResponds(item, props.record.session.directory)
}),
)
const isWorking = createMemo(() => {
if (hasPermissions()) return false
return sessionStore.session_working(props.record.session.id)
function HomeSessionRow(props: {
record: HomeSessionRecord
sync: HomeSessionSync
activeServer: boolean
openSession: (session: Session) => void
}) {
const status = createHomeSessionStatus({
record: () => props.record,
sync: () => props.sync,
activeServer: () => props.activeServer,
})
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent))
const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0)
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
return (
<button
@@ -893,7 +1029,7 @@ function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (sessio
onClick={() => props.openSession(props.record.session)}
>
<Show
when={showStatus()}
when={status.show()}
fallback={
<div class="flex size-4 shrink-0 items-center justify-center">
<TabStateIndicator />
@@ -902,19 +1038,19 @@ function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (sessio
>
<div
class="flex size-4 shrink-0 items-center justify-center"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
style={{ color: status.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch>
<Match when={isWorking()}>
<Match when={status.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={hasPermissions()}>
<Match when={status.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<Match when={status.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={unseenCount() > 0}>
<Match when={status.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
@@ -974,12 +1110,11 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
function LegacyHome() {
const sync = useServerSync()
const layout = useLayout()
const platform = usePlatform()
const dialog = useDialog()
const navigate = useNavigate()
const global = useGlobal()
const server = useServer()
const servers = useServers()
const language = useLanguage()
const homedir = createMemo(() => sync.data.path.home)
const recent = createMemo(() => {
@@ -990,26 +1125,30 @@ function LegacyHome() {
})
const serverDotClass = createMemo(() => {
const healthy = servers.health[server.key]?.healthy
const healthy = global.servers.health[server.key]?.healthy
if (healthy === true) return "bg-icon-success-base"
if (healthy === false) return "bg-icon-critical-base"
return "bg-border-weak-base"
})
function openProject(directory: string) {
layout.projects.open(directory)
server.projects.touch(directory)
function openProject(server: ServerConnection.Any, directory: string) {
const serverCtx = global.createServerCtx(server)
serverCtx.projects.open(directory)
serverCtx.projects.touch(directory)
navigate(`/${base64Encode(directory)}`)
}
async function chooseProject() {
function resolve(result: string | string[] | null) {
const s = server.current
if (!s) return
const resolve = (result: string | string[] | null) => {
if (Array.isArray(result)) {
for (const directory of result) {
openProject(directory)
openProject(s, directory)
}
} else if (result) {
openProject(result)
openProject(s, result)
}
}
@@ -1021,7 +1160,7 @@ function LegacyHome() {
resolve(result)
} else {
dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => <DialogSelectDirectory multiple={true} onSelect={resolve} server={s} />,
() => resolve(null),
)
}
@@ -1060,7 +1199,7 @@ function LegacyHome() {
size="large"
variant="ghost"
class="text-14-mono text-left justify-between px-3"
onClick={() => openProject(project.worktree)}
onClick={() => openProject(server.current!, project.worktree)}
>
{project.worktree.replace(homedir(), "~")}
<div class="text-14-regular text-text-weak">
+33 -26
View File
@@ -37,7 +37,7 @@ import { useProviders } from "@/hooks/use-providers"
import { toaster } from "@opencode-ai/ui/toast"
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
import { useServerSDK } from "@/context/server-sdk"
import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal"
import { clearWorkspaceTerminals } from "@/context/terminal"
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
import {
clearSessionPrefetchInflight,
@@ -57,6 +57,7 @@ import { createAim } from "@/utils/aim"
import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
@@ -64,7 +65,7 @@ import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DebugBar } from "@/components/debug-bar"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useServer } from "@/context/server"
import { ServerConnection, useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language"
import { pathKey } from "@/utils/path-key"
import {
@@ -89,10 +90,12 @@ 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()
const [store, setStore, , ready] = persisted(
Persist.global("layout.page", ["layout.page.v1"]),
Persist.serverGlobal(serverSDK.scope, "layout.page", ["layout.page.v1"]),
createStore({
lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
activeProject: undefined as string | undefined,
@@ -112,7 +115,6 @@ export default function Layout(props: ParentProps) {
let dialogDead = false
const params = useParams()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const layout = useLayout()
const layoutReady = createMemo(() => layout.ready())
@@ -183,11 +185,7 @@ export default function Layout(props: ParentProps) {
return updateQuery.data.version ?? ""
}
const installUpdate = () => {
if (!platform.updateAndRestart) return
setUpdate("installing", true)
void platform.updateAndRestart().catch(() => {
setUpdate("installing", false)
})
runUpdateAndRestart(platform.updateAndRestart, (installing) => setUpdate("installing", installing))
}
const titlebarUpdate: TitlebarUpdate = {
version: updateVersion,
@@ -414,13 +412,17 @@ export default function Layout(props: ParentProps) {
const unsub = serverSDK.event.listen((e) => {
if (e.details?.type === "worktree.ready") {
setBusy(e.name, false)
WorktreeState.ready(e.name)
WorktreeState.ready(serverSDK.scope, e.name)
return
}
if (e.details?.type === "worktree.failed") {
setBusy(e.name, false)
WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed"))
WorktreeState.failed(
serverSDK.scope,
e.name,
e.details.properties?.message ?? language.t("common.requestFailed"),
)
return
}
@@ -696,7 +698,7 @@ export default function Layout(props: ParentProps) {
serverSDK.url
prefetchToken.value += 1
clearSessionPrefetchInflight()
clearSessionPrefetchInflight(serverSDK.scope)
prefetchQueues.clear()
})
@@ -743,13 +745,14 @@ export default function Layout(props: ParentProps) {
const [store, setStore] = serverSync.child(directory, { bootstrap: false })
return runSessionPrefetch({
scope: serverSDK.scope,
directory,
sessionID,
task: (rev) =>
retry(() => serverSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
.then((messages) => {
if (prefetchToken.value !== token) return
if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
if (!isSessionPrefetchCurrent(serverSDK.scope, directory, sessionID, rev)) return
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
@@ -764,7 +767,7 @@ export default function Layout(props: ParentProps) {
}
if (stale.length > 0) {
clearSessionPrefetch(directory, stale)
clearSessionPrefetch(serverSDK.scope, directory, stale)
for (const id of stale) {
serverSync.todo.set(id, undefined)
}
@@ -776,7 +779,7 @@ export default function Layout(props: ParentProps) {
sorted,
)
if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
if (!isSessionPrefetchCurrent(serverSDK.scope, directory, sessionID, rev)) return
batch(() => {
if (stale.length > 0) {
@@ -788,7 +791,7 @@ export default function Layout(props: ParentProps) {
}
setStore("message", sessionID, reconcile(merged, { key: "id" }))
setSessionPrefetch({ directory, sessionID, ...meta })
setSessionPrefetch({ scope: serverSDK.scope, directory, sessionID, ...meta })
for (const message of items) {
const currentParts = store.part[message.info.id] ?? []
@@ -833,7 +836,7 @@ export default function Layout(props: ParentProps) {
const [store] = serverSync.child(directory, { bootstrap: false })
const cached = untrack(() => {
const info = getSessionPrefetch(directory, session.id)
const info = getSessionPrefetch(serverSDK.scope, directory, session.id)
return shouldSkipSessionPrefetch({
message: store.message[session.id] !== undefined,
info,
@@ -1384,7 +1387,9 @@ export default function Layout(props: ParentProps) {
void openProject(link.directory, false)
const slug = base64Encode(link.directory)
if (link.prompt) {
setSessionHandoff(slug, { prompt: link.prompt })
setSessionHandoff(SessionStateKey.from(server.scope(), SessionRouteKey.fromLegacy(slug)), {
prompt: link.prompt,
})
}
const href = link.prompt ? `/${slug}/session?prompt=${encodeURIComponent(link.prompt)}` : `/${slug}/session`
navigateWithSidebarReset(href)
@@ -1459,15 +1464,17 @@ export default function Layout(props: ParentProps) {
layout.sidebar.toggleWorkspaces(project.worktree)
}
const showEditProjectDialog = (project: LocalProject) => {
const showEditProjectDialog = (conn: ServerConnection.Any, project: LocalProject) => {
const run = ++dialogRun
void import("@/components/dialog-edit-project").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogEditProject project={project} />)
dialog.show(() => <x.DialogEditProject server={conn} project={project} />)
})
}
async function chooseProject() {
const conn = server.current
if (!conn) return
function resolve(result: string | string[] | null) {
if (Array.isArray(result)) {
for (const directory of result) {
@@ -1490,7 +1497,7 @@ export default function Layout(props: ParentProps) {
void import("@/components/dialog-select-directory").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
() => resolve(null),
)
})
@@ -1577,7 +1584,7 @@ export default function Layout(props: ParentProps) {
directory,
sessions.map((s) => s.id),
platform,
getTerminalServerScope(server.current, server.key),
serverSDK.scope,
)
await serverSDK.client.instance.dispose({ directory }).catch(() => undefined)
@@ -1891,7 +1898,7 @@ export default function Layout(props: ParentProps) {
directory && pathKey(directory) !== pathKey(local) && !dirs.some((item) => pathKey(item) === pathKey(directory))
? directory
: undefined
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
const pending = extra ? WorktreeState.get(serverSDK.scope, extra)?.status === "pending" : false
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
@@ -1963,7 +1970,7 @@ export default function Layout(props: ParentProps) {
const root = pathKey(local)
setBusy(created.directory, true)
WorktreeState.pending(created.directory)
WorktreeState.pending(serverSDK.scope, created.directory)
setStore("workspaceExpanded", key, true)
if (key !== created.directory) {
setStore("workspaceExpanded", created.directory, true)
@@ -2024,7 +2031,7 @@ export default function Layout(props: ParentProps) {
navigateToProject,
openSidebar: () => layout.sidebar.open(),
closeProject,
showEditProjectDialog,
showEditProjectDialog: (proj) => showEditProjectDialog(server.current!, proj),
toggleProjectWorkspaces,
workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
workspaceIds,
@@ -2171,7 +2178,7 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
showEditProjectDialog(project)
showEditProjectDialog(server.current!, project)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
@@ -9,13 +9,21 @@ import {
import { type Session } from "@opencode-ai/sdk/v2/client"
import {
childSessionOnPath,
closeHomeProject,
displayName,
effectiveWorkspaceOrder,
errorMessage,
hasProjectPermissions,
homeProjectNavigation,
homeProjectDirectories,
homeSessionServerStatus,
latestRootSession,
toggleHomeProjectSelection,
} from "./helpers"
import { pathKey } from "@/utils/path-key"
import { ServerConnection } from "@/context/server"
const serverKey = ServerConnection.Key.make
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
({
@@ -215,6 +223,94 @@ describe("layout workspace helpers", () => {
test("formats fallback project display name", () => {
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
expect(displayName({ worktree: "/" })).toBe("/")
})
test("scopes home project selection by server", () => {
expect(
toggleHomeProjectSelection(undefined, serverKey("https://debian.example"), "/home/luke/repos/amazon"),
).toEqual({
server: serverKey("https://debian.example"),
directory: "/home/luke/repos/amazon",
})
expect(
toggleHomeProjectSelection(
{ server: serverKey("https://windows.example"), directory: "/home/luke/repos/amazon" },
serverKey("https://debian.example"),
"/home/luke/repos/amazon",
),
).toEqual({ server: serverKey("https://debian.example"), directory: "/home/luke/repos/amazon" })
expect(
toggleHomeProjectSelection(
{ server: serverKey("https://debian.example"), directory: "/home/luke/repos/amazon" },
serverKey("https://debian.example"),
"/home/luke/repos/amazon",
),
).toEqual({ server: serverKey("https://debian.example") })
})
test("closes a home project through its server context", () => {
const closed: string[] = []
expect(
closeHomeProject(
{ server: serverKey("https://windows.example"), directory: "/shared" },
serverKey("https://debian.example"),
{ close: (directory) => closed.push(directory) },
"/shared",
),
).toEqual({ server: serverKey("https://windows.example"), directory: "/shared" })
expect(closed).toEqual(["/shared"])
expect(
closeHomeProject(
{ server: serverKey("https://debian.example"), directory: "/shared" },
serverKey("https://debian.example"),
{ close: (directory) => closed.push(directory) },
"/shared",
),
).toEqual({ server: serverKey("https://debian.example") })
})
test("defers home project navigation until its server is active", () => {
expect(
homeProjectNavigation(serverKey("sidecar"), serverKey("https://debian.example"), "/YW1hem9u/session"),
).toEqual({
server: serverKey("https://debian.example"),
href: "/YW1hem9u/session",
})
expect(
homeProjectNavigation(
serverKey("https://debian.example"),
serverKey("https://debian.example"),
"/YW1hem9u/session",
),
).toEqual({
href: "/YW1hem9u/session",
})
})
test("preserves picker order when adding multiple projects", () => {
expect(homeProjectDirectories(["/first", "/second"])).toEqual(["/first", "/second"])
expect(homeProjectDirectories("/only")).toEqual(["/only"])
expect(homeProjectDirectories(null)).toEqual([])
})
test("hides status derived from an inactive server", () => {
let reads = 0
const status = () => {
reads++
return { working: true, tint: "red" }
}
expect(homeSessionServerStatus(false, status)).toEqual({
working: false,
tint: undefined,
})
expect(reads).toBe(0)
expect(homeSessionServerStatus(true, status)).toEqual({
working: true,
tint: "red",
})
expect(reads).toBe(1)
})
test("extracts api error message and fallback", () => {
+40 -2
View File
@@ -1,6 +1,7 @@
import { getFilename } from "@opencode-ai/core/util/path"
import { type Session } from "@opencode-ai/sdk/v2/client"
import { pathKey } from "@/utils/path-key"
import type { ServerConnection } from "@/context/server"
type SessionStore = {
session?: Session[]
@@ -53,7 +54,44 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri
}
export const displayName = (project: { name?: string; worktree: string }) =>
project.name || getFilename(project.worktree)
project.name || getFilename(project.worktree) || project.worktree
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
export function toggleHomeProjectSelection(
current: HomeProjectSelection | undefined,
server: ServerConnection.Key,
directory: string,
): HomeProjectSelection {
if (current?.server === server && current.directory === directory) return { server }
return { server, directory }
}
export function closeHomeProject(
selected: HomeProjectSelection | undefined,
server: ServerConnection.Key,
projects: { close: (directory: string) => void },
directory: string,
) {
projects.close(directory)
if (selected?.server === server && selected.directory === directory) return { server }
return selected
}
export function homeProjectNavigation(active: ServerConnection.Key, server: ServerConnection.Key, href: string) {
if (active === server) return { href }
return { server, href }
}
export function homeProjectDirectories(result: string | string[] | null) {
if (!result) return []
return Array.isArray(result) ? result : [result]
}
export function homeSessionServerStatus(active: boolean, status: () => { working: boolean; tint?: string }) {
if (!active) return { working: false, tint: undefined }
return status()
}
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
@@ -67,7 +105,7 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url
export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
session: Session,
projects: T[],
byID: Map<string, T>,
byID: Map<string, T> = new Map(projects.flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
) {
const direct = byID.get(session.projectID)
if (direct) return direct
@@ -4,18 +4,24 @@ import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
export function useSessionTabAvatarState(directory: Accessor<string>, sessionId: Accessor<string>) {
export function useSessionTabAvatarState(
directory: Accessor<string>,
sessionId: Accessor<string>,
active: Accessor<boolean> = () => true,
) {
const globalSync = useServerSync()
const notification = useNotification()
const permission = usePermission()
const hasPermissions = createMemo(() => {
if (!active()) return false
const [store] = globalSync.child(directory(), { bootstrap: false })
return !!sessionPermissionRequest(store.session, store.permission, sessionId(), (item) => {
return !permission.autoResponds(item, directory())
})
})
const unread = createMemo(() => hasPermissions() || notification.session.unseenCount(sessionId()) > 0)
const unread = createMemo(() => active() && (hasPermissions() || notification.session.unseenCount(sessionId()) > 0))
const loading = createMemo(() => {
if (!active()) return false
if (hasPermissions()) return false
const [store] = globalSync.child(directory(), { bootstrap: false })
return store.session_working(sessionId())
@@ -0,0 +1,19 @@
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])
})
})
+10
View File
@@ -0,0 +1,10 @@
export function runUpdateAndRestart(
updateAndRestart: (() => Promise<void>) | undefined,
setInstalling: (installing: boolean) => void,
) {
if (!updateAndRestart) return
setInstalling(true)
void updateAndRestart()
.catch(() => undefined)
.finally(() => setInstalling(false))
}
+9 -7
View File
@@ -39,6 +39,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
@@ -54,6 +55,7 @@ import {
import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout"
import { useServer } from "@/context/server"
import { syncSessionModel } from "@/pages/session/session-model-helpers"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
@@ -190,13 +192,15 @@ export default function Page() {
const dialog = useDialog()
const language = useLanguage()
const sdk = useSDK()
const serverSDK = useServerSDK()
const settings = useSettings()
const prompt = usePrompt()
const comments = useComments()
const terminal = useTerminal()
const server = useServer()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
const { params, sessionKey, tabs, view } = useSessionLayout()
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
createEffect(() => {
@@ -223,7 +227,6 @@ export default function Page() {
const composer = createSessionComposerState()
const workspaceKey = createMemo(() => params.dir ?? "")
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
createEffect(
@@ -239,6 +242,7 @@ export default function Page() {
layout.handoff.clearTabs()
return
}
if (pending.scope !== server.scope()) return
if (pending.id !== id) return
layout.handoff.clearTabs()
@@ -386,7 +390,7 @@ export default function Page() {
})
const [followup, setFollowup] = persisted(
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
Persist.serverWorkspace(serverSDK.scope, sdk.directory, "followup", ["followup.v1"]),
createStore<{
items: Record<string, FollowupItem[] | undefined>
failed: Record<string, string | undefined>
@@ -467,8 +471,6 @@ export default function Page() {
return {
queryKey: [...vcsKey(), mode] as const,
enabled,
staleTime: Number.POSITIVE_INFINITY,
gcTime: 60 * 1000,
queryFn: mode
? () =>
sdk.client.vcs
@@ -635,7 +637,7 @@ export default function Page() {
const stale = !cached
? false
: (() => {
const info = getSessionPrefetch(directory, id)
const info = getSessionPrefetch(serverSDK.scope, directory, id)
if (!info) return true
return Date.now() - info.at > SESSION_PREFETCH_TTL
})()
@@ -1747,7 +1749,7 @@ export default function Page() {
"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(),
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}}
style={{
width: sessionPanelWidth(),
@@ -10,6 +10,8 @@ import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useServerSDK } from "@/context/server-sdk"
import { ScopedKey } from "@/utils/server-scope"
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
@@ -60,12 +62,14 @@ function Option(props: {
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
const sdk = useSDK()
const serverSDK = useServerSDK()
const language = useLanguage()
const cacheKey = ScopedKey.from(serverSDK.scope, props.request.id)
const questions = createMemo(() => props.request.questions)
const total = createMemo(() => questions().length)
const cached = cache.get(props.request.id)
const cached = cache.get(cacheKey)
const [store, setStore] = createStore({
tab: cached?.tab ?? 0,
answers: cached?.answers ?? ([] as QuestionAnswer[]),
@@ -191,7 +195,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
onCleanup(() => {
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
if (replied) return
cache.set(props.request.id, {
cache.set(cacheKey, {
tab: store.tab,
answers: store.answers.map((a) => (a ? [...a] : [])),
custom: store.custom.map((s) => s ?? ""),
@@ -211,7 +215,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
},
onSuccess: () => {
replied = true
cache.delete(props.request.id)
cache.delete(cacheKey)
},
onError: fail,
}))
@@ -223,7 +227,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
},
onSuccess: () => {
replied = true
cache.delete(props.request.id)
cache.delete(cacheKey)
},
onError: fail,
}))
@@ -1,19 +1,25 @@
import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js"
import { useLayout } from "@/context/layout"
import { useServer } from "@/context/server"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
export const useSessionKey = () => {
const params = useParams()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
return { params, sessionKey }
const server = useServer()
const scope = createMemo(() => server.scope())
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir)))
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir, params.id)))
return { params, sessionKey, workspaceKey }
}
export const useSessionLayout = () => {
const layout = useLayout()
const { params, sessionKey } = useSessionKey()
const { params, sessionKey, workspaceKey } = useSessionKey()
return {
params,
sessionKey,
workspaceKey,
tabs: createMemo(() => layout.tabs(sessionKey)),
view: createMemo(() => layout.view(sessionKey)),
}
@@ -26,7 +26,7 @@ export function TerminalPanel() {
const terminal = useTerminal()
const language = useLanguage()
const command = useCommand()
const { params, view } = useSessionLayout()
const { params, workspaceKey, view } = useSessionLayout()
const opened = createMemo(() => view().terminal.opened())
const size = createSizing()
@@ -126,7 +126,7 @@ export function TerminalPanel() {
language.locale()
setTerminalHandoff(
dir,
workspaceKey(),
terminal.all().map((pty) =>
terminalTabLabel({
title: pty.title,
@@ -140,7 +140,7 @@ export function TerminalPanel() {
const handoff = createMemo(() => {
const dir = params.dir
if (!dir) return []
return getTerminalHandoff(dir) ?? []
return getTerminalHandoff(workspaceKey()) ?? []
})
const all = terminal.all
@@ -418,23 +418,26 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}),
]
const fileCmds = () => [
fileCommand({
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+k,mod+p",
slash: "open",
onSelect: openFile,
}),
fileCommand({
id: "tab.close",
title: language.t("command.tab.close"),
keybind: "mod+w",
disabled: !closableTab(),
onSelect: closeTab,
}),
]
const fileCmds = () => {
const tab = closableTab()
return [
fileCommand({
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+k,mod+p",
slash: "open",
onSelect: openFile,
}),
tab &&
fileCommand({
id: "tab.close",
title: language.t("command.tab.close"),
keybind: "mod+w",
onSelect: closeTab,
}),
].filter((v) => !!v)
}
const contextCmds = () => [
contextCommand({
+26
View File
@@ -1,4 +1,5 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { ServerScope } from "./server-scope"
type PersistTestingType = typeof import("./persist").PersistTesting
type PersistType = typeof import("./persist").Persist
@@ -164,4 +165,29 @@ describe("persist localStorage resilience", () => {
expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull()
expect(storage.getItem(`${target.legacyStorageNames![0]}:${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")
const debian = Persist.serverWorkspace("https://debian.example" as ServerScope, "/home/luke/repo", "prompt")
expect(local).toEqual(Persist.workspace("/home/luke/repo", "prompt"))
expect(windows.storage).not.toBe(local.storage)
expect(debian.storage).not.toBe(local.storage)
expect(debian.storage).not.toBe(windows.storage)
expect(windows.legacyStorageNames).toBeUndefined()
expect(debian.legacyStorageNames).toBeUndefined()
})
test("server global target preserves local key and isolates remote keys", () => {
expect(Persist.serverGlobal(ServerScope.local, "notification")).toEqual(Persist.global("notification"))
expect(Persist.serverGlobal("https://debian.example" as ServerScope, "notification")).toEqual({
storage: "opencode.global.dat",
key: "https://debian.example\0notification",
})
})
test("server global target cannot collide when scope and key contain colons", () => {
expect(Persist.serverGlobal("a:b" as ServerScope, "c")).not.toEqual(Persist.serverGlobal("a" as ServerScope, "b:c"))
})
})
+22 -9
View File
@@ -4,6 +4,7 @@ import { checksum } from "@opencode-ai/core/util/encode"
import { createResource, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
import { pathKey } from "@/utils/path-key"
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [
@@ -357,6 +358,11 @@ function legacyWorkspaceStorage(dir: string) {
return [...result]
}
function serverWorkspaceTarget(scope: ServerScopeValue, dir: string, key: string, legacy?: string[]): PersistTarget {
if (scope !== ServerScope.local) return { storage: workspaceStorage(ScopedKey.from(scope, pathKey(dir))), key }
return { storage: workspaceStorage(pathKey(dir)), legacyStorageNames: legacyWorkspaceStorage(dir), key, legacy }
}
function localStorageWithPrefix(prefix: string): SyncStorage {
const base = `${prefix}:`
const scope = `prefix:${prefix}`
@@ -456,23 +462,30 @@ export const Persist = {
global(key: string, legacy?: string[]): PersistTarget {
return { storage: GLOBAL_STORAGE, 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) }
},
workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
const storage = workspaceStorage(pathKey(dir))
return { storage, legacyStorageNames: legacyWorkspaceStorage(dir), key: `workspace:${key}`, legacy }
return serverWorkspaceTarget(ServerScope.local, dir, `workspace:${key}`, legacy)
},
serverWorkspace(scope: ServerScopeValue, dir: string, key: string, legacy?: string[]): PersistTarget {
return serverWorkspaceTarget(scope, dir, `workspace:${key}`, legacy)
},
session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
const storage = workspaceStorage(pathKey(dir))
return {
storage,
legacyStorageNames: legacyWorkspaceStorage(dir),
key: `session:${session}:${key}`,
legacy,
}
return serverWorkspaceTarget(ServerScope.local, dir, `session:${session}:${key}`, legacy)
},
serverSession(scope: ServerScopeValue, dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
return serverWorkspaceTarget(scope, dir, `session:${session}:${key}`, legacy)
},
scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
if (session) return Persist.session(dir, session, key, legacy)
return Persist.workspace(dir, key, legacy)
},
serverScoped(scope: ServerScopeValue, dir: string, session: string | undefined, key: string, legacy?: string[]) {
if (session) return Persist.serverSession(scope, dir, session, key, legacy)
return Persist.serverWorkspace(scope, dir, key, legacy)
},
}
export function removePersisted(

Some files were not shown because too many files have changed in this diff Show More