Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton 07b357de04 refactor(opencode): drop AppRuntime from instance-runtime
Builds a local ManagedRuntime over InstanceLayer.layer with the shared
process-wide memoMap, so the four exported Promise helpers (load,
disposeInstance, disposeAllInstances, reloadInstance) no longer go
through AppRuntime. Service identity is preserved via memoMap; every
caller's behavior is unchanged.

One step in the broader AppRuntime removal. All public-facing exports
stay the same — callers in src/cli/bootstrap, src/cli/cmd/tui/worker,
src/acp/runtime, and the test fixtures don't need to change.
2026-05-20 20:50:49 -04:00
Kit Langton ddbd119dcb test: drop AppRuntime usage from tests
Removes all direct AppRuntime usage from packages/opencode/test —
`grep -r AppRuntime packages/opencode/test` now returns nothing.

Two patterns are applied:

1. Event tests rewritten in the httpapi-cors.test.ts style. The two
   /event SSE tests now serve HttpApiApp.routes on
   NodeHttpServer.layerTest and hit them via HttpClient. Pub/sub
   identity with the in-process routes is preserved via a new opt-in
   `testEffectShared` (in test/lib/effect.ts) that builds the test
   layer through the shared process-wide memoMap so Bus.defaultLayer
   resolves to the same Bus.Service the routes subscribed to.

   The SSE reader helpers move to test/lib/sse.ts and use HttpClient +
   Effect.Stream + Queue<SseEvent>.

   The D7 diagnostic case is removed: the AppRuntime-vs-test-runtime
   distinction it diagnosed no longer exists.

2. Surgical swap in the remaining four files
   (provider/{amazon-bedrock,provider}, session/llm,
   control-plane/workspace). Each `AppRuntime.runPromise(...)` becomes a
   module-level `ManagedRuntime.make(Service.defaultLayer, { memoMap })`.
   The shared memoMap preserves service identity, so behavior is
   unchanged.

Tests: event (9/9), amazon-bedrock (19/19), provider (84/84),
workspace (35/35); 147 pass across the 5 affected files. The 3
pre-existing failures in session/llm.test.ts are independent of this
change (verified by stashing the diff).
2026-05-20 20:50:32 -04:00
1738 changed files with 53023 additions and 183098 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/http-recorder": minor
---
Publish the initial beta of the Effect HTTP and WebSocket record/replay library.
-11
View File
@@ -1,11 +0,0 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "dev",
"updateInternalDependencies": "patch",
"ignore": []
}
-12
View File
@@ -1,12 +0,0 @@
.git
.opencode
.sst
.turbo
.wrangler
node_modules
**/node_modules
**/.output
**/dist
**/.turbo
**/.vite
**/coverage
-2
View File
@@ -1,2 +0,0 @@
packages/core/migration/**/snapshot.json linguist-generated
packages/core/src/database/migration.gen.ts linguist-generated
+1 -1
View File
@@ -1,5 +1,5 @@
name: Bug report name: Bug report
description: Report an issue that should be fixed (avoid pasting giant AI generated summaries or your issue may be closed/ignored) description: Report an issue that should be fixed
body: body:
- type: textarea - type: textarea
id: description id: description
+8 -11
View File
@@ -9,15 +9,9 @@ on:
concurrency: ${{ github.workflow }}-${{ github.ref }} concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: read
id-token: write
jobs: jobs:
deploy: deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production')
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: ${{ github.ref_name }}
steps: steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
@@ -27,11 +21,14 @@ jobs:
with: with:
node-version: "24" node-version: "24"
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 # Workaround for Pulumi version conflict:
with: # GitHub runners have Pulumi 3.212.0+ pre-installed, which removed the -root flag
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} # from pulumi-language-nodejs (see https://github.com/pulumi/pulumi/pull/21065).
role-session-name: opencode-${{ github.run_id }} # SST 3.17.x uses Pulumi SDK 3.210.0 which still passes -root, causing a conflict.
aws-region: us-east-1 # Removing the system language plugin forces SST to use its bundled compatible version.
# TODO: Remove when sst supports Pulumi >3.210.0
- name: Fix Pulumi version conflict
run: sudo rm -f /usr/local/bin/pulumi-language-nodejs
- run: bun sst deploy --stage=${{ github.ref_name }} - run: bun sst deploy --stage=${{ github.ref_name }}
env: env:
@@ -1,53 +0,0 @@
name: http-recorder release
on:
push:
branches:
- dev
paths:
- ".changeset/**"
- "packages/http-recorder/**"
- ".github/workflows/http-recorder-release.yml"
concurrency: http-recorder-release
permissions:
contents: write
id-token: write
pull-requests: write
jobs:
release:
if: github.repository == 'anomalyco/opencode'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
with:
fetch-depth: 0
- uses: ./.github/actions/setup-bun
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Verify package
run: |
bun run --cwd packages/http-recorder build
bun run --cwd packages/http-recorder test
bun run --cwd packages/http-recorder typecheck
bun run --cwd packages/http-recorder verify:package
- name: Version or publish beta
uses: changesets/action@3841a0683d3cfa6dae0f9bb335290003010fe3f0 # v1.9.0
with:
version: bun run version:http-recorder
publish: bun run release:http-recorder
commit: "chore(http-recorder): release beta"
title: "chore(http-recorder): release beta"
env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
NPM_CONFIG_PROVENANCE: true
+2 -12
View File
@@ -56,24 +56,14 @@ jobs:
BUILD_LOG=$(mktemp) BUILD_LOG=$(mktemp)
trap 'rm -f "$BUILD_LOG"' EXIT trap 'rm -f "$BUILD_LOG"' EXIT
HASH=""
MAX_ATTEMPTS=3
for ((ATTEMPT = 1; ATTEMPT <= MAX_ATTEMPTS; ATTEMPT++)); do
# Build with fakeHash to trigger hash mismatch and reveal correct hash # 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 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 if [ -z "$HASH" ]; then
echo "::error::Failed to compute hash for ${SYSTEM} after ${MAX_ATTEMPTS} attempts" echo "::error::Failed to compute hash for ${SYSTEM}"
cat "$BUILD_LOG" cat "$BUILD_LOG"
exit 1 exit 1
fi fi
+6 -35
View File
@@ -90,7 +90,6 @@ jobs:
id: build id: build
run: | run: |
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }} ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env: env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }} OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@@ -108,12 +107,6 @@ jobs:
with: with:
name: opencode-cli-windows name: opencode-cli-windows
path: packages/opencode/dist/opencode-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: outputs:
version: ${{ needs.version.outputs.version }} version: ${{ needs.version.outputs.version }}
@@ -334,9 +327,9 @@ jobs:
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }} VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
- name: Package - name: Package and publish
if: needs.version.outputs.release if: needs.version.outputs.release
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish always --config electron-builder.config.ts
working-directory: packages/desktop working-directory: packages/desktop
timeout-minutes: 60 timeout-minutes: 60
env: env:
@@ -356,9 +349,11 @@ jobs:
env: env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- name: Create macOS .app.tar.gz - name: Create and upload macOS .app.tar.gz
if: runner.os == 'macOS' && needs.version.outputs.release if: runner.os == 'macOS' && needs.version.outputs.release
working-directory: packages/desktop/dist working-directory: packages/desktop/dist
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: | run: |
if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then
APP_DIR="mac" APP_DIR="mac"
@@ -376,6 +371,7 @@ jobs:
exit 1 exit 1
fi fi
tar -czf "$OUT_NAME" -C "$(dirname "$APP_PATH")" "$(basename "$APP_PATH")" 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 - name: Verify signed Windows Electron artifacts
if: runner.os == 'Windows' if: runner.os == 'Windows'
@@ -450,24 +446,12 @@ jobs:
name: opencode-cli-signed-windows name: opencode-cli-signed-windows
path: packages/opencode/dist 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 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: needs.version.outputs.release if: needs.version.outputs.release
with: with:
pattern: latest-yml-* pattern: latest-yml-*
path: /tmp/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 - name: Setup git committer
id: committer id: committer
uses: ./.github/actions/setup-git-committer uses: ./.github/actions/setup-git-committer
@@ -494,19 +478,6 @@ jobs:
git config --global user.name "opencode" git config --global user.name "opencode"
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true 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 - run: ./script/publish.ts
env: env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
+35
View File
@@ -0,0 +1,35 @@
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 }}
+1 -2
View File
@@ -64,8 +64,7 @@ jobs:
turbo-${{ runner.os }}- turbo-${{ runner.os }}-
- name: Run unit tests - name: Run unit tests
timeout-minutes: 20 run: bun turbo test:ci
run: bun turbo test:ci --log-order=stream --log-prefix=task
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
-2
View File
@@ -15,8 +15,6 @@ ts-dist
.turbo .turbo
**/.serena **/.serena
.serena/ .serena/
**/.omo
.omo/
/result /result
refs refs
Session.vim Session.vim
+1 -1
View File
@@ -1,6 +1,6 @@
--- ---
description: translate English to other languages description: translate English to other languages
model: opencode/claude-opus-4-8 model: opencode/claude-opus-4-7
--- ---
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time. run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
-3
View File
@@ -2,9 +2,6 @@
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"provider": {}, "provider": {},
"permission": {}, "permission": {},
"reference": {
"effect": "github.com/Effect-TS/effect-smol",
},
"mcp": {}, "mcp": {},
"tools": { "tools": {
"github-triage": false, "github-triage": false,
@@ -0,0 +1,37 @@
# Deepening
How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**.
## Dependency categories
When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam.
### 1. In-process
Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed.
### 2. Local-substitutable
Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface.
### 3. Remote but owned (Ports & Adapters)
Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter.
Recommendation shape: _"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."_
### 4. True external (Mock)
Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter.
## Seam discipline
- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection.
- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them.
## Testing strategy: replace, don't layer
- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them.
- Write new tests at the deepened module's interface. The **interface is the test surface**.
- Tests assert on observable outcomes through the interface, not internal state.
- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface.
@@ -0,0 +1,44 @@
# Interface Design
When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best.
Uses the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**, **leverage**.
## Process
### 1. Frame the problem space
Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate:
- The constraints any new interface would need to satisfy
- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md))
- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete
Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel.
### 2. Spawn sub-agents
Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module.
Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint:
- Agent 1: "Minimize the interface — aim for 13 entry points max. Maximise leverage per entry point."
- Agent 2: "Maximise flexibility — support many use cases and extension."
- Agent 3: "Optimise for the most common caller — make the default case trivial."
- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies."
Include both [LANGUAGE.md](LANGUAGE.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language.
Each sub-agent outputs:
1. Interface (types, methods, params — plus invariants, ordering, error modes)
2. Usage example showing how callers use it
3. What the implementation hides behind the seam
4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md))
5. Trade-offs — where leverage is high, where it's thin
### 3. Present and compare
Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**.
After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu.
@@ -0,0 +1,53 @@
# Language
Shared vocabulary for every suggestion this skill makes. Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point.
## Terms
**Module**
Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, class, package, or tier-spanning slice.
_Avoid_: unit, component, service.
**Interface**
Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics.
_Avoid_: API, signature (too narrow — those refer only to the type-level surface).
**Implementation**
What's inside a module — its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise.
**Depth**
Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation.
**Seam** _(from Michael Feathers)_
A place where you can alter behaviour without editing in that place. The _location_ at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it.
_Avoid_: boundary (overloaded with DDD's bounded context).
**Adapter**
A concrete thing that satisfies an interface at a seam. Describes _role_ (what slot it fills), not substance (what's inside).
**Leverage**
What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests.
**Locality**
What maintainers get from depth. Change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere.
## Principles
- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface.
- **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep.
- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test _past_ the interface, the module is probably the wrong shape.
- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it.
## Relationships
- A **Module** has exactly one **Interface** (the surface it presents to callers and tests).
- **Depth** is a property of a **Module**, measured against its **Interface**.
- A **Seam** is where a **Module**'s **Interface** lives.
- An **Adapter** sits at a **Seam** and satisfies the **Interface**.
- **Depth** produces **Leverage** for callers and **Locality** for maintainers.
## Rejected framings
- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead.
- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know.
- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**.
@@ -0,0 +1,71 @@
---
name: improve-codebase-architecture
description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable.
---
# Improve Codebase Architecture
Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability.
## Glossary
Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md).
- **Module** — anything with an interface and an implementation (function, class, package, slice).
- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature.
- **Implementation** — the code inside.
- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. **Deep** = high leverage. **Shallow** = interface nearly as complex as the implementation.
- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. (Use this, not "boundary.")
- **Adapter** — a concrete thing satisfying an interface at a seam.
- **Leverage** — what callers get from depth.
- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place.
Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list):
- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep.
- **The interface is the test surface.**
- **One adapter = hypothetical seam. Two adapters = real seam.**
This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate.
## Process
### 1. Explore
Read the project's domain glossary and any ADRs in the area you're touching first.
Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction:
- Where does understanding one concept require bouncing between many small modules?
- Where are modules **shallow** — interface nearly as complex as the implementation?
- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)?
- Where do tightly-coupled modules leak across their seams?
- Which parts of the codebase are untested, or hard to test through their current interface?
Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want.
### 2. Present candidates
Present a numbered list of deepening opportunities. For each candidate:
- **Files** — which files/modules are involved
- **Problem** — why the current architecture is causing friction
- **Solution** — plain English description of what would change
- **Benefits** — explained in terms of locality and leverage, and also in how tests would improve
**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service."
**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly (e.g. _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids.
Do NOT propose interfaces yet. Ask the user: "Which of these would you like to explore?"
### 3. Grilling loop
Once the user picks a candidate, drop into a grilling conversation. Walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive.
Side effects happen inline as decisions crystallize:
- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist.
- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there.
- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md).
- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md).
+2 -19
View File
@@ -1,6 +1,8 @@
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`. - To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
- The default branch in this repo is `dev`. - The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
## Commits and PR Titles ## Commits and PR Titles
@@ -47,13 +49,6 @@ obj.b
const { a, b } = obj const { a, b } = obj
``` ```
### Imports
- Never alias imports. Do not use `import { foo as bar } from "..."` or renamed imports like `resolve as pathResolve`.
- Never use star imports. Do not use `import * as Foo from "..."` or `import type * as Foo from "..."`.
- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode-ai/core/project"`, then reference `Project.ID`.
- Prefer dynamic imports for heavy modules that are only needed in selected code paths, especially in startup-sensitive entrypoints. Destructure dynamic import bindings near the top of the narrowest scope that needs them so they read like normal imports. Avoid inline chains such as `await import("./module").then((mod) => mod.value())` or `(await import("./module")).value()`. Keep branch-specific imports inside the branch that needs them to preserve lazy loading.
### Variables ### Variables
Prefer `const` over `let`. Use ternaries or early returns instead of reassignment. Prefer `const` over `let`. Use ternaries or early returns instead of reassignment.
@@ -138,15 +133,3 @@ const table = sqliteTable("session", {
## Type Checking ## Type Checking
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly. - 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. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work.
- Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
-124
View File
@@ -1,124 +0,0 @@
# 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.
**Model Tool Output**:
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
**Managed Tool Output File**:
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
**Model Request Options**:
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
_Avoid_: Request body, wire options
**Generation Controls**:
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
## Relationships
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
- **Session History** contains projected conversational messages and admitted **Mid-Conversation System Messages**; the active **Baseline System Context** remains separate provider-request state.
- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Provider-Turn Boundary**.
- A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state.
- A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model.
- 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.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
- A truncated **Model Tool Output** identifies its complete text both in the bounded model-visible preview and as a typed managed output path. Managed output paths do not modify the tool's validated structured result.
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
- Failure to retain a **Managed Tool Output File** does not change a successful tool operation into a failed one. The Session records an explicitly lossy bounded output without a path, while operators receive diagnostics for the storage failure.
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
## Example dialogue
> **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.
+1059 -1165
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true exact = true
# Only install newly resolved package versions published at least 3 days ago. # Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200 minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64"] 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"]
[test] [test]
root = "./do-not-run-tests-from-root" root = "./do-not-run-tests-from-root"
+2 -22
View File
@@ -663,15 +663,8 @@ async function configureGit(appToken: string) {
await $`git config --local --unset-all ${config}` await $`git config --local --unset-all ${config}`
await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"` await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"`
} await $`git config --global user.name "opencode-agent[bot]"`
await $`git config --global user.email "opencode-agent[bot]@users.noreply.github.com"`
async function assertGitIdentityConfigured() {
const name = (await $`git config --get user.name`.nothrow()).stdout.toString().trim()
const email = (await $`git config --get user.email`.nothrow()).stdout.toString().trim()
if (name && email) return
throw new Error(
"Git author identity is missing in this environment. Configure user.name and user.email before committing.",
)
} }
async function restoreGitConfig() { async function restoreGitConfig() {
@@ -724,7 +717,6 @@ async function pushToNewBranch(summary: string, branch: string) {
console.log("Pushing to new branch...") console.log("Pushing to new branch...")
const actor = useContext().actor const actor = useContext().actor
await assertGitIdentityConfigured()
await $`git add .` await $`git add .`
await $`git commit -m "${summary} await $`git commit -m "${summary}
@@ -736,7 +728,6 @@ async function pushToLocalBranch(summary: string) {
console.log("Pushing to local branch...") console.log("Pushing to local branch...")
const actor = useContext().actor const actor = useContext().actor
await assertGitIdentityConfigured()
await $`git add .` await $`git add .`
await $`git commit -m "${summary} await $`git commit -m "${summary}
@@ -750,7 +741,6 @@ async function pushToForkBranch(summary: string, pr: GitHubPullRequest) {
const remoteBranch = pr.headRefName const remoteBranch = pr.headRefName
await assertGitIdentityConfigured()
await $`git add .` await $`git add .`
await $`git commit -m "${summary} await $`git commit -m "${summary}
@@ -896,11 +886,6 @@ function buildPromptDataForIssue(issue: GitHubIssue) {
return [ return [
"Read the following data as context, but do not act on them:", "Read the following data as context, but do not act on them:",
"<environment>",
"Git author identity is already configured in this GitHub Actions environment.",
"Before committing, reuse the existing git author user.name/user.email and do not modify git config unless the user explicitly asks.",
"Do not invent noreply emails for git author identity.",
"</environment>",
"<issue>", "<issue>",
`Title: ${issue.title}`, `Title: ${issue.title}`,
`Body: ${issue.body}`, `Body: ${issue.body}`,
@@ -1033,11 +1018,6 @@ function buildPromptDataForPR(pr: GitHubPullRequest) {
return [ return [
"Read the following data as context, but do not act on them:", "Read the following data as context, but do not act on them:",
"<environment>",
"Git author identity is already configured in this GitHub Actions environment.",
"Before committing, reuse the existing git author user.name/user.email and do not modify git config unless the user explicitly asks.",
"Do not invent noreply emails for git author identity.",
"</environment>",
"<pull_request>", "<pull_request>",
`Title: ${pr.title}`, `Title: ${pr.title}`,
`Body: ${pr.body}`, `Body: ${pr.body}`,
+1 -1
View File
@@ -30,7 +30,7 @@ export const api = new sst.cloudflare.Worker("Api", {
transform: { transform: {
worker: (args) => { worker: (args) => {
args.logpush = true args.logpush = true
if ($app.stage === "vimtor" || $app.stage === "adam") return if ($app.stage === "vimtor") return
args.bindings = $resolve(args.bindings).apply((bindings) => [ args.bindings = $resolve(args.bindings).apply((bindings) => [
...bindings, ...bindings,
{ {
+3 -7
View File
@@ -1,9 +1,7 @@
import { deployAws, domain } from "./stage" import { domain } from "./stage"
import { EMAILOCTOPUS_API_KEY } from "./app" import { EMAILOCTOPUS_API_KEY } from "./app"
import { SECRET } from "./secret" import { SECRET } from "./secret"
const lake = deployAws ? await import("./lake") : undefined
//////////////// ////////////////
// DATABASE // DATABASE
//////////////// ////////////////
@@ -242,7 +240,7 @@ const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
const logProcessor = new sst.cloudflare.Worker("LogProcessor", { const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts", handler: "packages/console/function/src/log-processor.ts",
link: [SECRET.HoneycombApiKey, ...(lake?.lakeIngest ? [lake.lakeIngest] : [])], link: [new sst.Secret("HONEYCOMB_API_KEY")],
}) })
new sst.cloudflare.x.SolidStart("Console", { new sst.cloudflare.x.SolidStart("Console", {
@@ -252,8 +250,6 @@ new sst.cloudflare.x.SolidStart("Console", {
bucket, bucket,
bucketNew, bucketNew,
database, database,
SECRET.UpstashRedisRestUrl,
SECRET.UpstashRedisRestToken,
AUTH_API_URL, AUTH_API_URL,
STRIPE_WEBHOOK_SECRET, STRIPE_WEBHOOK_SECRET,
DISCORD_INCIDENT_WEBHOOK_URL, DISCORD_INCIDENT_WEBHOOK_URL,
@@ -285,7 +281,7 @@ new sst.cloudflare.x.SolidStart("Console", {
}, },
transform: { transform: {
server: { server: {
placement: { region: "aws:us-east-2" }, placement: { region: "aws:us-east-1" },
transform: { transform: {
worker: { worker: {
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }], tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
-327
View File
@@ -1,327 +0,0 @@
import { domain } from "./stage"
const current = aws.getCallerIdentityOutput({})
const partition = aws.getPartitionOutput({})
const region = aws.getRegionOutput({})
const tableBucketName = `opencode-${$app.stage}-lake`
const glueCatalogName = "s3tablescatalog"
const glueCatalogArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:catalog`
const glueS3TablesCatalogArn = $interpolate`${glueCatalogArn}/${glueCatalogName}`
const glueS3TablesChildCatalogArn = $interpolate`${glueS3TablesCatalogArn}/${tableBucketName}`
const glueS3TablesDatabaseWildcardArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/${glueCatalogName}/${tableBucketName}/*`
const glueS3TablesTableWildcardArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/${tableBucketName}/*/*`
const s3TablesBucketWildcardArn = $interpolate`arn:${partition.partition}:s3tables:${region.region}:${current.accountId}:bucket/*`
export const tableBucket = new aws.s3tables.TableBucket("LakeTableBucket", {
name: tableBucketName,
forceDestroy: $app.stage !== "production",
})
const s3TablesCatalog = new aws.cloudcontrol.Resource(
"LakeS3TablesCatalog",
{
typeName: "AWS::Glue::Catalog",
desiredState: $jsonStringify({
Name: glueCatalogName,
Description: "Federated catalog for S3 Tables",
FederatedCatalog: {
Identifier: s3TablesBucketWildcardArn,
ConnectionName: "aws:s3tables",
},
CreateDatabaseDefaultPermissions: [
{
Principal: {
DataLakePrincipalIdentifier: "IAM_ALLOWED_PRINCIPALS",
},
Permissions: ["ALL"],
},
],
CreateTableDefaultPermissions: [
{
Principal: {
DataLakePrincipalIdentifier: "IAM_ALLOWED_PRINCIPALS",
},
Permissions: ["ALL"],
},
],
AllowFullTableExternalDataAccess: "True",
}),
},
{ dependsOn: [tableBucket] },
)
const athenaResultsBucket = new aws.s3.Bucket("LakeAthenaResults", {
bucket: `opencode-${$app.stage}-lake-athena-results`,
forceDestroy: $app.stage !== "production",
})
const firehoseErrorBucket = new aws.s3.Bucket("LakeFirehoseErrors", {
bucket: `opencode-${$app.stage}-lake-firehose-errors`,
forceDestroy: $app.stage !== "production",
})
const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
name: `opencode-${$app.stage}-lake-workgroup`,
forceDestroy: $app.stage !== "production",
configuration: {
enforceWorkgroupConfiguration: true,
publishCloudwatchMetricsEnabled: true,
resultConfiguration: {
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
},
},
})
const firehoseRole = new aws.iam.Role("LakeFirehoseRole", {
assumeRolePolicy: aws.iam.getPolicyDocumentOutput({
statements: [
{
effect: "Allow",
actions: ["sts:AssumeRole"],
principals: [
{
type: "Service",
identifiers: ["firehose.amazonaws.com"],
},
],
},
],
}).json,
})
const firehosePolicy = new aws.iam.RolePolicy("LakeFirehosePolicy", {
role: firehoseRole.id,
policy: aws.iam.getPolicyDocumentOutput({
statements: [
{
effect: "Allow",
actions: [
"s3tables:ListTableBuckets",
"s3tables:GetTableBucket",
"s3tables:GetNamespace",
"s3tables:GetTable",
"s3tables:GetTableData",
"s3tables:GetTableMetadataLocation",
"s3tables:ListNamespaces",
"s3tables:ListTables",
"s3tables:PutTableData",
"s3tables:UpdateTableMetadataLocation",
],
resources: ["*"],
},
{
effect: "Allow",
actions: [
"glue:GetCatalog",
"glue:GetCatalogs",
"glue:GetDatabase",
"glue:GetDatabases",
"glue:GetTable",
"glue:GetTables",
"glue:UpdateTable",
],
resources: [
glueCatalogArn,
glueS3TablesCatalogArn,
$interpolate`${glueS3TablesCatalogArn}/*`,
glueS3TablesDatabaseWildcardArn,
glueS3TablesTableWildcardArn,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/*`,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/*/*`,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/*`,
],
},
{
effect: "Allow",
actions: [
"s3:AbortMultipartUpload",
"s3:GetBucketLocation",
"s3:GetObject",
"s3:ListBucket",
"s3:ListBucketMultipartUploads",
"s3:PutObject",
],
resources: [firehoseErrorBucket.arn, $interpolate`${firehoseErrorBucket.arn}/*`],
},
{
effect: "Allow",
actions: ["lakeformation:GetDataAccess"],
resources: ["*"],
},
],
}).json,
})
const firehose = new aws.kinesis.FirehoseDeliveryStream(
"LakeFirehose",
{
name: `opencode-${$app.stage}-lake-ingest`,
destination: "iceberg",
icebergConfiguration: {
appendOnly: true,
bufferingInterval: 60,
bufferingSize: 1,
catalogArn: glueS3TablesChildCatalogArn,
processingConfiguration: {
enabled: true,
processors: [
{
type: "MetadataExtraction",
parameters: [
{ parameterName: "JsonParsingEngine", parameterValue: "JQ-1.6" },
{
parameterName: "MetadataExtractionQuery",
parameterValue:
'{destinationDatabaseName:._lake_database,destinationTableName:._lake_table,operation:(._lake_operation // "insert")}',
},
],
},
],
},
roleArn: firehoseRole.arn,
s3BackupMode: "FailedDataOnly",
s3Configuration: {
roleArn: firehoseRole.arn,
bucketArn: firehoseErrorBucket.arn,
errorOutputPrefix: "errors/!{firehose:error-output-type}/",
},
},
},
{ dependsOn: [s3TablesCatalog, firehosePolicy] },
)
export const lakeVpc = new sst.aws.Vpc("LakeVpc")
export const lakeCluster = new sst.aws.Cluster("LakeCluster", { vpc: lakeVpc })
export const lakeRegion = region.region
export const lakeCatalog = $interpolate`${glueCatalogName}/${tableBucket.name}`
export const lakeAthenaWorkgroup = athenaWorkgroup
const ingestSecret = new random.RandomPassword("LakeIngestSecret", { length: 32 })
export const ingestSecretSsm = new aws.ssm.Parameter("LakeIngestSecretSsm", {
name: $interpolate`/${$app.name}/${$app.stage}/lake/ingest/secret`,
type: "SecureString",
value: ingestSecret.result,
})
const ingestConfig = new sst.Linkable("LakeIngestConfig", {
properties: {
streamName: firehose.name,
secret: ingestSecret.result,
},
})
const ingestService = new sst.aws.Service("LakeIngestService", {
cluster: lakeCluster,
architecture: "arm64",
cpu: "1 vCPU",
memory: "4 GB",
image: {
context: ".",
dockerfile: "packages/stats/server/Dockerfile",
},
link: [ingestConfig],
permissions: [
{
actions: ["firehose:PutRecord", "firehose:PutRecordBatch"],
resources: [firehose.arn],
},
],
scaling: {
min: $app.stage === "production" ? 2 : 1,
max: $app.stage === "production" ? 32 : 4,
cpuUtilization: 60,
memoryUtilization: 70,
},
loadBalancer: {
domain: {
name: `lake.${domain}`,
dns: sst.cloudflare.dns(),
},
rules: [
{ listen: "80/http", redirect: "443/https" },
{ listen: "443/https", forward: "3000/http" },
],
health: {
"3000/http": {
path: "/ready",
successCodes: "200-299",
},
},
},
health: {
command: [
"CMD-SHELL",
"bun --eval \"fetch('http://localhost:3000/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))\"",
],
interval: "30 seconds",
retries: 3,
startPeriod: "30 seconds",
timeout: "5 seconds",
},
dev: {
command: "bun run start",
directory: "packages/stats/server",
url: "http://localhost:3000",
},
wait: $app.stage === "production",
})
export const lakeIngest = new sst.Linkable("LakeIngest", {
properties: {
url: ingestService.url,
secret: ingestSecret.result,
},
})
export const lakeQueryPermissions = [
{
actions: ["athena:StartQueryExecution", "athena:GetQueryExecution", "athena:GetQueryResults"],
resources: [athenaWorkgroup.arn],
},
{
actions: [
"glue:GetCatalog",
"glue:GetCatalogs",
"glue:GetDatabase",
"glue:GetDatabases",
"glue:GetTable",
"glue:GetTables",
"glue:GetPartitions",
],
resources: [
glueCatalogArn,
glueS3TablesCatalogArn,
$interpolate`${glueS3TablesCatalogArn}/*`,
glueS3TablesDatabaseWildcardArn,
glueS3TablesTableWildcardArn,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/*`,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/*/*`,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/*`,
],
},
{
actions: ["s3:GetBucketLocation", "s3:ListBucket"],
resources: [athenaResultsBucket.arn],
},
{
actions: ["s3:GetObject", "s3:PutObject", "s3:AbortMultipartUpload", "s3:ListBucketMultipartUploads"],
resources: [$interpolate`${athenaResultsBucket.arn}/*`],
},
{
actions: [
"s3tables:GetTableBucket",
"s3tables:GetNamespace",
"s3tables:GetTable",
"s3tables:GetTableData",
"s3tables:GetTableMetadataLocation",
"s3tables:ListNamespaces",
"s3tables:ListTables",
],
resources: ["*"],
},
{
actions: ["lakeformation:GetDataAccess"],
resources: ["*"],
},
]
+11 -11
View File
@@ -4,7 +4,7 @@ import { domain } from "./stage"
const description = "Managed by SST (Don't edit in Honeycomb UI)" const description = "Managed by SST (Don't edit in Honeycomb UI)"
const alertsDisabled = $app.stage !== "production" const alertsDisabled = $app.stage !== "production"
const webhookRecipient = new honeycombio.WebhookRecipient("DiscordAlerts", { const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`, name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`,
url: `https://${domain}/honeycomb/webhook`, url: `https://${domain}/honeycomb/webhook`,
secret: SECRET.HoneycombWebhookSecret.result, secret: SECRET.HoneycombWebhookSecret.result,
@@ -67,7 +67,7 @@ IF(
)`, )`,
}) })
return honeycombio.getQuerySpecificationOutput({ return honeycomb.getQuerySpecificationOutput({
breakdowns: ["model"], breakdowns: ["model"],
calculatedFields: [failedHttpStatus], calculatedFields: [failedHttpStatus],
calculations: [ calculations: [
@@ -99,7 +99,7 @@ const providerHttpErrorsQuery = () => {
expression: `IF(GT($llm.error.code, "400"), 1, 0)`, expression: `IF(GT($llm.error.code, "400"), 1, 0)`,
}) })
return honeycombio.getQuerySpecificationOutput({ return honeycomb.getQuerySpecificationOutput({
breakdowns: ["provider"], breakdowns: ["provider"],
calculatedFields: [successHttpStatus, failedProviderHttpStatus], calculatedFields: [successHttpStatus, failedProviderHttpStatus],
calculations: [ calculations: [
@@ -140,7 +140,7 @@ const modelLowTpsQuery = (product: "go" | "zen") => {
{ column: "tps.output", op: "exists" }, { column: "tps.output", op: "exists" },
] ]
return honeycombio.getQuerySpecificationOutput({ return honeycomb.getQuerySpecificationOutput({
breakdowns: ["model"], breakdowns: ["model"],
calculations: [ calculations: [
{ op: "COUNT", name: "TOTAL", filterCombination: "AND", filters }, { op: "COUNT", name: "TOTAL", filterCombination: "AND", filters },
@@ -157,7 +157,7 @@ const modelLowTpsQuery = (product: "go" | "zen") => {
}).json }).json
} }
new honeycombio.Trigger("IncreasedModelHttpErrorsGo", { new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
name: "Increased Model HTTP Errors [Go]", name: "Increased Model HTTP Errors [Go]",
description, description,
disabled: alertsDisabled, disabled: alertsDisabled,
@@ -177,7 +177,7 @@ new honeycombio.Trigger("IncreasedModelHttpErrorsGo", {
], ],
}) })
new honeycombio.Trigger("IncreasedModelHttpErrorsZen", { new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
name: "Increased Model HTTP Errors [Zen]", name: "Increased Model HTTP Errors [Zen]",
description, description,
disabled: alertsDisabled, disabled: alertsDisabled,
@@ -197,7 +197,7 @@ new honeycombio.Trigger("IncreasedModelHttpErrorsZen", {
], ],
}) })
new honeycombio.Trigger("LowModelTpsGo", { new honeycomb.Trigger("LowModelTpsGo", {
name: "Low Model TPS [Go]", name: "Low Model TPS [Go]",
description, description,
disabled: alertsDisabled, disabled: alertsDisabled,
@@ -217,7 +217,7 @@ new honeycombio.Trigger("LowModelTpsGo", {
], ],
}) })
new honeycombio.Trigger("LowModelTpsZen", { new honeycomb.Trigger("LowModelTpsZen", {
name: "Low Model TPS [Zen]", name: "Low Model TPS [Zen]",
description, description,
disabled: alertsDisabled, disabled: alertsDisabled,
@@ -237,7 +237,7 @@ new honeycombio.Trigger("LowModelTpsZen", {
], ],
}) })
new honeycombio.Trigger("IncreasedProviderHttpErrors", { new honeycomb.Trigger("IncreasedProviderHttpErrors", {
name: "Increased Provider HTTP Errors", name: "Increased Provider HTTP Errors",
description, description,
disabled: alertsDisabled, disabled: alertsDisabled,
@@ -257,11 +257,11 @@ new honeycombio.Trigger("IncreasedProviderHttpErrors", {
], ],
}) })
new honeycombio.Trigger("IncreasedFreeTierRequests", { new honeycomb.Trigger("IncreasedFreeTierRequests", {
name: "Increased Free Tier Requests", name: "Increased Free Tier Requests",
description, description,
disabled: alertsDisabled, disabled: alertsDisabled,
queryJson: honeycombio.getQuerySpecificationOutput({ queryJson: honeycomb.getQuerySpecificationOutput({
calculations: [{ op: "COUNT" }], calculations: [{ op: "COUNT" }],
filters: [ filters: [
{ column: "event_type", op: "=", value: "completions" }, { column: "event_type", op: "=", value: "completions" },
-3
View File
@@ -7,8 +7,5 @@ sst.Linkable.wrap(random.RandomPassword, (resource) => ({
export const SECRET = { export const SECRET = {
R2AccessKey: new sst.Secret("R2AccessKey", "unknown"), R2AccessKey: new sst.Secret("R2AccessKey", "unknown"),
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"), R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }), HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
} }
-2
View File
@@ -5,8 +5,6 @@ export const domain = (() => {
})() })()
export const zoneID = "430ba34c138cfb5360826c4909f99be8" export const zoneID = "430ba34c138cfb5360826c4909f99be8"
export const awsStage = $app.stage === "production" ? "production" : "dev"
export const deployAws = $app.stage === awsStage
new cloudflare.RegionalHostname("RegionalHostname", { new cloudflare.RegionalHostname("RegionalHostname", {
hostname: domain, hostname: domain,
-203
View File
@@ -1,203 +0,0 @@
import { lakeAthenaWorkgroup, lakeCatalog, lakeCluster, lakeQueryPermissions, lakeRegion, tableBucket } from "./lake"
import { EMAILOCTOPUS_API_KEY } from "./app"
import { domain } from "./stage"
////////////////
// LAKE
////////////////
const inferenceNamespace = new aws.s3tables.Namespace("LakeInferenceNamespace", {
namespace: "inference",
tableBucketArn: tableBucket.arn,
})
const inferenceEventTable = new aws.s3tables.Table(
"LakeInferenceEventTable",
{
name: "event",
namespace: inferenceNamespace.namespace,
tableBucketArn: inferenceNamespace.tableBucketArn,
format: "ICEBERG",
metadata: {
iceberg: {
schema: {
fields: [
{ name: "event_timestamp", type: "string", required: false },
{ name: "event_date", type: "string", required: false },
{ name: "event_type", type: "string", required: false },
{ name: "dataset", type: "string", required: false },
{ name: "cf_continent", type: "string", required: false },
{ name: "cf_country", type: "string", required: false },
{ name: "cf_city", type: "string", required: false },
{ name: "cf_region", type: "string", required: false },
{ name: "cf_latitude", type: "double", required: false },
{ name: "cf_longitude", type: "double", required: false },
{ name: "cf_timezone", type: "string", required: false },
{ name: "duration", type: "double", required: false },
{ name: "request_length", type: "long", required: false },
{ name: "status", type: "int", required: false },
{ name: "ip", type: "string", required: false },
{ name: "is_stream", type: "boolean", required: false },
{ name: "session", type: "string", required: false },
{ name: "request", type: "string", required: false },
{ name: "client", type: "string", required: false },
{ name: "user_agent", type: "string", required: false },
{ name: "model_variant", type: "string", required: false },
{ name: "source", type: "string", required: false },
{ name: "provider", type: "string", required: false },
{ name: "provider_model", type: "string", required: false },
{ name: "model", type: "string", required: false },
{ name: "llm_error_code", type: "int", required: false },
{ name: "llm_error_message", type: "string", required: false },
{ name: "error_response", type: "string", required: false },
{ name: "error_type", type: "string", required: false },
{ name: "error_message", type: "string", required: false },
{ name: "error_cause", type: "string", required: false },
{ name: "error_cause2", type: "string", required: false },
{ name: "api_key", type: "string", required: false },
{ name: "workspace", type: "string", required: false },
{ name: "is_subscription", type: "boolean", required: false },
{ name: "subscription", type: "string", required: false },
{ name: "response_length", type: "long", required: false },
{ name: "time_to_first_byte", type: "long", required: false },
{ name: "timestamp_first_byte", type: "long", required: false },
{ name: "timestamp_last_byte", type: "long", required: false },
{ name: "tokens_input", type: "long", required: false },
{ name: "tokens_output", type: "long", required: false },
{ name: "tokens_reasoning", type: "long", required: false },
{ name: "tokens_cache_read", type: "long", required: false },
{ name: "tokens_cache_write_5m", type: "long", required: false },
{ name: "tokens_cache_write_1h", type: "long", required: false },
{ name: "cost_input_microcents", type: "long", required: false },
{ name: "cost_output_microcents", type: "long", required: false },
{ name: "cost_cache_read_microcents", type: "long", required: false },
{ name: "cost_cache_write_microcents", type: "long", required: false },
{ name: "cost_total_microcents", type: "long", required: false },
{ name: "cost_input", type: "long", required: false },
{ name: "cost_output", type: "long", required: false },
{ name: "cost_cache_read", type: "long", required: false },
{ name: "cost_cache_write_5m", type: "long", required: false },
{ name: "cost_cache_write_1h", type: "long", required: false },
{ name: "cost_total", type: "long", required: false },
],
},
},
},
},
{ deleteBeforeReplace: $app.stage !== "production" },
)
export const inferenceEvent = new sst.Linkable("InferenceEvent", {
properties: {
region: lakeRegion,
catalog: lakeCatalog,
database: inferenceNamespace.namespace,
table: inferenceEventTable.name,
tableBucket: tableBucket.name,
workgroup: lakeAthenaWorkgroup.name,
},
})
////////////////
// DATABASE
////////////////
const cluster = planetscale.getDatabaseOutput({
name: "opencode-stats",
organization: "anomalyco",
})
const branch =
$app.stage === "production"
? planetscale.getBranchOutput({
name: "production",
organization: cluster.organization,
database: cluster.name,
})
: new planetscale.Branch("StatsDatabaseBranch", {
database: cluster.name,
organization: cluster.organization,
name: $app.stage,
parentBranch: "production",
})
const password = new planetscale.Password("StatsDatabasePassword", {
name: $app.stage,
database: cluster.name,
organization: cluster.organization,
branch: branch.name,
})
const databaseUrl = $interpolate`mysql://${password.username.apply(encodeURIComponent)}:${password.plaintext.apply(
encodeURIComponent,
)}@${password.accessHostUrl}/${cluster.name}`
export const database = new sst.Linkable("StatsDatabase", {
properties: {
host: password.accessHostUrl,
database: cluster.name,
username: password.username,
password: password.plaintext,
port: 3306,
url: databaseUrl,
},
})
new sst.x.DevCommand("StatsStudio", {
link: [database],
environment: {
DATABASE_URL: databaseUrl,
},
dev: {
command: "bun db:studio",
directory: "packages/stats/core",
autostart: false,
},
})
////////////////
// APP
////////////////
export const app = new sst.cloudflare.x.SolidStart("Stats", {
path: "packages/stats/app",
buildCommand: "bun run build",
domain: `stats.${domain}`,
link: [database, EMAILOCTOPUS_API_KEY],
environment: {
PUBLIC_URL: `https://${domain}/stats`,
},
})
////////////////
// SERVICES
////////////////
const statsSyncConfig = new sst.Linkable("StatsSyncConfig", {
properties: {
dataset: "zen",
},
})
export const statSync = new sst.aws.Service("StatsSyncService", {
cluster: lakeCluster,
architecture: "arm64",
cpu: "0.25 vCPU",
memory: "0.5 GB",
image: {
context: ".",
dockerfile: "packages/stats/server/Dockerfile",
},
command: ["bun", "src/stat-sync.ts"],
link: [database, inferenceEvent, statsSyncConfig],
permissions: lakeQueryPermissions,
scaling: {
min: 1,
max: 1,
},
dev: {
command: "bun src/stat-sync.ts",
directory: "packages/stats/server",
autostart: false,
},
})
+1 -10
View File
@@ -3,7 +3,6 @@
stdenv, stdenv,
bun, bun,
nodejs, nodejs,
darwin,
electron_41, electron_41,
makeWrapper, makeWrapper,
writableTmpDirAsHomeHook, writableTmpDirAsHomeHook,
@@ -15,12 +14,7 @@ let
in in
stdenv.mkDerivation (finalAttrs: { stdenv.mkDerivation (finalAttrs: {
pname = "opencode-desktop"; pname = "opencode-desktop";
inherit (opencode) inherit (opencode) version src node_modules;
version
src
node_modules
patches
;
nativeBuildInputs = [ nativeBuildInputs = [
bun bun
@@ -29,9 +23,6 @@ stdenv.mkDerivation (finalAttrs: {
writableTmpDirAsHomeHook writableTmpDirAsHomeHook
] ++ lib.optionals stdenv.hostPlatform.isLinux [ ] ++ lib.optionals stdenv.hostPlatform.isLinux [
autoPatchelfHook autoPatchelfHook
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
darwin.autoSignDarwinBinariesHook
]; ];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-5DhbOm/gs2mfjmNYdZHkr0ZopgSC2HcGN9/r1noGqhc=", "x86_64-linux": "sha256-kCSAVPQgJROcvnnwf0Cn6PuYL25hYgTasJeBJlmnFgQ=",
"aarch64-linux": "sha256-0dIKcqKmhrPhRpabnfM20wnqt/AkoCWDMgG9cQZ8P3o=", "aarch64-linux": "sha256-prY27Ek2QhW+4OvBJ3bHHkUDoLTA4mD3KQmOQqSbAuo=",
"aarch64-darwin": "sha256-Sx3G63vORj69u2AOMDfpk6NU7fMgxsbYBh3jnGohNXI=", "aarch64-darwin": "sha256-0yIqnnjreVHTgGZLrKFpT9Cc2B2LNfmYcRByaCu7tiU=",
"x86_64-darwin": "sha256-4g2ydNayqNqWlBeQt90rUI6bY5dMvXP4OmQ6QWaJbuI=" "x86_64-darwin": "sha256-n+urvMRozB9nO5D3qyCweSa5HExFk1YGEzOt2445LEE="
} }
} }
+12 -25
View File
@@ -10,41 +10,35 @@
"dev:desktop": "bun --cwd packages/desktop dev", "dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev", "dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook", "dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint", "lint": "oxlint",
"changeset": "changeset",
"version:http-recorder": "changeset version",
"release:http-recorder": "bun ./packages/http-recorder/script/publish.ts",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
"upgrade-opentui": "bun run script/upgrade-opentui.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty", "postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky", "prepare": "husky",
"random": "echo 'Random script'", "random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser", "hello": "echo 'Hello World!'",
"test": "echo 'do not run tests from root' && exit 1" "test": "echo 'do not run tests from root' && exit 1"
}, },
"workspaces": { "workspaces": {
"packages": [ "packages": [
"packages/*", "packages/*",
"packages/console/*", "packages/console/*",
"packages/stats/*",
"packages/sdk/js", "packages/sdk/js",
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@effect/opentelemetry": "4.0.0-beta.74", "@effect/opentelemetry": "4.0.0-beta.66",
"@effect/platform-node": "4.0.0-beta.74", "@effect/platform-node": "4.0.0-beta.66",
"@effect/sql-sqlite-bun": "4.0.0-beta.74", "@effect/sql-sqlite-bun": "4.0.0-beta.66",
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13", "@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6", "@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.3.2", "@opentui/core": "0.2.15",
"@opentui/keymap": "0.3.2", "@opentui/keymap": "0.2.15",
"@opentui/solid": "0.3.2", "@opentui/solid": "0.2.15",
"ulid": "3.0.1", "ulid": "3.0.1",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1", "@types/luxon": "3.7.1",
@@ -62,7 +56,7 @@
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2", "drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2", "drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.74", "effect": "4.0.0-beta.66",
"ai": "6.0.168", "ai": "6.0.168",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"hono": "4.10.7", "hono": "4.10.7",
@@ -78,7 +72,6 @@
"@typescript/native-preview": "7.0.0-dev.20251207.1", "@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8", "zod": "4.1.8",
"remeda": "2.26.0", "remeda": "2.26.0",
"sst": "4.13.1",
"shiki": "3.20.0", "shiki": "3.20.0",
"solid-list": "0.3.0", "solid-list": "0.3.0",
"tailwindcss": "4.1.11", "tailwindcss": "4.1.11",
@@ -91,12 +84,11 @@
"@sentry/vite-plugin": "4.6.0", "@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.10", "solid-js": "1.9.10",
"vite-plugin-solid": "2.11.10", "vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.12" "@lydell/node-pty": "1.2.0-beta.10"
} }
}, },
"devDependencies": { "devDependencies": {
"@actions/artifact": "5.0.1", "@actions/artifact": "5.0.1",
"@changesets/cli": "2.31.0",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1", "@types/mime-types": "3.0.1",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
@@ -106,7 +98,7 @@
"oxlint-tsgolint": "0.21.0", "oxlint-tsgolint": "0.21.0",
"prettier": "3.6.2", "prettier": "3.6.2",
"semver": "^7.6.0", "semver": "^7.6.0",
"sst": "catalog:", "sst": "3.18.10",
"turbo": "2.8.13" "turbo": "2.8.13"
}, },
"dependencies": { "dependencies": {
@@ -147,11 +139,6 @@
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch"
} }
} }
@@ -1,87 +0,0 @@
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,41 +0,0 @@
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, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
let releasePath!: () => void
const pathBlocked = new Promise<void>((resolve) => {
releasePath = resolve
})
await page.route("**/path?*", async (route) => {
if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback()
await pathBlocked
return route.fallback()
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
try {
await expectAppVisible(page.getByText(fixture.expected.sourceTitle).first())
} finally {
releasePath()
}
})
@@ -1,353 +0,0 @@
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"
const sessionID = "ses_timeline_state_regression"
const userMessageID = "msg_user_regression"
const assistantMessageID = "msg_assistant_regression"
const editPartID = "prt_0001_edit"
const textPartID = "prt_9999_text"
const title = "Timeline collapse state regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type EventPayload = {
directory: string
payload: Record<string, unknown>
}
declare global {
interface Window {
__timelineDiffProbe: {
reset: () => void
shadowRoots: () => number
}
}
}
const userMessage = {
info: {
id: userMessageID,
sessionID,
role: "user",
time: { created: 1700000000000 },
summary: { diffs: [] },
agent: "build",
model,
},
parts: [
{
id: "prt_user_text",
sessionID,
messageID: userMessageID,
type: "text",
text: "Please edit the file.",
},
],
}
const editPart = {
id: editPartID,
sessionID,
messageID: assistantMessageID,
type: "tool",
callID: "call_edit_regression",
tool: "edit",
state: {
status: "completed",
input: { filePath: "src/regression.ts" },
output: "Edited src/regression.ts",
title: "src/regression.ts",
metadata: {
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: "export const value = 'before'\n",
after: "export const value = 'after'\n",
},
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
},
time: { start: 1700000001000, end: 1700000002000 },
},
}
const streamedTextPart = {
id: textPartID,
sessionID,
messageID: assistantMessageID,
type: "text",
text: "Streaming added a later assistant text part.",
}
const assistantMessage = {
info: {
id: assistantMessageID,
sessionID,
role: "assistant",
time: { created: 1700000001000 },
parentID: userMessageID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
},
parts: [editPart],
}
test.describe("regression: session timeline local row state", () => {
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
const events: EventPayload[] = []
await mockServer(page, events)
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
await expectAppVisible(wrapper)
await expectExpanded(wrapper, true)
await wrapper.evaluate((element) => {
;(element as HTMLElement).dataset.regressionMarker = "before-stream"
})
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
await expectExpanded(wrapper, false)
events.push({
directory,
payload: {
type: "message.part.updated",
properties: { part: streamedTextPart },
},
})
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
expect(await readToolState(page)).toEqual({
expanded: false,
row: "AssistantPart",
streamedTextVisible: true,
})
})
test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => {
const events: EventPayload[] = []
await installDiffProbe(page)
await mockServer(page, events)
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
await expectAppVisible(wrapper)
await expectAppVisible(wrapper.locator('[data-component="file"][data-mode="diff"]').first())
await markDiffProbe(page)
events.push({
directory,
payload: {
type: "message.part.updated",
properties: { part: streamedTextPart },
},
})
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
await markDiffProbe(page)
events.push({
directory,
payload: {
type: "message.part.updated",
properties: { part: editPartWithAdditions(2) },
},
})
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
{ timeout: 10_000 },
)
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
})
})
async function configurePage(page: Page) {
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
})
}
async function expectExpanded(locator: Locator, expected: boolean) {
await expect.poll(() => locator.evaluate(readExpanded)).toBe(expected)
}
async function readToolState(page: Page) {
return page
.locator(`[data-timeline-part-id="${editPartID}"]`)
.first()
.evaluate(
(element, textPartID) => ({
expanded: (() => {
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
const aria = trigger?.getAttribute("aria-expanded")
if (aria === "true") return true
if (aria === "false") return false
const root = element.querySelector('[data-component="collapsible"]')
if (root?.hasAttribute("data-expanded")) return true
if (root?.hasAttribute("data-closed")) return false
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
return !!content && content.getBoundingClientRect().height > 0
})(),
row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
}),
textPartID,
)
}
async function installDiffProbe(page: Page) {
await page.addInitScript(() => {
let shadowRootCount = 0
const attachShadow = Element.prototype.attachShadow
Element.prototype.attachShadow = function (init) {
shadowRootCount += 1
return attachShadow.call(this, init)
}
window.__timelineDiffProbe = {
reset: () => {
shadowRootCount = 0
},
shadowRoots: () => shadowRootCount,
}
})
}
async function markDiffProbe(page: Page) {
await page
.locator(`[data-timeline-part-id="${editPartID}"]`)
.first()
.evaluate((element) => {
const tool = element as HTMLElement
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
if (!file) throw new Error("missing edit diff file")
tool.dataset.timelineProbe = "before"
file.dataset.timelineProbe = "before"
window.__timelineDiffProbe.reset()
})
}
async function readDiffProbe(page: Page) {
return page
.locator(`[data-timeline-part-id="${editPartID}"]`)
.first()
.evaluate((element) => {
const tool = element as HTMLElement
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
return {
fileMarker: file?.dataset.timelineProbe,
shadowRoots: window.__timelineDiffProbe.shadowRoots(),
toolMarker: tool.dataset.timelineProbe,
}
})
}
function editPartWithAdditions(additions: number) {
return {
...editPart,
state: {
...editPart.state,
metadata: {
...editPart.state.metadata,
filediff: {
...editPart.state.metadata.filediff,
additions,
},
},
},
}
}
function readExpanded(element: Element) {
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
const aria = trigger?.getAttribute("aria-expanded")
if (aria === "true") return true
if (aria === "false") return false
const root = element.querySelector('[data-component="collapsible"]')
if (root?.hasAttribute("data-expanded")) return true
if (root?.hasAttribute("data-closed")) return false
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
return !!content && content.getBoundingClientRect().height > 0
}
async function mockServer(page: Page, events: EventPayload[]) {
await mockOpenCodeServer(page, {
directory,
project: project(),
provider: provider(),
sessions: [session()],
pageMessages: () => ({ items: [userMessage, assistantMessage] }),
events: () => events.splice(0),
})
}
function project() {
return {
id: projectID,
worktree: directory,
vcs: "git",
name: "timeline-state-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
}
}
function session() {
return {
id: sessionID,
slug: "timeline-state-regression",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
}
}
function provider() {
return {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
@@ -1,268 +0,0 @@
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"
const sessionID = "ses_context_resize_regression"
const title = "Context resize regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
const followingTextID = "prt_0104_text"
type Message = {
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
parts: Record<string, unknown>[]
}
const messages = [...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), ...turn(10, true)]
test.describe("regression: session timeline context group resize", () => {
test("remeasures a recent explored context group before the next paint", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await mockServer(page)
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
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)
const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
console.log("context resize samples", JSON.stringify(samples, null, 2))
expect(samples[0]?.overlap).toBe(0)
expect(visibleOverlap).toEqual([])
expect(samples.at(-1)?.expanded).toBe("true")
})
})
async function configurePage(page: Page) {
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
})
}
async function sampleExpansion(page: Page) {
return page.evaluate(
({ contextIDs, followingTextID }) =>
new Promise<
{
frame: number
label: string
scrollTop: number
scrollHeight: number
contextBottom: number
textTop: number
overlap: number
gap: number
expanded: string | null
}[]
>((resolve) => {
const context = document.querySelector<HTMLElement>(`[data-timeline-part-ids="${contextIDs.join(",")}"]`)
const text = document.querySelector<HTMLElement>(`[data-timeline-part-id="${followingTextID}"]`)
const scroller = context?.closest<HTMLElement>(".scroll-view__viewport")
const trigger = context?.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')
const contextRow = context?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
const textRow = text?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
if (!context || !text || !scroller || !trigger || !contextRow || !textRow)
throw new Error("missing regression nodes")
scroller.scrollTop = scroller.scrollHeight
const samples: {
frame: number
label: string
scrollTop: number
scrollHeight: number
contextBottom: number
textTop: number
overlap: number
gap: number
expanded: string | null
}[] = []
const capture = (frame: number, label: string) => {
const contextRect = contextRow.getBoundingClientRect()
const textRect = textRow.getBoundingClientRect()
samples.push({
frame,
label,
scrollTop: Math.round(scroller.scrollTop * 10) / 10,
scrollHeight: Math.round(scroller.scrollHeight * 10) / 10,
contextBottom: Math.round(contextRect.bottom * 10) / 10,
textTop: Math.round(textRect.top * 10) / 10,
overlap: Math.max(0, Math.round((contextRect.bottom - textRect.top) * 10) / 10),
gap: Math.max(0, Math.round((textRect.top - contextRect.bottom) * 10) / 10),
expanded: trigger.getAttribute("aria-expanded"),
})
}
capture(-1, "before")
trigger.click()
capture(0, "sync-after-click")
let frame = 1
const tick = () => {
capture(frame, "raf")
frame += 1
if (frame > 8) {
resolve(samples)
return
}
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}),
{ contextIDs, followingTextID },
)
}
function turn(index: number, target: boolean): Message[] {
const userID = id("msg_user", index)
const assistantID = id("msg_assistant", index)
return [
{
info: {
id: userID,
sessionID,
role: "user",
time: { created: 1700000000000 + index * 10_000 },
summary: { diffs: [] },
agent: "build",
model,
},
parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }],
},
{
info: {
id: assistantID,
sessionID,
role: "assistant",
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
parentID: userID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
finish: "stop",
},
parts: target
? [
contextTool(contextIDs[0]!, assistantID, "read", { filePath: "src/recent-a.ts", offset: 0, limit: 120 }),
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }),
contextTool(contextIDs[2]!, assistantID, "grep", { path: directory, pattern: "Explored", include: "*.ts" }),
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }),
{
id: followingTextID,
sessionID,
messageID: assistantID,
type: "text",
text: "This assistant text is immediately after the explored context group.",
},
]
: [
{
id: id("prt_text", index),
sessionID,
messageID: assistantID,
type: "text",
text: `Assistant filler ${index}. ${"filler ".repeat(60)}`,
},
],
},
]
}
function contextTool(partID: string, messageID: string, tool: string, input: Record<string, unknown>) {
return {
id: partID,
sessionID,
messageID,
type: "tool",
callID: `call_${partID}`,
tool,
state: {
status: "completed",
input,
output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
title: input.filePath || input.path || input.pattern || "completed",
metadata: {},
time: { start: 1700000000000, end: 1700000000100 },
},
}
}
async function mockServer(page: Page) {
await mockOpenCodeServer(page, {
directory,
project: project(),
provider: provider(),
sessions: [session()],
pageMessages: () => ({ items: messages }),
})
}
async function settle(page: Page) {
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))
}
function id(prefix: string, index: number) {
return `${prefix}_${String(index).padStart(4, "0")}`
}
function project() {
return {
id: projectID,
worktree: directory,
vcs: "git",
name: "context-resize-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
}
}
function session() {
return {
id: sessionID,
slug: "context-resize-regression",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
}
}
function provider() {
return {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
+19 -39
View File
@@ -1,9 +1,7 @@
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { fixture, pageMessages } from "./session-timeline.fixture" import { fixture, pageMessages } from "./session-timeline.fixture"
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors" import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { APP_READY_TIMEOUT, expectAppVisible, expectSessionTitle } from "../utils/waits"
const forbiddenText = ["Load details", "Show earlier steps"] const forbiddenText = ["Load details", "Show earlier steps"]
@@ -39,12 +37,12 @@ test.describe("smoke: session timeline", () => {
project: fixture.project, project: fixture.project,
pageMessages, pageMessages,
}) })
await configureSmokePage(page, fixture.directory) await configureSmokePage(page)
await selectHomeProject(page, fixture.project.name) await openProject(page, "SmokeProject")
await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle) await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle)
await expectSessionReady(page) await expectSessionReady(page, "smoke-project")
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
const expectedPartIDs = fixture.expected.targetPartIDs const expectedPartIDs = fixture.expected.targetPartIDs
const expectedMessageIDs = fixture.expected.targetMessageIDs const expectedMessageIDs = fixture.expected.targetMessageIDs
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors) await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
@@ -52,7 +50,7 @@ test.describe("smoke: session timeline", () => {
}) })
}) })
async function configureSmokePage(page: Page, directory: string) { async function configureSmokePage(page: Page) {
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem( localStorage.setItem(
"settings.v3", "settings.v3",
@@ -65,23 +63,7 @@ async function configureSmokePage(page: Page, directory: string) {
}, },
}), }),
) )
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: {
local: [{ worktree: directory, expanded: true }],
},
lastProject: {
local: directory,
},
}),
)
}, directory)
await page.addInitScript(() => {
const smoke = window as SmokeWindow const smoke = window as SmokeWindow
smoke.__timelineSmokeErrorToasts = [] smoke.__timelineSmokeErrorToasts = []
smoke.__timelineSmokeForbiddenText = [] smoke.__timelineSmokeForbiddenText = []
@@ -410,23 +392,21 @@ function expectCompleteScroll(
expect(expectedPartIDs.length).toBe(331) expect(expectedPartIDs.length).toBe(331)
} }
async function selectHomeProject(page: Page, projectName: string) { async function openProject(page: Page, projectName: string) {
await page.goto("/") await page.goto("/")
const row = page await page.getByRole("button", { name: new RegExp(projectName, "i") }).click()
.locator('[data-component="home-project-row"]') }
.filter({ hasText: new RegExp(projectName, "i") })
async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) {
// Use evaluate to click to avoid strict visibility/animation issues during rapid e2e navigation
await page
.locator(`a[href*="${sessionId}"]`)
.first() .first()
await expectAppVisible(row) .evaluate((el) => (el as HTMLElement).click())
await row.click() await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
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) { async function expectSessionReady(page: Page, projectName: string) {
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`) await expect(page.getByText(projectName).first()).toBeVisible()
await expectSessionTitle(page, expectedTitle) await expect(page.getByText("Ask anything...")).toBeVisible()
}
async function expectSessionReady(page: Page) {
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
} }
+3 -9
View File
@@ -18,7 +18,6 @@ export interface MockServerConfig {
project: unknown project: unknown
sessions: ({ id: string } & Record<string, unknown>)[] sessions: ({ id: string } & Record<string, unknown>)[]
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string } pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
events?: () => unknown[]
} }
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
@@ -44,8 +43,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (url.port !== targetPort) return route.fallback() if (url.port !== targetPort) return route.fallback()
const path = url.pathname const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.()) if (path === "/global/event" || path === "/event") return sse(route)
if (path === "/global/health") return json(route, { healthy: true })
if (emptyObject.has(path)) return json(route, {}) if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, []) if (emptyList.has(path)) return json(route, [])
if (path in staticRoutes) return json(route, staticRoutes[path]) if (path in staticRoutes) return json(route, staticRoutes[path])
@@ -83,10 +81,6 @@ function json(route: Route, body: unknown, headers?: Record<string, string>) {
}) })
} }
function sse(route: Route, events?: unknown[]) { function sse(route: Route) {
return route.fulfill({ return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
status: 200,
contentType: "text/event-stream",
body: events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n",
})
} }
-11
View File
@@ -1,11 +0,0 @@
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 }))
}
+3 -5
View File
@@ -1,12 +1,11 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.16.2", "version": "1.15.6",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./desktop-menu": "./src/desktop-menu.ts", "./desktop-menu": "./src/desktop-menu.ts",
"./wsl/types": "./src/wsl/types.ts",
"./vite": "./vite.js", "./vite": "./vite.js",
"./index.css": "./src/index.css" "./index.css": "./src/index.css"
}, },
@@ -43,10 +42,10 @@
}, },
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@sentry/solid": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:", "@opencode-ai/core": "workspace:*",
"@shikijs/transformers": "3.9.2", "@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3", "@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2", "@solid-primitives/audio": "1.4.2",
@@ -55,7 +54,6 @@
"@solid-primitives/i18n": "2.2.1", "@solid-primitives/i18n": "2.2.1",
"@solid-primitives/media": "2.3.3", "@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5", "@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",
"@solid-primitives/scroll": "2.1.3", "@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4", "@solid-primitives/timer": "1.4.4",
Binary file not shown.
+39 -57
View File
@@ -14,7 +14,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect" import { Effect } from "effect"
import { import {
type Component, type Component,
createEffect,
createMemo, createMemo,
createResource, createResource,
createSignal, createSignal,
@@ -25,14 +24,14 @@ import {
onCleanup, onCleanup,
type ParentProps, type ParentProps,
Show, Show,
Suspense,
} from "solid-js" } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command" import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments" import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk" import { GlobalSDKProvider } from "@/context/global-sdk"
import { ServerSyncProvider } from "@/context/server-sync" import { GlobalSyncProvider } from "@/context/global-sync"
import { GlobalProvider } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights" import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout" import { LayoutProvider } from "@/context/layout"
@@ -41,27 +40,30 @@ import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission" import { PermissionProvider } from "@/context/permission"
import { PromptProvider } from "@/context/prompt" import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider, useSettings } from "@/context/settings" import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal" import { TerminalProvider } from "@/context/terminal"
import { TabsProvider } from "@/context/tabs"
import { WslServersProvider } from "@/wsl/context"
import DirectoryLayout from "@/pages/directory-layout" import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout" import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" import { useCheckServerHealth } from "./utils/server-health"
const HomeRoute = lazy(() => import("@/pages/home")) const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const loadSession = () => import("@/pages/session")
const Session = lazy(loadSession)
const Loading = () => <div class="size-full" />
const SessionRoute = Object.assign( if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
() => ( void loadSession()
}
const SessionRoute = () => (
<SessionProviders> <SessionProviders>
<Session /> <Session />
</SessionProviders> </SessionProviders>
),
{ preload: Session.preload },
) )
const SessionIndexRoute = () => <Navigate href="session" />
function UiI18nBridge(props: ParentProps) { function UiI18nBridge(props: ParentProps) {
const language = useLanguage() const language = useLanguage()
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider> return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
@@ -72,10 +74,10 @@ declare global {
__OPENCODE__?: { __OPENCODE__?: {
updaterEnabled?: boolean updaterEnabled?: boolean
deepLinks?: string[] deepLinks?: string[]
wsl?: boolean
} }
api?: { api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void> setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
exportDebugLogs?: () => Promise<string>
} }
} }
} }
@@ -93,26 +95,9 @@ function QueryProvider(props: ParentProps) {
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider> return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
} }
function BodyDesignClass() {
const settings = useSettings()
createEffect(() => {
if (typeof document === "undefined") return
const enabled = settings.general.newLayoutDesigns()
document.body.classList.toggle("text-12-regular", !enabled)
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
document.body.classList.toggle("text-[13px]", enabled)
document.body.classList.toggle("font-[440]", enabled)
})
return null
}
function AppShellProviders(props: ParentProps) { function AppShellProviders(props: ParentProps) {
return ( return (
<SettingsProvider> <SettingsProvider>
<BodyDesignClass />
<PermissionProvider> <PermissionProvider>
<LayoutProvider> <LayoutProvider>
<NotificationProvider> <NotificationProvider>
@@ -171,13 +156,11 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
}} }}
> >
<QueryProvider> <QueryProvider>
<WslServersProvider>
<DialogProvider> <DialogProvider>
<MarkedProvider> <MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider> <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider> </MarkedProvider>
</DialogProvider> </DialogProvider>
</WslServersProvider>
</QueryProvider> </QueryProvider>
</ErrorBoundary> </ErrorBoundary>
</UiI18nBridge> </UiI18nBridge>
@@ -213,21 +196,26 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
Effect.runPromise, Effect.runPromise,
), ),
) )
const checking = createMemo(
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
)
return ( return (
<Show <Suspense
when={!checking()}
fallback={ fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base"> <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" /> <Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div> </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 <Show
when={startupHealthCheck.latest} when={startupHealthCheck()}
fallback={ fallback={
<ConnectionError <ConnectionError
onRetry={() => { onRetry={() => {
@@ -243,7 +231,8 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
> >
{props.children} {props.children}
</Show> </Show>
</Show> {/*</Show>*/}
</Suspense>
) )
} }
@@ -306,7 +295,6 @@ function ServerKey(props: ParentProps) {
export function AppInterface(props: { export function AppInterface(props: {
children?: JSX.Element children?: JSX.Element
defaultServer: ServerConnection.Key defaultServer: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any> servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps> router?: Component<BaseRouterProps>
disableHealthCheck?: boolean disableHealthCheck?: boolean
@@ -314,35 +302,29 @@ export function AppInterface(props: {
return ( return (
<ServerProvider <ServerProvider
defaultServer={props.defaultServer} defaultServer={props.defaultServer}
canonicalLocalServer={props.canonicalLocalServer} disableHealthCheck={props.disableHealthCheck}
servers={props.servers} servers={props.servers}
> >
<GlobalProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}> <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<ServerKey> <ServerKey>
<QueryProvider> <QueryProvider>
<ServerSDKProvider> <GlobalSDKProvider>
<ServerSyncProvider> <GlobalSyncProvider>
<RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot> <Dynamic
</ServerSyncProvider> component={props.router ?? Router}
</ServerSDKProvider> root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
</QueryProvider>
</ServerKey>
</TabsProvider>
)}
> >
<Route path="/" component={HomeRoute} /> <Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}> <Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} /> <Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} /> <Route path="/session/:id?" component={SessionRoute} />
</Route> </Route>
</Dynamic> </Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
</QueryProvider>
</ServerKey>
</ConnectionGate> </ConnectionGate>
</GlobalProvider>
</ServerProvider> </ServerProvider>
) )
} }
@@ -8,19 +8,19 @@ import { List, type ListRef } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast" import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useServerSync } from "@/context/server-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
export function DialogConnectProvider(props: { provider: string }) { export function DialogConnectProvider(props: { provider: string }) {
const dialog = useDialog() const dialog = useDialog()
const serverSync = useServerSync() const globalSync = useGlobalSync()
const serverSDK = useServerSDK() const globalSDK = useGlobalSDK()
const language = useLanguage() const language = useLanguage()
const providers = useProviders() const providers = useProviders()
@@ -41,7 +41,9 @@ export function DialogConnectProvider(props: { provider: string }) {
}) })
const provider = createMemo( const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync.data.provider.all.get(props.provider)!, () =>
providers.all().find((x) => x.id === props.provider) ??
globalSync.data.provider.all.find((x) => x.id === props.provider)!,
) )
const fallback = createMemo<ProviderAuthMethod[]>(() => [ const fallback = createMemo<ProviderAuthMethod[]>(() => [
{ {
@@ -52,16 +54,16 @@ export function DialogConnectProvider(props: { provider: string }) {
const [auth] = createResource( const [auth] = createResource(
() => props.provider, () => props.provider,
async () => { async () => {
const cached = serverSync.data.provider_auth[props.provider] const cached = globalSync.data.provider_auth[props.provider]
if (cached) return cached if (cached) return cached
const res = await serverSDK.client.provider.auth() const res = await globalSDK.client.provider.auth()
if (!alive.value) return fallback() if (!alive.value) return fallback()
serverSync.set("provider_auth", res.data ?? {}) globalSync.set("provider_auth", res.data ?? {})
return res.data?.[props.provider] ?? fallback() return res.data?.[props.provider] ?? fallback()
}, },
) )
const loading = createMemo(() => auth.loading && !serverSync.data.provider_auth[props.provider]) const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? serverSync.data.provider_auth[props.provider] ?? fallback()) const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
const [store, setStore] = createStore({ const [store, setStore] = createStore({
methodIndex: undefined as undefined | number, methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization, authorization: undefined as undefined | ProviderAuthAuthorization,
@@ -158,7 +160,7 @@ export function DialogConnectProvider(props: { provider: string }) {
} }
dispatch({ type: "auth.pending" }) dispatch({ type: "auth.pending" })
const start = Date.now() const start = Date.now()
await serverSDK.client.provider.oauth await globalSDK.client.provider.oauth
.authorize( .authorize(
{ {
providerID: props.provider, providerID: props.provider,
@@ -277,7 +279,6 @@ export function DialogConnectProvider(props: { provider: string }) {
<div class="text-14-regular text-text-base">{select()?.message}</div> <div class="text-14-regular text-text-base">{select()?.message}</div>
<div> <div>
<List <List
class="px-3"
items={select()?.options ?? []} items={select()?.options ?? []}
key={(x) => x.value} key={(x) => x.value}
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])} current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
@@ -331,7 +332,7 @@ export function DialogConnectProvider(props: { provider: string }) {
}) })
async function complete() { async function complete() {
await serverSDK.client.global.dispose() await globalSDK.client.global.dispose()
dialog.close() dialog.close()
showToast({ showToast({
variant: "success", variant: "success",
@@ -365,7 +366,6 @@ export function DialogConnectProvider(props: { provider: string }) {
</div> </div>
<div> <div>
<List <List
class="px-3"
ref={(ref) => { ref={(ref) => {
listRef = ref listRef = ref
}} }}
@@ -409,7 +409,7 @@ export function DialogConnectProvider(props: { provider: string }) {
} }
setFormStore("error", undefined) setFormStore("error", undefined)
await serverSDK.client.auth.set({ await globalSDK.client.auth.set({
providerID: props.provider, providerID: props.provider,
auth: { auth: {
type: "api", type: "api",
@@ -480,7 +480,7 @@ export function DialogConnectProvider(props: { provider: string }) {
} }
setFormStore("error", undefined) setFormStore("error", undefined)
const result = await serverSDK.client.provider.oauth const result = await globalSDK.client.provider.oauth
.callback({ .callback({
providerID: props.provider, providerID: props.provider,
method: store.methodIndex, method: store.methodIndex,
@@ -526,14 +526,14 @@ export function DialogConnectProvider(props: { provider: string }) {
const code = createMemo(() => { const code = createMemo(() => {
const instructions = store.authorization?.instructions const instructions = store.authorization?.instructions
if (instructions?.includes(":")) { if (instructions?.includes(":")) {
return instructions.split(":").pop()?.trim() return instructions.split(":")[1]?.trim()
} }
return instructions return instructions
}) })
onMount(() => { onMount(() => {
void (async () => { void (async () => {
const result = await serverSDK.client.provider.oauth const result = await globalSDK.client.provider.oauth
.callback({ .callback({
providerID: props.provider, providerID: props.provider,
method: store.methodIndex, method: store.methodIndex,
@@ -5,12 +5,12 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast" import { showToast } from "@opencode-ai/ui/toast"
import { batch, For } from "solid-js" import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useServerSync } from "@/context/server-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form" import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectProvider } from "./dialog-select-provider"
@@ -21,8 +21,8 @@ type Props = {
export function DialogCustomProvider(props: Props) { export function DialogCustomProvider(props: Props) {
const dialog = useDialog() const dialog = useDialog()
const serverSync = useServerSync() const globalSync = useGlobalSync()
const serverSDK = useServerSDK() const globalSDK = useGlobalSDK()
const language = useLanguage() const language = useLanguage()
const [form, setForm] = createStore<FormState>({ const [form, setForm] = createStore<FormState>({
@@ -105,8 +105,8 @@ export function DialogCustomProvider(props: Props) {
const output = validateCustomProvider({ const output = validateCustomProvider({
form, form,
t: language.t, t: language.t,
disabledProviders: serverSync.data.config.disabled_providers ?? [], disabledProviders: globalSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync.data.provider.all.keys()), existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
}) })
batch(() => { batch(() => {
setForm("err", output.err) setForm("err", output.err)
@@ -118,11 +118,11 @@ export function DialogCustomProvider(props: Props) {
const saveMutation = useMutation(() => ({ const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => { mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
const disabledProviders = serverSync.data.config.disabled_providers ?? [] const disabledProviders = globalSync.data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
if (result.key) { if (result.key) {
await serverSDK.client.auth.set({ await globalSDK.client.auth.set({
providerID: result.providerID, providerID: result.providerID,
auth: { auth: {
type: "api", type: "api",
@@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) {
}) })
} }
await serverSync.updateConfig({ await globalSync.updateConfig({
provider: { [result.providerID]: result.config }, provider: { [result.providerID]: result.config },
disabled_providers: nextDisabled, disabled_providers: nextDisabled,
}) })
@@ -6,23 +6,21 @@ import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, For, Show } from "solid-js" import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { type LocalProject, getAvatarColors } from "@/context/layout" import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { Avatar } from "@opencode-ai/ui/avatar" import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getProjectAvatarSource } from "@/pages/layout/helpers" import { getProjectAvatarSource } from "@/pages/layout/sidebar-items"
import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) { export function DialogEditProject(props: { project: LocalProject }) {
const dialog = useDialog() const dialog = useDialog()
const global = useGlobal() const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const language = useLanguage() 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 folderName = createMemo(() => getFilename(props.project.worktree))
const defaultName = createMemo(() => props.project.name || folderName()) const defaultName = createMemo(() => props.project.name || folderName())
@@ -80,19 +78,19 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
const start = store.startup.trim() const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") { if (props.project.id && props.project.id !== "global") {
await serverSDK().client.project.update({ await globalSDK.client.project.update({
projectID: props.project.id, projectID: props.project.id,
directory: props.project.worktree, directory: props.project.worktree,
name, name,
icon: { color: store.color || "", override: store.iconOverride || "" }, icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start }, commands: { start },
}) })
serverSync().project.icon(props.project.worktree, store.iconOverride || undefined) globalSync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close() dialog.close()
return return
} }
serverSync().project.meta(props.project.worktree, { globalSync.project.meta(props.project.worktree, {
name, name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined }, icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined }, commands: { start: start || undefined },
+2 -2
View File
@@ -6,7 +6,7 @@ import { usePrompt } from "@/context/prompt"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { showToast } from "@/utils/toast" import { showToast } from "@opencode-ai/ui/toast"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
@@ -88,7 +88,7 @@ export const DialogFork: Component = () => {
return ( return (
<Dialog title={language.t("command.session.fork")}> <Dialog title={language.t("command.session.fork")}>
<List <List
class="flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0" class="flex-1 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 }} search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.fork.empty")} emptyMessage={language.t("dialog.fork.empty")}
key={(x) => x.id} key={(x) => x.id}
@@ -39,7 +39,6 @@ export const DialogManageModels: Component = () => {
} }
> >
<List <List
class="px-3"
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }} search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.model.empty")} emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x?.provider?.id}:${x?.id}`} key={(x) => `${x?.provider?.id}:${x?.id}`}
@@ -6,16 +6,15 @@ import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { createMemo, createResource, createSignal } from "solid-js" import { createMemo, createResource, createSignal } from "solid-js"
import { ServerSDK } from "@/context/server-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global"
interface DialogSelectDirectoryProps { interface DialogSelectDirectoryProps {
title?: string title?: string
multiple?: boolean multiple?: boolean
onSelect: (result: string | string[] | null) => void onSelect: (result: string | string[] | null) => void
server: ServerConnection.Any
} }
type Row = { type Row = {
@@ -128,7 +127,11 @@ function uniqueRows(rows: Row[]) {
}) })
} }
function useDirectorySearch(args: { sdk: ServerSDK; start: () => string | undefined; home: () => string }) { function useDirectorySearch(args: {
sdk: ReturnType<typeof useGlobalSDK>
start: () => string | undefined
home: () => string
}) {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>() const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0 let current = 0
@@ -243,8 +246,9 @@ function useDirectorySearch(args: { sdk: ServerSDK; start: () => string | undefi
} }
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const global = useGlobal() const sync = useGlobalSync()
const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server) const sdk = useGlobalSDK()
const layout = useLayout()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
@@ -275,7 +279,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}) })
const recentProjects = createMemo(() => { const recentProjects = createMemo(() => {
const projects = serverCtx.projects.list() const projects = layout.projects.list()
const byProject = new Map<string, number>() const byProject = new Map<string, number>()
for (const project of projects) { for (const project of projects) {
@@ -320,7 +324,6 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return ( return (
<Dialog title={props.title ?? language.t("command.project.open")}> <Dialog title={props.title ?? language.t("command.project.open")}>
<List <List
class="px-3"
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }} search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.directory.empty")} emptyMessage={language.t("dialog.directory.empty")}
loadingMessage={language.t("common.loading")} loadingMessage={language.t("common.loading")}
@@ -9,8 +9,8 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js" import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command" import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useServerSync } from "@/context/server-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -175,7 +175,7 @@ function createFileEntries(props: {
function createSessionEntries(props: { function createSessionEntries(props: {
workspaces: () => string[] workspaces: () => string[]
label: (directory: string) => string label: (directory: string) => string
serverSDK: ReturnType<typeof useServerSDK> globalSDK: ReturnType<typeof useGlobalSDK>
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}) { }) {
const state: { const state: {
@@ -207,7 +207,7 @@ function createSessionEntries(props: {
state.inflight = Promise.all( state.inflight = Promise.all(
dirs.map((directory) => { dirs.map((directory) => {
const description = props.label(directory) const description = props.label(directory)
return props.serverSDK.client.session return props.globalSDK.client.session
.list({ directory, roots: true }) .list({ directory, roots: true })
.then((x) => .then((x) =>
(x.data ?? []) (x.data ?? [])
@@ -261,19 +261,15 @@ function createSessionEntries(props: {
return { sessions } return { sessions }
} }
export function DialogSelectFile(props: { export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
mode?: DialogSelectFileMode
onOpenFile?: (path: string) => void
onSelectFile?: (path: string) => void
}) {
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
const layout = useLayout() const layout = useLayout()
const file = useFile() const file = useFile()
const dialog = useDialog() const dialog = useDialog()
const navigate = useNavigate() const navigate = useNavigate()
const serverSDK = useServerSDK() const globalSDK = useGlobalSDK()
const serverSync = useServerSync() const globalSync = useGlobalSync()
const { params, tabs, view } = useSessionLayout() const { params, tabs, view } = useSessionLayout()
const filesOnly = () => props.mode === "files" const filesOnly = () => props.mode === "files"
const state = { cleanup: undefined as (() => void) | void, committed: false } const state = { cleanup: undefined as (() => void) | void, committed: false }
@@ -296,21 +292,21 @@ export function DialogSelectFile(props: {
if (directory && !dirs.includes(directory)) return [...dirs, directory] if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs return dirs
}) })
const homedir = createMemo(() => serverSync.data.path.home) const homedir = createMemo(() => globalSync.data.path.home)
const label = (directory: string) => { const label = (directory: string) => {
const current = project() const current = project()
const kind = const kind =
current && directory === current.worktree current && directory === current.worktree
? language.t("workspace.type.local") ? language.t("workspace.type.local")
: language.t("workspace.type.sandbox") : language.t("workspace.type.sandbox")
const [store] = serverSync.child(directory, { bootstrap: false }) const [store] = globalSync.child(directory, { bootstrap: false })
const home = homedir() const home = homedir()
const path = home ? directory.replace(home, "~") : directory const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory) const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}` return `${kind} : ${name || path}`
} }
const { sessions } = createSessionEntries({ workspaces, label, serverSDK, language }) const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language })
const items = async (text: string) => { const items = async (text: string) => {
const query = text.trim() const query = text.trim()
@@ -379,10 +375,6 @@ export function DialogSelectFile(props: {
} }
if (!item.path) return if (!item.path) return
if (props.onSelectFile) {
props.onSelectFile(item.path)
return
}
open(item.path) open(item.path)
} }
@@ -394,7 +386,6 @@ export function DialogSelectFile(props: {
return ( return (
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition> <Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
<List <List
class="px-3"
search={{ search={{
placeholder: filesOnly() placeholder: filesOnly()
? language.t("session.header.searchFiles") ? language.t("session.header.searchFiles")
@@ -6,7 +6,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useQueryOptions } from "@/context/server-sync" import { useQueryOptions } from "@/context/global-sync"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
const statusLabels = { const statusLabels = {
@@ -55,7 +55,6 @@ export const DialogSelectMcp: Component = () => {
description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })} description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })}
> >
<List <List
class="px-3"
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }} search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.mcp.empty")} emptyMessage={language.t("dialog.mcp.empty")}
key={(x) => x?.name ?? ""} 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="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> <div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
<List <List
class="px-3 [&_[data-slot=list-scroll]]:overflow-visible" class="[&_[data-slot=list-scroll]]:overflow-visible"
ref={(ref) => (listRef = ref)} ref={(ref) => (listRef = ref)}
items={model.list} items={model.list}
current={model.current()} current={model.current()}
@@ -90,8 +90,8 @@ 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="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div>
<div class="w-full"> <div class="w-full">
<List <List
class="w-full px-3" class="w-full px-0"
key={(p) => p.id} key={(x) => x?.id}
items={providers.popular} items={providers.popular}
activeIcon="plus-small" activeIcon="plus-small"
sortBy={(a, b) => { sortBy={(a, b) => {
@@ -37,7 +37,7 @@ const ModelList: Component<{
return ( return (
<List <List
class={`flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`} class={`flex-1 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 }} search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true, action: props.action }}
emptyMessage={language.t("dialog.model.empty")} emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x.provider.id}:${x.id}`} key={(x) => `${x.provider.id}:${x.id}`}
@@ -29,14 +29,13 @@ export const DialogSelectProvider: Component = () => {
return ( return (
<Dialog title={language.t("command.provider.connect")} transition> <Dialog title={language.t("command.provider.connect")} transition>
<List <List
class="px-3"
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }} search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.provider.empty")} emptyMessage={language.t("dialog.provider.empty")}
activeIcon="plus-small" activeIcon="plus-small"
key={(x) => x?.id} key={(x) => x?.id}
items={() => { items={() => {
language.locale() language.locale()
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()] return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()]
}} }}
filterKeys={["id", "name"]} filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
@@ -7,18 +7,15 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@/utils/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, Show } from "solid-js" import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
const DEFAULT_USERNAME = "opencode" const DEFAULT_USERNAME = "opencode"
@@ -74,7 +71,7 @@ function useDefaultServer() {
} }
} }
return { defaultKey: () => defaultKey.latest, canDefault, setDefault } return { defaultKey, canDefault, setDefault }
} }
function useServerPreview() { function useServerPreview() {
@@ -124,7 +121,7 @@ function ServerForm(props: ServerFormProps) {
} }
return ( return (
<div> <div class="px-5">
<div class="bg-surface-base rounded-md p-5 flex flex-col gap-3"> <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"> <div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<TextField <TextField
@@ -175,31 +172,16 @@ function ServerForm(props: ServerFormProps) {
} }
export function DialogSelectServer() { export function DialogSelectServer() {
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; navigateOnAdd?: boolean } = {}) {
const navigate = useNavigate() const navigate = useNavigate()
const dialog = useDialog()
const server = useServer() const server = useServer()
const tabs = useTabs()
const global = useGlobal()
const platform = usePlatform() const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const { defaultKey, canDefault, setDefault } = useDefaultServer() const { defaultKey, canDefault, setDefault } = useDefaultServer()
const { previewStatus } = useServerPreview() const { previewStatus } = useServerPreview()
const checkServerHealth = useCheckServerHealth() const checkServerHealth = useCheckServerHealth()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: { addServer: {
url: "", url: "",
name: "", name: "",
@@ -265,11 +247,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
} }
resetAdd() resetAdd()
if (options.navigateOnAdd === false) {
server.add(conn)
options.onSelect?.()
return
}
await select(conn, true) await select(conn, true)
}, },
})) }))
@@ -318,14 +295,12 @@ export function useServerManagementController(options: { onSelect?: () => void;
})) }))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => { const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const originalKey = ServerConnection.key(original)
const active = server.key const active = server.key
tabs.removeServer(originalKey)
const newConn = server.add(next) const newConn = server.add(next)
if (!newConn) return if (!newConn) return
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive) if (nextActive) server.setActive(nextActive)
server.remove(originalKey) server.remove(ServerConnection.key(original))
} }
const items = createMemo(() => { const items = createMemo(() => {
@@ -336,12 +311,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
return [current, ...list.filter((x) => x !== current)] return [current, ...list.filter((x) => x !== current)]
}) })
const settings = useSettings() const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
)
const sortedItems = createMemo(() => { const sortedItems = createMemo(() => {
const list = items() const list = items()
@@ -356,16 +326,32 @@ export function useServerManagementController(options: { onSelect?: () => void;
return list.slice().sort((a, b) => { return list.slice().sort((a, b) => {
if (a === active) return -1 if (a === active) return -1
if (b === active) return 1 if (b === active) return 1
const diff = const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)])
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0) 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) { async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
options.onSelect?.() dialog.close()
if (persist && conn.type === "http") { if (persist && conn.type === "http") {
server.add(conn) server.add(conn)
navigate("/") navigate("/")
@@ -471,7 +457,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
username: conn.http.username ?? "", username: conn.http.username ?? "",
password: conn.http.password ?? "", password: conn.http.password ?? "",
error: "", error: "",
status: global.servers.health[ServerConnection.key(conn)]?.healthy, status: store.status[ServerConnection.key(conn)]?.healthy,
}) })
} }
@@ -510,78 +496,65 @@ export function useServerManagementController(options: { onSelect?: () => void;
}) })
async function handleRemove(url: ServerConnection.Key) { async function handleRemove(url: ServerConnection.Key) {
tabs.removeServer(url)
server.remove(url) server.remove(url)
if ((await platform.getDefaultServer?.()) === url) { if ((await platform.getDefaultServer?.()) === url) {
void platform.setDefaultServer?.(null) 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 ( return (
<div class="flex flex-1 min-h-0 flex-col gap-4"> <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}
/>
}
>
<List <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={{ search={{
placeholder: language.t("dialog.server.search.placeholder"), placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false, autofocus: false,
}} }}
noInitialSelection noInitialSelection
emptyMessage={language.t("dialog.server.empty")} emptyMessage={language.t("dialog.server.empty")}
items={props.controller.sortedItems} items={sortedItems}
key={(x) => x.http.url} key={(x) => x.http.url}
onSelect={(x) => { onSelect={(x) => {
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x) if (x) void select(x)
}} }}
divider={true} 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) => { {(i) => {
const key = ServerConnection.key(i) const key = ServerConnection.key(i)
return ( return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item"> <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"> <div class="flex flex-col h-full items-start w-5">
<ServerHealthIndicator health={props.controller.status()[key]} /> <ServerHealthIndicator health={store.status[key]} />
</div> </div>
<ServerRow <ServerRow
conn={i} conn={i}
dimmed={props.controller.status()[key]?.healthy === false} dimmed={store.status[key]?.healthy === false}
status={props.controller.status()[key]} status={store.status[key]}
class="flex items-center gap-3 min-w-0 flex-1" class="flex items-center gap-3 min-w-0 flex-1"
badge={ badge={
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}> <Show when={defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs"> <span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")} {language.t("dialog.server.status.default")}
</span> </span>
@@ -590,7 +563,7 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
showCredentials showCredentials
/> />
<div class="flex items-center justify-center gap-4 pl-4"> <div class="flex items-center justify-center gap-4 pl-4">
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}> <Show when={ServerConnection.key(current()) === key}>
<Icon name="check" class="h-6" /> <Icon name="check" class="h-6" />
</Show> </Show>
@@ -609,18 +582,20 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
if (i.type !== "http") return if (i.type !== "http") return
props.controller.startEdit(i) startEdit(i)
}} }}
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}> <Show when={canDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}> <DropdownMenu.Item onSelect={() => setDefault(key)}>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}> <Show when={canDefault() && defaultKey() === key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}> <DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")} {language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
@@ -628,7 +603,7 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
</Show> </Show>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))} onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak" class="text-text-on-critical-base hover:bg-surface-critical-weak"
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
@@ -642,58 +617,33 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
) )
}} }}
</List> </List>
</Show>
<div class="shrink-0 pb-5"> <div class="shrink-0 px-5 pb-5">
<Show
when={isFormMode()}
fallback={
<Button <Button
variant="secondary" variant="secondary"
icon="plus-small" icon="plus-small"
size="large" size="large"
onClick={props.controller.startAdd} onClick={startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5" class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
> >
{language.t("dialog.server.add.button")} {language.t("dialog.server.add.button")}
</Button> </Button>
</div> }
</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() <Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
{formBusy()
? language.t("dialog.server.add.checking") ? language.t("dialog.server.add.checking")
: props.controller.isAddMode() : isAddMode()
? language.t("dialog.server.add.button") ? language.t("dialog.server.add.button")
: language.t("common.save")} : language.t("common.save")}
</Button> </Button>
</Show>
</div> </div>
</div> </div>
</Dialog>
) )
} }
@@ -8,7 +8,6 @@ import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds" import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers" import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models" import { SettingsModels } from "./settings-models"
import { SettingsServers } from "./settings-servers"
export const DialogSettings: Component = () => { export const DialogSettings: Component = () => {
const language = useLanguage() const language = useLanguage()
@@ -18,7 +17,7 @@ export const DialogSettings: Component = () => {
<Dialog size="x-large" transition> <Dialog size="x-large" transition>
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog"> <Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
<Tabs.List> <Tabs.List>
<div class="flex flex-col justify-between h-full w-full gap-4"> <div class="flex flex-col justify-between h-full w-full">
<div class="flex flex-col gap-3 w-full pt-3"> <div class="flex flex-col gap-3 w-full pt-3">
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
@@ -32,10 +31,6 @@ export const DialogSettings: Component = () => {
<Icon name="keyboard" /> <Icon name="keyboard" />
{language.t("settings.tab.shortcuts")} {language.t("settings.tab.shortcuts")}
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
</div> </div>
</div> </div>
@@ -66,9 +61,6 @@ export const DialogSettings: Component = () => {
<Tabs.Content value="shortcuts" class="no-scrollbar"> <Tabs.Content value="shortcuts" class="no-scrollbar">
<SettingsKeybinds /> <SettingsKeybinds />
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="servers" class="no-scrollbar">
<SettingsServers />
</Tabs.Content>
<Tabs.Content value="providers" class="no-scrollbar"> <Tabs.Content value="providers" class="no-scrollbar">
<SettingsProviders /> <SettingsProviders />
</Tabs.Content> </Tabs.Content>
+7 -604
View File
@@ -1,22 +1,6 @@
import { useFilteredList } from "@opencode-ai/ui/hooks" import { useFilteredList } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring" import { useSpring } from "@opencode-ai/ui/motion-spring"
import { import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal, createResource } from "solid-js"
createEffect,
on,
Component,
splitProps,
For,
Show,
onCleanup,
createMemo,
createSignal,
createResource,
Switch,
Match,
type ComponentProps,
type JSX,
} from "solid-js"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
@@ -31,14 +15,12 @@ import {
FileAttachmentPart, FileAttachmentPart,
} from "@/context/prompt" } from "@/context/prompt"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useNavigate } from "@solidjs/router"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon, type IconProps } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -51,8 +33,6 @@ import { Persist, persisted } from "@/utils/persist"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { serverAttachmentFile } from "./prompt-input/server-attachment"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
@@ -75,14 +55,11 @@ import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder" import { promptPlaceholder } from "./prompt-input/placeholder"
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useQueries } from "@tanstack/solid-query" import { useQueries } from "@tanstack/solid-query"
import { useQueryOptions } from "@/context/server-sync" import { useQueryOptions } from "@/context/global-sync"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { displayName } from "@/pages/layout/helpers"
interface PromptInputProps { interface PromptInputProps {
class?: string class?: string
variant?: "dock" | "new-session"
ref?: (el: HTMLDivElement) => void ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void onNewSessionWorktreeReset?: () => void
@@ -124,7 +101,6 @@ const EXAMPLES = [
export const PromptInput: Component<PromptInputProps> = (props) => { export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK() const sdk = useSDK()
const navigate = useNavigate()
const queryOptions = useQueryOptions() const queryOptions = useQueryOptions()
const sync = useSync() const sync = useSync()
@@ -132,7 +108,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const files = useFile() const files = useFile()
const prompt = usePrompt() const prompt = usePrompt()
const layout = useLayout() const layout = useLayout()
const server = useServer()
const comments = useComments() const comments = useComments()
const dialog = useDialog() const dialog = useDialog()
const providers = useProviders() const providers = useProviders()
@@ -140,13 +115,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const permission = usePermission() const permission = usePermission()
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings()
const { params, tabs, view } = useSessionLayout() const { params, tabs, view } = useSessionLayout()
let editorRef!: HTMLDivElement let editorRef!: HTMLDivElement
let fileInputRef: HTMLInputElement | undefined let fileInputRef: HTMLInputElement | undefined
let scrollRef!: HTMLDivElement let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement
let projectSearchRef: HTMLInputElement | undefined
const mirror = { input: false } const mirror = { input: false }
const inset = 56 const inset = 56
@@ -278,7 +251,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: "image" | "@mention" | null draggingType: "image" | "@mention" | null
mode: "normal" | "shell" mode: "normal" | "shell"
applyingHistory: boolean applyingHistory: boolean
variantOpen: boolean
}>({ }>({
popover: null, popover: null,
historyIndex: -1, historyIndex: -1,
@@ -287,11 +259,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: null, draggingType: null,
mode: "normal", mode: "normal",
applyingHistory: false, applyingHistory: false,
variantOpen: false,
})
const [picker, setPicker] = createStore({
projectOpen: false,
projectSearch: "",
}) })
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
@@ -466,25 +433,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const escBlur = () => platform.platform === "desktop" && platform.os === "macos" const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
const pick = () => { const pick = () => fileInputRef?.click()
if (server.isLocal()) {
fileInputRef?.click()
return
}
void import("@/components/dialog-select-file").then((module) =>
dialog.show(() => (
<module.DialogSelectFile
mode="files"
onSelectFile={(path) => {
void sdk.client.v2.fs
.read({ path })
.then((response) => response.data?.data)
.then((data) => data && addAttachments([serverAttachmentFile(path, data)]))
}}
/>
)),
)
}
const setMode = (mode: "normal" | "shell") => { const setMode = (mode: "normal" | "shell") => {
setStore("mode", mode) setStore("mode", mode)
@@ -1106,24 +1055,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
readClipboardImage: platform.readClipboardImage, readClipboardImage: platform.readClipboardImage,
}) })
const fileAttachmentInput = () => (
<input
ref={(el) => (fileInputRef = el)}
type="file"
multiple
accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden"
onChange={(e) => {
const list = e.currentTarget.files
if (list) void addAttachments(Array.from(list))
e.currentTarget.value = ""
}}
/>
)
const variants = createMemo(() => ["default", ...local.model.variant.list()]) 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 accepting = createMemo(() => {
const id = params.id const id = params.id
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
@@ -1334,134 +1266,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
(p) => p, (p) => p,
) )
const designPlaceholder = () => {
if (store.mode === "shell") return placeholder()
return "Ask anything, / for commands, @ for context..."
}
const modelControlState = createMemo<ComposerModelControlState>(() => ({
loading: providersLoading(),
paid: providers.paid().length > 0,
title: language.t("command.model.choose"),
keybind: command.keybind("model.choose"),
model: local.model,
providerID: local.model.current()?.provider?.id,
modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"),
style: control(),
onClose: restoreFocus,
onUnpaidClick: () => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
},
}))
const newSession = () => props.variant === "new-session"
const projects = createMemo(() => layout.projects.list())
const projectForDirectory = (directory: string | undefined) => {
if (!directory) return
const key = pathKey(directory)
return projects().find(
(project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
)
}
const selectedProject = createMemo(() => projectForDirectory(sdk.directory))
const projectResults = createMemo(() => {
const search = picker.projectSearch.trim().toLowerCase()
if (!search) return projects()
return projects().filter((project) => displayName(project).toLowerCase().includes(search))
})
const showAgentControl = createMemo(() => settings.general.showCustomAgents() && agentNames().length > 0)
const selectProject = (worktree: string) => {
setPicker({
projectOpen: false,
projectSearch: "",
})
if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "")) {
restoreFocus()
return
}
layout.projects.open(worktree)
server.projects.touch(worktree)
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
selectProject(directory)
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") }))
return
}
void import("@/components/dialog-select-directory").then((x) => {
dialog.show(
() => <x.DialogSelectDirectory onSelect={select} server={conn} />,
() => select(null),
)
})
}
const projectPickerState = createMemo<ComposerPickerState>(() => ({
open: picker.projectOpen,
trigger: {
action: "prompt-project",
icon: "folder",
label: selectedProject() ? displayName(selectedProject()!) : language.t("session.new.project.new"),
class: "max-w-[203px]",
style: control(),
onPress: () => setPicker("projectOpen", true),
},
search: picker.projectSearch,
searchPlaceholder: language.t("session.new.project.search"),
clearLabel: language.t("common.clear"),
items: projectResults().map((project) => ({
icon: "folder",
label: displayName(project),
selected: selectedProject()?.worktree === project.worktree,
onSelect: () => selectProject(project.worktree),
})),
action: {
icon: "plus",
label: language.t("session.new.project.add"),
onSelect: () => {
setPicker("projectOpen", false)
void addProject()
},
},
onOpenChange: (open) => {
setPicker("projectOpen", open)
if (open) requestAnimationFrame(() => projectSearchRef?.focus())
},
onSearchInput: (value) => setPicker("projectSearch", value),
onSearchClear: () => setPicker("projectSearch", ""),
searchRef: (el) => (projectSearchRef = el),
}))
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
title: language.t("command.agent.cycle"),
keybind: command.keybind("agent.cycle"),
options: agentNames(),
current: local.agent.current()?.name ?? "",
style: control(),
onSelect: (value) => {
local.agent.set(value)
restoreFocus()
},
}))
const newProjectTriggerState = createMemo<ComposerPickerTriggerState>(() => ({
action: "prompt-project",
icon: "folder-add-left",
label: language.t("session.new.project.new"),
class: "max-w-[160px]",
style: control(),
onPress: () => void addProject(),
}))
return ( return (
<div class="relative size-full flex flex-col gap-0"> <div class="relative size-full _max-h-[320px] flex flex-col gap-0">
{(promptReady(), null)} {(promptReady(), null)}
<PromptPopover <PromptPopover
popover={store.popover} popover={store.popover}
@@ -1478,182 +1284,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
commandKeybind={command.keybind} commandKeybind={command.keybind}
t={(key) => language.t(key as Parameters<typeof language.t>[0])} t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/> />
<Switch>
<Match when={settings.general.newLayoutDesigns()}>
<div class="flex flex-col gap-3">
<DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"}
onSubmit={handleSubmit}
classList={{
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
"border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class,
}}
>
<PromptDragOverlay
type={store.draggingType}
label={language.t(
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
)}
/>
<PromptContextItems
items={contextItems()}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
}}
openComment={openComment}
remove={(item) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<PromptImageAttachments
attachments={imageAttachments()}
onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
/>
<div
class="relative min-h-[52px]"
onMouseDown={(e) => {
const target = e.target
if (!(target instanceof HTMLElement)) return
if (target.closest('[data-action^="prompt-"]')) return
editorRef?.focus()
}}
>
<div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
<div
data-component="prompt-input"
ref={(el) => {
editorRef = el
props.ref?.(el)
}}
role="textbox"
aria-multiline="true"
aria-label={designPlaceholder()}
contenteditable="true"
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
inputMode="text"
// @ts-expect-error
autocomplete="off"
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-base": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
/>
<div
data-component={newSession() ? "session-new-design-text" : "session-composer-text"}
class="absolute top-0 inset-x-0 px-4 pt-4 pointer-events-none whitespace-nowrap truncate leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]"
classList={{ "font-mono!": store.mode === "shell", hidden: prompt.dirty() }}
>
{designPlaceholder()}
</div>
</div>
</div>
<div class="flex h-11 items-center px-2">
<div class="flex min-w-0 flex-1 items-center gap-0">
{fileAttachmentInput()}
<TooltipKeybind
placement="top"
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
>
<IconButton
data-action="prompt-attach"
type="button"
icon="plus"
variant="ghost"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
style={buttons()}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
/>
</TooltipKeybind>
<Show when={showAgentControl()}>
<ComposerAgentControl state={agentControlState()} />
</Show>
<Show when={newSession() && !selectedProject()}>
<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
data-action="prompt-submit"
type="submit"
disabled={!working() && blank()}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
style={{
"background-image":
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
}}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</DockShellForm>
<Show when={newSession() && selectedProject()}>
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
<ComposerPicker state={projectPickerState()} />
</div>
</Show>
</div>
</Match>
<Match when>
<DockShellForm <DockShellForm
onSubmit={handleSubmit} onSubmit={handleSubmit}
classList={{ classList={{
@@ -1665,9 +1295,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
> >
<PromptDragOverlay <PromptDragOverlay
type={store.draggingType} type={store.draggingType}
label={language.t( label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
)}
/> />
<PromptContextItems <PromptContextItems
items={contextItems()} items={contextItems()}
@@ -1946,7 +1574,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</TooltipKeybind> </TooltipKeybind>
</Show> </Show>
</div> </div>
<Show when={showVariantControl()}> <Show when={variants().length > 2}>
<div <div
data-component="prompt-variant-control" data-component="prompt-variant-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined} style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
@@ -1982,231 +1610,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div> </div>
</DockTray> </DockTray>
</Show> </Show>
</Match>
</Switch>
</div> </div>
) )
} }
type ComposerPickerItemState = {
icon: IconProps["name"]
label: string
selected?: boolean
onSelect: () => void
}
type ComposerPickerTriggerState = {
action: string
icon?: IconProps["name"]
label: string
class?: string
style: JSX.CSSProperties | undefined
onPress: () => void
}
type ComposerPickerState = {
open: boolean
trigger: ComposerPickerTriggerState
search: string
searchPlaceholder: string
clearLabel: string
items: ComposerPickerItemState[]
action: ComposerPickerItemState
listClass?: string
searchRef: (el: HTMLInputElement) => void
onOpenChange: (open: boolean) => void
onSearchInput: (value: string) => void
onSearchClear: () => void
}
type ComposerAgentControlState = {
title: string
keybind: string
options: string[]
current: string
style: JSX.CSSProperties | undefined
onSelect: (value: string | undefined) => void
}
type ComposerModelControlState = {
loading: boolean
paid: boolean
title: string
keybind: string
model: ReturnType<typeof useLocal>["model"]
providerID?: string
modelName: string
style: JSX.CSSProperties | undefined
onClose: () => void
onUnpaidClick: () => void
}
function ComposerPickerTrigger(props: ComponentProps<"button"> & { state: ComposerPickerTriggerState }) {
const [local, rest] = splitProps(props, ["state", "class", "style", "onClick"])
return (
<button
{...rest}
data-action={local.state.action}
type="button"
class={`flex h-7 min-w-0 items-center gap-1.5 rounded px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none ${local.state.class ?? ""}`}
style={local.state.style}
onClick={() => local.state.onPress()}
>
<Show when={local.state.icon}>
{(icon) => <Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />}
</Show>
<span class="min-w-0 truncate leading-5">{local.state.label}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</button>
)
}
function ComposerPickerMenuItem(props: { state: ComposerPickerItemState }) {
return (
<button
type="button"
class="flex h-7 w-full items-center gap-2 rounded px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={props.state.onSelect}
>
<Icon name={props.state.icon} size="small" class="shrink-0 text-v2-icon-icon-base" />
<span class="min-w-0 flex-1 truncate leading-5">{props.state.label}</span>
<Show when={props.state.selected}>
<Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
)
}
function ComposerPicker(props: { state: ComposerPickerState }) {
return (
<KobaltePopover
open={props.state.open}
placement="bottom-start"
gutter={4}
modal={false}
onOpenChange={props.state.onOpenChange}
>
<KobaltePopover.Trigger as={ComposerPickerTrigger} state={props.state.trigger} />
<KobaltePopover.Portal>
<KobaltePopover.Content
class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div class={`flex flex-col p-0.5 ${props.state.listClass ?? ""}`}>
<div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={props.state.searchRef}
value={props.state.search}
placeholder={props.state.searchPlaceholder}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => props.state.onSearchInput(event.currentTarget.value)}
/>
<Show when={props.state.search.trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onClick={props.state.onSearchClear}
aria-label={props.state.clearLabel}
>
<Icon name="close-small" size="small" />
</button>
</Show>
</div>
<For each={props.state.items}>{(item) => <ComposerPickerMenuItem state={item} />}</For>
</div>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
<ComposerPickerMenuItem state={props.state.action} />
</div>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
)
}
function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
return (
<div class="relative">
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
<Icon name="sliders" size="small" />
</div>
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
<Select
size="normal"
options={props.state.options}
current={props.state.current}
onSelect={props.state.onSelect}
class="max-w-[175px] justify-start text-v2-text-text-faint [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate pl-5 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
triggerStyle={props.state.style}
triggerProps={{ "data-action": "prompt-agent" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
)
}
function ComposerModelControl(props: { state: ComposerModelControlState }) {
return (
<Show when={!props.state.loading}>
<Show
when={props.state.paid}
fallback={
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
style={props.state.style}
onClick={props.state.onUnpaidClick}
>
<Show when={props.state.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
<ModelSelectorPopover
model={props.state.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: props.state.style,
class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
"data-action": "prompt-model",
}}
onClose={props.state.onClose}
>
<Show when={props.state.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
</Show>
)
}
@@ -1,6 +1,6 @@
import { onMount } from "solid-js" import { onMount } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { showToast } from "@/utils/toast" import { showToast } from "@opencode-ai/ui/toast"
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt" import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
@@ -1,25 +0,0 @@
import { describe, expect, test } from "bun:test"
import { serverAttachmentFile } from "./server-attachment"
describe("serverAttachmentFile", () => {
test("creates a file from server text content", async () => {
const file = serverAttachmentFile("docs/readme.txt", { type: "text", content: "hello", mime: "text/plain" })
expect(file.name).toBe("readme.txt")
expect(file.type).toBe("text/plain")
expect(await file.text()).toBe("hello")
})
test("creates a file from server base64 content", async () => {
const file = serverAttachmentFile("images/pixel.png", {
type: "binary",
content: "aGVsbG8=",
encoding: "base64",
mime: "image/png",
})
expect(file.name).toBe("pixel.png")
expect(file.type).toBe("image/png")
expect(await file.text()).toBe("hello")
})
})
@@ -1,8 +0,0 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { FileSystemBinaryContent, FileSystemTextContent } from "@opencode-ai/sdk/v2"
export function serverAttachmentFile(path: string, data: FileSystemTextContent | FileSystemBinaryContent) {
const content =
data.type === "text" ? data.content : Uint8Array.from(atob(data.content), (char) => char.charCodeAt(0))
return new File([content], getFilename(path), { type: data.mime })
}
@@ -127,7 +127,6 @@ beforeAll(async () => {
mock.module("@/context/sdk", () => ({ mock.module("@/context/sdk", () => ({
useSDK: () => { useSDK: () => {
const sdk = { const sdk = {
scope: "local",
directory: "/repo/main", directory: "/repo/main",
client: rootClient, client: rootClient,
url: "http://localhost:4096", url: "http://localhost:4096",
@@ -163,8 +162,8 @@ beforeAll(async () => {
}), }),
})) }))
mock.module("@/context/server-sync", () => ({ mock.module("@/context/global-sync", () => ({
useServerSync: () => ({ useGlobalSync: () => ({
child: (directory: string) => { child: (directory: string) => {
syncedDirectories.push(directory) syncedDirectories.push(directory)
storedSessions[directory] ??= [] storedSessions[directory] ??= []
@@ -1,11 +1,11 @@
import type { Message, Session } from "@opencode-ai/sdk/v2/client" import type { Message, Session } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast" import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { batch, type Accessor } from "solid-js" import { batch, type Accessor } from "solid-js"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { useServerSync } from "@/context/server-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
@@ -18,7 +18,6 @@ import { Worktree as WorktreeState } from "@/utils/worktree"
import { buildRequestParts } from "./build-request-parts" import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom" import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope"
type PendingPrompt = { type PendingPrompt = {
abort: AbortController abort: AbortController
@@ -39,7 +38,7 @@ export type FollowupDraft = {
type FollowupSendInput = { type FollowupSendInput = {
client: ReturnType<typeof useSDK>["client"] client: ReturnType<typeof useSDK>["client"]
serverSync: ReturnType<typeof useServerSync> globalSync: ReturnType<typeof useGlobalSync>
sync: ReturnType<typeof useSync> sync: ReturnType<typeof useSync>
draft: FollowupDraft draft: FollowupDraft
messageID?: string messageID?: string
@@ -54,7 +53,7 @@ const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttac
export async function sendFollowupDraft(input: FollowupSendInput) { export async function sendFollowupDraft(input: FollowupSendInput) {
const text = draftText(input.draft.prompt) const text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt) const images = draftImages(input.draft.prompt)
const [, setStore] = input.serverSync.child(input.draft.sessionDirectory) const [, setStore] = input.globalSync.child(input.draft.sessionDirectory)
const setBusy = () => { const setBusy = () => {
if (!input.optimisticBusy) return if (!input.optimisticBusy) return
@@ -206,14 +205,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const navigate = useNavigate() const navigate = useNavigate()
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const serverSync = useServerSync() const globalSync = useGlobalSync()
const local = useLocal() const local = useLocal()
const permission = usePermission() const permission = usePermission()
const prompt = usePrompt() const prompt = usePrompt()
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const params = useParams() const params = useParams()
const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID)
const errorMessage = (err: unknown) => { const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) { if (err && typeof err === "object" && "data" in err) {
@@ -228,18 +226,17 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return Promise.resolve() if (!sessionID) return Promise.resolve()
serverSync.todo.set(sessionID, []) globalSync.todo.set(sessionID, [])
const [, setStore] = serverSync.child(sdk.directory) const [, setStore] = globalSync.child(sdk.directory)
setStore("todo", sessionID, []) setStore("todo", sessionID, [])
input.onAbort?.() input.onAbort?.()
const key = pendingKey(sessionID) const queued = pending.get(sessionID)
const queued = pending.get(key)
if (queued) { if (queued) {
queued.abort.abort() queued.abort.abort()
queued.cleanup() queued.cleanup()
pending.delete(key) pending.delete(sessionID)
return Promise.resolve() return Promise.resolve()
} }
return sdk.client.session return sdk.client.session
@@ -276,7 +273,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
const seed = (dir: string, info: Session) => { const seed = (dir: string, info: Session) => {
const [, setStore] = serverSync.child(dir) const [, setStore] = globalSync.child(dir)
setStore("session", (list: Session[]) => { setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id) const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list] const next = [...list]
@@ -344,7 +341,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}) })
return return
} }
WorktreeState.pending(sdk.scope, createdWorktree.directory) WorktreeState.pending(createdWorktree.directory)
sessionDirectory = createdWorktree.directory sessionDirectory = createdWorktree.directory
} }
@@ -357,7 +354,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
directory: sessionDirectory, directory: sessionDirectory,
throwOnError: true, throwOnError: true,
}) })
serverSync.child(sessionDirectory) globalSync.child(sessionDirectory)
} }
input.onNewSessionWorktreeReset?.() input.onNewSessionWorktreeReset?.()
@@ -503,7 +500,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
clearInput() clearInput()
const waitForWorktree = async () => { const waitForWorktree = async () => {
const worktree = WorktreeState.get(sdk.scope, sessionDirectory) const worktree = WorktreeState.get(sessionDirectory)
if (!worktree || worktree.status !== "pending") return true if (!worktree || worktree.status !== "pending") return true
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
@@ -520,7 +517,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
restoreInput() restoreInput()
} }
pending.set(pendingKey(session.id), { abort: controller, cleanup }) pending.set(session.id, { abort: controller, cleanup })
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => { const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
if (controller.signal.aborted) { if (controller.signal.aborted) {
@@ -547,13 +544,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}, timeoutMs) }, timeoutMs)
}) })
const result = await Promise.race([WorktreeState.wait(sdk.scope, sessionDirectory), abortWait, timeout]).finally( const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => {
() => {
if (timer.id === undefined) return if (timer.id === undefined) return
clearTimeout(timer.id) clearTimeout(timer.id)
}, })
) pending.delete(session.id)
pending.delete(pendingKey(session.id))
if (controller.signal.aborted) return false if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message) if (result.status === "failed") throw new Error(result.message)
return true return true
@@ -562,13 +557,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
void sendFollowupDraft({ void sendFollowupDraft({
client, client,
sync, sync,
serverSync, globalSync,
draft, draft,
messageID, messageID,
optimisticBusy: sessionDirectory === projectDirectory, optimisticBusy: sessionDirectory === projectDirectory,
before: waitForWorktree, before: waitForWorktree,
}).catch((err) => { }).catch((err) => {
pending.delete(pendingKey(session.id)) pending.delete(session.id)
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" }) sync.set("session_status", session.id, { type: "idle" })
} }
@@ -1,59 +0,0 @@
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { type Component, Show } from "solid-js"
import { useServerManagementController } from "@/components/dialog-select-server"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
export const ServerRowMenu: Component<{
server: ServerConnection.Any
controller: ReturnType<typeof useServerManagementController>
onEdit: (server: ServerConnection.Http) => void
open?: boolean
onOpenChange?: (open: boolean) => void
}> = (props) => {
const language = useLanguage()
const key = ServerConnection.key(props.server)
const builtin = ServerConnection.builtin(props.server)
const isDefault = () => props.controller.defaultKey() === key
return (
<MenuV2 gutter={4} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
<MenuV2.Trigger
as={IconButtonV2}
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
aria-label={language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
<MenuV2.Item
disabled={builtin || props.server.type !== "http"}
onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
>
{language.t("dialog.server.menu.edit")}
</MenuV2.Item>
<Show when={props.controller.canDefault() && !isDefault()}>
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
{language.t("dialog.server.menu.default")}
</MenuV2.Item>
</Show>
<Show when={props.controller.canDefault() && isDefault()}>
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
{language.t("dialog.server.menu.defaultRemove")}
</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
{language.t("dialog.server.menu.delete")}
</MenuV2.Item>
</MenuV2.Group>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
)
}
@@ -52,7 +52,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}), }),
) )
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()])) const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
const context = createMemo(() => metrics().context) const context = createMemo(() => metrics().context)
const cost = createMemo(() => { const cost = createMemo(() => {
return usd().format(metrics().totalCost) return usd().format(metrics().totalCost)
@@ -3,4 +3,3 @@ export { SessionContextTab } from "./session-context-tab"
export { SortableTab, FileVisual } from "./session-sortable-tab" export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTerminalTab } from "./session-sortable-terminal-tab" export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view" export { NewSessionView } from "./session-new-view"
export { NewSessionDesignView } from "./session-new-design-view"
@@ -132,7 +132,7 @@ export function SessionContextTab() {
}), }),
) )
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()])) const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
const ctx = createMemo(() => metrics().context) const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.intl())) const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
@@ -5,7 +5,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Keybind } from "@opencode-ai/ui/keybind" import { Keybind } from "@opencode-ai/ui/keybind"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@/utils/toast" import { showToast } from "@opencode-ai/ui/toast"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
@@ -24,9 +24,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
import { messageAgentColor } from "@/utils/agent" import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { StatusPopover, StatusPopoverV2 } from "../status-popover" import { StatusPopover } from "../status-popover"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
const OPEN_APPS = [ const OPEN_APPS = [
"vscode", "vscode",
@@ -155,11 +153,11 @@ export function SessionHeader() {
}) })
const hotkey = createMemo(() => command.keybind("file.open")) const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform)) const os = createMemo(() => detectOS(platform))
const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns()) const isDesktopBeta = platform.platform === "desktop" && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true)) const search = createMemo(() => !isDesktopBeta || settings.general.showSearch())
const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true)) const tree = createMemo(() => !isDesktopBeta || settings.general.showFileTree())
const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true)) const term = createMemo(() => !isDesktopBeta || settings.general.showTerminal())
const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true)) const status = createMemo(() => !isDesktopBeta || settings.general.showStatus())
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true, finder: true,
@@ -233,14 +231,6 @@ export function SessionHeader() {
const tint = createMemo(() => const tint = createMemo(() =>
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent), messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
) )
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
statusVisible: status(),
statusLabel: language.t("status.popover.trigger"),
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: command.keybind("review.toggle"),
reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(),
}))
const selectApp = (app: OpenApp) => { const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return if (!options().some((item) => item.id === app)) return
@@ -321,9 +311,6 @@ export function SessionHeader() {
<Show when={rightMount()}> <Show when={rightMount()}>
{(mount) => ( {(mount) => (
<Portal mount={mount()}> <Portal mount={mount()}>
<Show
when={isDesktopV2}
fallback={
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Show when={projectDirectory()}> <Show when={projectDirectory()}>
<div class="hidden xl:flex items-center"> <div class="hidden xl:flex items-center">
@@ -508,48 +495,9 @@ export function SessionHeader() {
</div> </div>
</div> </div>
</div> </div>
}
>
<SessionHeaderV2Actions state={v2ActionsState()} />
</Show>
</Portal> </Portal>
)} )}
</Show> </Show>
</> </>
) )
} }
type SessionHeaderV2ActionsState = {
statusVisible: boolean
statusLabel: string
reviewLabel: string
reviewKeybind: string
reviewOpened: boolean
onReviewToggle: () => void
}
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
return (
<div class="flex items-center gap-2">
<Show when={props.state.statusVisible}>
<Tooltip placement="bottom" value={props.state.statusLabel}>
<StatusPopoverV2 />
</Tooltip>
</Show>
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
state={props.state.reviewOpened ? "pressed" : undefined}
onClick={props.state.onReviewToggle}
aria-label={props.state.reviewLabel}
aria-expanded={props.state.reviewOpened}
aria-controls="review-panel"
icon={<IconV2 name="sidebar-right" />}
/>
</TooltipKeybind>
</div>
)
}
@@ -1,16 +0,0 @@
import type { JSX } from "solid-js"
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
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 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" />
<div class="mt-8">{props.children}</div>
</div>
</div>
</div>
)
}
@@ -7,14 +7,13 @@ import { Switch } from "@opencode-ai/ui/switch"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform" import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync" import { useGlobalSync } from "@/context/global-sync"
import { useServerSDK } from "@/context/server-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { import {
monoDefault, monoDefault,
monoFontFamily, monoFontFamily,
@@ -87,7 +86,6 @@ export const SettingsGeneral: Component = () => {
const language = useLanguage() const language = useLanguage()
const permission = usePermission() const permission = usePermission()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog()
const params = useParams() const params = useParams()
const settings = useSettings() const settings = useSettings()
@@ -177,12 +175,12 @@ export const SettingsGeneral: Component = () => {
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync() const globalSync = useGlobalSync()
const serverSdk = useServerSDK() const globalSdk = useGlobalSDK()
const [shells] = createResource( const [shells] = createResource(
() => () =>
serverSdk.client.pty globalSdk.client.pty
.shells() .shells()
.then((res) => res.data ?? []) .then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]), .catch(() => [] as ShellOption[]),
@@ -195,22 +193,16 @@ export const SettingsGeneral: Component = () => {
{ initialValue: null as DisplayBackend | null }, { initialValue: null as DisplayBackend | null },
) )
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
{ initialValue: false },
)
onMount(() => { onMount(() => {
void theme.loadThemes() void theme.loadThemes()
}) })
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") } const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => serverSync.data.config.shell ?? "") const currentShell = createMemo(() => globalSync.data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => { const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest const list = shells.latest
const current = serverSync.data.config.shell const current = globalSync.data.config.shell
const nameCounts = new Map<string, number>() const nameCounts = new Map<string, number>()
for (const s of list) { for (const s of list) {
@@ -247,13 +239,6 @@ export const SettingsGeneral: Component = () => {
}) })
} }
const onPinchZoomChange = (checked: boolean) => {
setPinchZoom(checked)
const update = platform.setPinchZoomEnabled?.(checked)
if (!update) return
void update.catch(() => setPinchZoom(!checked))
}
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") }, { value: "system", label: language.t("theme.scheme.system") },
{ value: "light", label: language.t("theme.scheme.light") }, { value: "light", label: language.t("theme.scheme.light") },
@@ -345,7 +330,7 @@ export const SettingsGeneral: Component = () => {
onSelect={(option) => { onSelect={(option) => {
if (!option) return if (!option) return
if (option.value === currentShell()) return if (option.value === currentShell()) return
serverSync.updateConfig({ shell: option.value }) globalSync.updateConfig({ shell: option.value })
}} }}
variant="secondary" variant="secondary"
size="small" size="small"
@@ -401,24 +386,6 @@ export const SettingsGeneral: Component = () => {
/> />
</div> </div>
</SettingsRow> </SettingsRow>
<SettingsRow
title={language.t("settings.general.row.newLayoutDesigns.title")}
description={language.t("settings.general.row.newLayoutDesigns.description")}
>
<div data-action="settings-new-layout-designs">
<Switch
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => {
settings.general.setNewLayoutDesigns(checked)
if (!checked) return
void import("@/components/settings-v2").then((module) => {
dialog.show(() => <module.DialogSettings />)
})
}}
/>
</div>
</SettingsRow>
</SettingsList> </SettingsList>
</div> </div>
) )
@@ -464,6 +431,18 @@ export const SettingsGeneral: Component = () => {
</div> </div>
</SettingsRow> </SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showTerminal.title")}
description={language.t("settings.general.row.showTerminal.description")}
>
<div data-action="settings-show-terminal">
<Switch
checked={settings.general.showTerminal()}
onChange={(checked) => settings.general.setShowTerminal(checked)}
/>
</div>
</SettingsRow>
<SettingsRow <SettingsRow
title={language.t("settings.general.row.showStatus.title")} title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")} description={language.t("settings.general.row.showStatus.description")}
@@ -475,18 +454,6 @@ export const SettingsGeneral: Component = () => {
/> />
</div> </div>
</SettingsRow> </SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showCustomAgents.title")}
description={language.t("settings.general.row.showCustomAgents.description")}
>
<div data-action="settings-show-custom-agents">
<Switch
checked={settings.general.showCustomAgents()}
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
/>
</div>
</SettingsRow>
</SettingsList> </SettingsList>
</div> </div>
) )
@@ -762,45 +729,7 @@ export const SettingsGeneral: Component = () => {
</div> </div>
) )
const DisplaySection = () => ( console.log(import.meta.env)
<Show when={desktop()}>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.pinchZoom.title")}
description={language.t("settings.general.row.pinchZoom.description")}
>
<div data-action="settings-pinch-zoom">
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRow>
<Show when={linux()}>
<SettingsRow
title={
<div class="flex items-center gap-2">
<span>{language.t("settings.general.row.wayland.title")}</span>
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
<span class="text-text-weak">
<Icon name="help" size="small" />
</span>
</Tooltip>
</div>
}
description={language.t("settings.general.row.wayland.description")}
>
<div data-action="settings-wayland">
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
</div>
</SettingsRow>
</Show>
</SettingsList>
</div>
</Show>
)
return ( 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 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="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
@@ -820,9 +749,33 @@ export const SettingsGeneral: Component = () => {
<UpdatesSection /> <UpdatesSection />
<DisplaySection /> <Show when={linux()}>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
<Show when={desktop()}> <SettingsList>
<SettingsRow
title={
<div class="flex items-center gap-2">
<span>{language.t("settings.general.row.wayland.title")}</span>
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
<span class="text-text-weak">
<Icon name="help" size="small" />
</span>
</Tooltip>
</div>
}
description={language.t("settings.general.row.wayland.description")}
>
<div data-action="settings-wayland">
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
</div>
</SettingsRow>
</SettingsList>
</div>
</Show>
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
<AdvancedSection /> <AdvancedSection />
</Show> </Show>
</div> </div>
+48 -153
View File
@@ -1,29 +1,17 @@
import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js" import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast" import { showToast } from "@opencode-ai/ui/toast"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { formatKeybind, parseKeybind, useCommand } from "@/context/command" import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
const ButtonV2 = lazy(() => import("@opencode-ai/ui/v2/button-v2").then((module) => ({ default: module.ButtonV2 })))
const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon })))
const IconButtonV2 = lazy(() =>
import("@opencode-ai/ui/v2/icon-button-v2").then((module) => ({ default: module.IconButtonV2 })),
)
const TextInputV2 = lazy(() =>
import("@opencode-ai/ui/v2/text-input-v2").then((module) => ({ default: module.TextInputV2 })),
)
const SettingsListV2 = lazy(() =>
import("./settings-v2/parts/list").then((module) => ({ default: module.SettingsListV2 })),
)
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette" const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
@@ -269,7 +257,7 @@ function useKeyCapture(input: {
}) })
} }
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => { export const SettingsKeybinds: Component = () => {
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
@@ -383,109 +371,7 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
if (store.active) command.keybinds(true) if (store.active) command.keybinds(true)
}) })
const emptyResults = (
<Show when={store.filter && !hasResults()}>
<div
classList={{
"flex flex-col items-center justify-center py-12 text-center": !props.v2,
"settings-v2-shortcuts-status": props.v2,
}}
>
<span
classList={{
"text-14-regular text-text-weak": !props.v2,
}}
>
{language.t("settings.shortcuts.search.empty")}
</span>
<Show when={store.filter}>
<span
classList={{
"text-14-regular text-text-strong mt-1": !props.v2,
"settings-v2-shortcuts-status-filter": props.v2,
}}
>
&quot;{store.filter}&quot;
</span>
</Show>
</div>
</Show>
)
const List = props.v2 ? SettingsListV2 : SettingsList
const groups = (
<div
classList={{
"settings-v2-shortcuts flex flex-col gap-8": props.v2,
"flex flex-col gap-8 max-w-[720px]": !props.v2,
}}
>
<For each={GROUPS}>
{(group) => (
<Show when={(filtered().get(group) ?? []).length > 0}>
<div
classList={{
"settings-v2-section": props.v2,
"flex flex-col gap-1": !props.v2,
}}
>
<h3
classList={{
"settings-v2-section-title": props.v2,
"text-14-medium text-text-strong pb-2": !props.v2,
}}
>
{language.t(groupKey[group])}
</h3>
<List>
<For each={filtered().get(group) ?? []}>
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span
classList={{
"text-14-regular text-text-strong": !props.v2,
}}
>
{title(id)}
</span>
<button
type="button"
data-keybind-id={id}
classList={{
"settings-v2-keybind-button": props.v2,
"settings-v2-keybind-button--active": props.v2 && store.active === id,
"h-8 px-3 rounded-md text-12-regular": !props.v2,
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
!props.v2 && store.active !== id,
"border border-border-weak-base bg-surface-inset-base text-text-weak":
!props.v2 && store.active === id,
}}
onClick={() => start(id)}
>
<Show
when={store.active === id}
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
</Show>
</button>
</div>
)}
</For>
</List>
</div>
</Show>
)}
</For>
{emptyResults}
</div>
)
return ( return (
<Show
when={props.v2}
fallback={
<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 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="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]"> <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
@@ -516,45 +402,54 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
</div> </div>
</div> </div>
</div> </div>
{groups}
</div> <div class="flex flex-col gap-8 max-w-[720px]">
} <For each={GROUPS}>
> {(group) => (
<> <Show when={(filtered().get(group) ?? []).length > 0}>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked"> <div class="flex flex-col gap-1">
<div class="settings-v2-tab-header-row"> <h3 class="text-14-medium text-text-strong pb-2">{language.t(groupKey[group])}</h3>
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2> <SettingsList>
<ButtonV2 variant="ghost" onClick={resetAll} disabled={!hasOverrides()}> <For each={filtered().get(group) ?? []}>
{language.t("settings.shortcuts.reset.button")} {(id) => (
</ButtonV2> <div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
</div> <span class="text-14-regular text-text-strong">{title(id)}</span>
<div class="settings-v2-tab-search"> <button
<TextInputV2
type="search"
appearance="base"
value={store.filter}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.shortcuts.search.placeholder")}
/>
<Show when={store.filter}>
<IconButtonV2
type="button" type="button"
variant="ghost-muted" data-keybind-id={id}
size="small" classList={{
class="settings-v2-tab-search-clear" "h-8 px-3 rounded-md text-12-regular": true,
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />} "bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
onClick={() => setStore("filter", "")} store.active !== id,
/> "border border-border-weak-base bg-surface-inset-base text-text-weak": store.active === id,
}}
onClick={() => start(id)}
>
<Show
when={store.active === id}
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
</Show>
</button>
</div>
)}
</For>
</SettingsList>
</div>
</Show>
)}
</For>
<Show when={store.filter && !hasResults()}>
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{language.t("settings.shortcuts.search.empty")}</span>
<Show when={store.filter}>
<span class="text-14-regular text-text-strong mt-1">"{store.filter}"</span>
</Show>
</div>
</Show> </Show>
</div> </div>
</div> </div>
<div class="settings-v2-tab-body">{groups}</div>
</>
</Show>
) )
} }
@@ -9,7 +9,6 @@ import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers" import { popularProviders } from "@/hooks/use-providers"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number] type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
@@ -33,14 +32,6 @@ const ListEmptyState: Component<{ message: string; filter: string }> = (props) =
} }
export const SettingsModels: Component = () => { export const SettingsModels: Component = () => {
return (
<SettingsServerScope>
<SettingsModelsContent />
</SettingsServerScope>
)
}
const SettingsModelsContent: Component = () => {
const language = useLanguage() const language = useLanguage()
const models = useModels() const models = useModels()
@@ -70,10 +61,7 @@ const SettingsModelsContent: 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="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="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]"> <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2> <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"> <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" /> <Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField <TextField
@@ -2,17 +2,16 @@ import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tag } from "@opencode-ai/ui/tag" import { Tag } from "@opencode-ai/ui/tag"
import { showToast } from "@/utils/toast" import { showToast } from "@opencode-ai/ui/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js" import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useServerSync } from "@/context/server-sync" import { useGlobalSync } from "@/context/global-sync"
import { DialogConnectProvider } from "./dialog-connect-provider" import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider" import { DialogCustomProvider } from "./dialog-custom-provider"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
type ProviderSource = "env" | "api" | "config" | "custom" type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number] type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
@@ -29,18 +28,10 @@ const PROVIDER_NOTES = [
] as const ] as const
export const SettingsProviders: Component = () => { export const SettingsProviders: Component = () => {
return (
<SettingsServerScope>
<SettingsProvidersContent />
</SettingsServerScope>
)
}
const SettingsProvidersContent: Component = () => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const serverSDK = useServerSDK() const globalSDK = useGlobalSDK()
const serverSync = useServerSync() const globalSync = useGlobalSync()
const providers = useProviders() const providers = useProviders()
const connected = createMemo(() => { const connected = createMemo(() => {
@@ -83,7 +74,7 @@ const SettingsProvidersContent: Component = () => {
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => { const isConfigCustom = (providerID: string) => {
const provider = serverSync.data.config.provider?.[providerID] const provider = globalSync.data.config.provider?.[providerID]
if (!provider) return false if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false if (!provider.models || Object.keys(provider.models).length === 0) return false
@@ -91,11 +82,11 @@ const SettingsProvidersContent: Component = () => {
} }
const disableProvider = async (providerID: string, name: string) => { const disableProvider = async (providerID: string, name: string) => {
const before = serverSync.data.config.disabled_providers ?? [] const before = globalSync.data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID] const next = before.includes(providerID) ? before : [...before, providerID]
serverSync.set("config", "disabled_providers", next) globalSync.set("config", "disabled_providers", next)
await serverSync await globalSync
.updateConfig({ disabled_providers: next }) .updateConfig({ disabled_providers: next })
.then(() => { .then(() => {
showToast({ showToast({
@@ -106,7 +97,7 @@ const SettingsProvidersContent: Component = () => {
}) })
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
serverSync.set("config", "disabled_providers", before) globalSync.set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message }) showToast({ title: language.t("common.requestFailed"), description: message })
}) })
@@ -114,14 +105,14 @@ const SettingsProvidersContent: Component = () => {
const disconnect = async (providerID: string, name: string) => { const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) { if (isConfigCustom(providerID)) {
await serverSDK.client.auth.remove({ providerID }).catch(() => undefined) await globalSDK.client.auth.remove({ providerID }).catch(() => undefined)
await disableProvider(providerID, name) await disableProvider(providerID, name)
return return
} }
await serverSDK.client.auth await globalSDK.client.auth
.remove({ providerID }) .remove({ providerID })
.then(async () => { .then(async () => {
await serverSDK.client.global.dispose() await globalSDK.client.global.dispose()
showToast({ showToast({
variant: "success", variant: "success",
icon: "circle-check", icon: "circle-check",
@@ -138,9 +129,8 @@ const SettingsProvidersContent: Component = () => {
return ( 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 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="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex items-center justify-between gap-4 pt-6 pb-8 max-w-[720px]"> <div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2> <h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
<SettingsServerPicker />
</div> </div>
</div> </div>
@@ -1,106 +0,0 @@
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>
)
}
@@ -1,33 +0,0 @@
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>
)
}
@@ -1,129 +0,0 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { useLanguage } from "@/context/language"
import { type ServerConnection } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server"
import "./settings-v2.css"
export const DialogServerV2: Component<{
mode: "add" | "edit"
server?: ServerConnection.Http
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const controller = useServerManagementController({
onSelect: () => dialog.close(),
navigateOnAdd: false,
})
const [opened, setOpened] = createSignal(false)
onMount(() => {
if (props.mode === "add") controller.startAdd()
if (props.mode === "edit" && props.server) controller.startEdit(props.server)
setOpened(true)
})
onCleanup(() => {
controller.resetForm()
})
createEffect(() => {
if (!opened()) return
if (controller.isFormMode()) return
dialog.close()
})
const keyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
controller.submitForm()
}
const title = () =>
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
const submitLabel = () => {
if (controller.formBusy()) return language.t("dialog.server.add.checking")
if (props.mode === "add") return language.t("dialog.server.add.button")
return language.t("common.save")
}
return (
<Dialog title={title()} fit class="settings-v2-server-dialog">
<div class="flex w-full min-w-0 flex-1 flex-col px-4">
<div class="flex w-full min-w-0 flex-col gap-6">
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label>
<TextInputV2
type="text"
appearance="large"
class="!w-full self-stretch"
value={controller.formValue()}
placeholder={language.t("dialog.server.add.placeholder")}
invalid={!!controller.formError()}
disabled={controller.formBusy()}
autofocus
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
onKeyDown={keyDown}
/>
<Show when={controller.formError()}>
<span class="settings-v2-server-dialog-error">{controller.formError()}</span>
</Show>
</div>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.name")}</label>
<TextInputV2
type="text"
appearance="large"
class="!w-full self-stretch"
value={controller.formName()}
placeholder={language.t("dialog.server.add.namePlaceholder")}
disabled={controller.formBusy()}
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
<div class="grid w-full min-w-0 grid-cols-2 gap-4">
<div class="flex min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.username")}</label>
<TextInputV2
type="text"
appearance="large"
class="!w-full self-stretch"
value={controller.formUsername()}
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
disabled={controller.formBusy()}
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
<div class="flex min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.password")}</label>
<TextInputV2
type="password"
appearance="large"
class="!w-full self-stretch"
value={controller.formPassword()}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
disabled={controller.formBusy()}
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
</div>
</div>
</div>
<DialogFooter>
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
{submitLabel()}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
@@ -1,82 +0,0 @@
import { Component } from "solid-js"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { SettingsGeneralV2 } from "./general"
import { SettingsKeybinds } from "../settings-keybinds"
import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models"
import "./settings-v2.css"
import { SettingsServersV2 } from "./servers"
export const DialogSettings: Component = () => {
const language = useLanguage()
const platform = usePlatform()
return (
<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">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<TabsV2.Trigger value="general">
<Icon name="sliders" />
{language.t("settings.tab.general")}
</TabsV2.Trigger>
<TabsV2.Trigger value="shortcuts">
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</TabsV2.Trigger>
</div>
</div>
<div class="flex flex-col gap-1.5">
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<TabsV2.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</TabsV2.Trigger>
<TabsV2.Trigger value="providers">
<Icon name="providers" />
{language.t("settings.providers.title")}
</TabsV2.Trigger>
<TabsV2.Trigger value="models">
<Icon name="models" />
{language.t("settings.models.title")}
</TabsV2.Trigger>
</div>
</div>
</div>
</div>
<div class="settings-v2-nav-footer">
<span>{language.t("app.name.desktop")}</span>
<span>v{platform.version}</span>
</div>
</div>
</TabsV2.List>
<TabsV2.Content value="general" class="settings-v2-panel">
<SettingsGeneralV2 />
</TabsV2.Content>
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds v2 />
</TabsV2.Content>
<TabsV2.Content value="servers" class="settings-v2-panel">
<SettingsServersV2 />
</TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 />
</TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 />
</TabsV2.Content>
</TabsV2>
</Dialog>
)
}
@@ -1,846 +0,0 @@
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import {
monoDefault,
monoFontFamily,
monoInput,
sansDefault,
sansFontFamily,
sansInput,
terminalDefault,
terminalFontFamily,
terminalInput,
useSettings,
} from "@/context/settings"
import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "../link"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import "./settings-v2.css"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined,
run: 0,
}
type ThemeOption = {
id: string
name: string
}
type ShellOption = {
path: string
name: string
acceptable: boolean
}
type ShellSelectOption = {
id: string
value: string
label: string
}
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const stopDemoSound = () => {
demoSoundState.run += 1
if (demoSoundState.cleanup) {
demoSoundState.cleanup()
}
clearTimeout(demoSoundState.timeout)
demoSoundState.cleanup = undefined
}
const playDemoSound = (id: string | undefined) => {
stopDemoSound()
if (!id) return
const run = ++demoSoundState.run
demoSoundState.timeout = setTimeout(() => {
void playSoundById(id).then((cleanup) => {
if (demoSoundState.run !== run) {
cleanup?.()
return
}
demoSoundState.cleanup = cleanup
})
}, 100)
}
export const SettingsGeneralV2: Component = () => {
const theme = useTheme()
const language = useLanguage()
const permission = usePermission()
const platform = usePlatform()
const dialog = useDialog()
const params = useParams()
const settings = useSettings()
const [store, setStore] = createStore({
checking: false,
})
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir))
const accepting = createMemo(() => {
const value = dir()
if (!value) return false
if (!params.id) return permission.isAutoAcceptingDirectory(value)
return permission.isAutoAccepting(params.id, value)
})
const toggleAccept = (checked: boolean) => {
const value = dir()
if (!value) return
if (!params.id) {
if (permission.isAutoAcceptingDirectory(value) === checked) return
permission.toggleAutoAcceptDirectory(value)
return
}
if (checked) {
permission.enableAutoAccept(params.id, value)
return
}
permission.disableAutoAccept(params.id, value)
}
const desktop = createMemo(() => platform.platform === "desktop")
const check = () => {
if (!platform.checkUpdate) return
setStore("checking", true)
void platform
.checkUpdate()
.then((result) => {
if (!result.updateAvailable) {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("settings.updates.toast.latest.title"),
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
})
return
}
const actions = platform.updateAndRestart
? [
{
label: language.t("toast.update.action.installRestart"),
onClick: async () => {
await platform.updateAndRestart!()
},
},
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss" as const,
},
]
: [
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss" as const,
},
]
showToast({
persistent: true,
icon: "download",
title: language.t("toast.update.title"),
description: language.t("toast.update.description", { version: result.version ?? "" }),
actions,
})
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => setStore("checking", false))
}
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const [shells] = createResource(
() =>
serverSdk.client.pty
.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
const [displayBackend, { refetch: refetchDisplayBackend }] = createResource(
() => (linux() && platform.getDisplayBackend ? true : false),
() => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null),
{ initialValue: null as DisplayBackend | null },
)
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
{ initialValue: false },
)
onMount(() => {
void theme.loadThemes()
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = serverSync.data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
}
const options = [
autoOption,
...list.map((s) => {
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
const text = ambiguousName ? s.path : s.name
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
return {
id: s.path,
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
value: ambiguousName ? s.path : s.name,
label,
}
}),
]
if (current && !options.some((o) => o.value === current)) {
options.push({ id: current, value: current, label: current })
}
return options
})
const onDisplayBackendChange = (checked: boolean) => {
const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
if (!update) return
void update.finally(() => {
void refetchDisplayBackend()
})
}
const onPinchZoomChange = (checked: boolean) => {
setPinchZoom(checked)
const update = platform.setPinchZoomEnabled?.(checked)
if (!update) return
void update.catch(() => setPinchZoom(!checked))
}
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
{ value: "light", label: language.t("theme.scheme.light") },
{ value: "dark", label: language.t("theme.scheme.dark") },
])
const languageOptions = createMemo(() =>
language.locales.map((locale) => ({
value: locale,
label: language.label(locale),
})),
)
const noneSound = { id: "none", label: "sound.option.none" } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const mono = () => monoInput(settings.appearance.font())
const sans = () => sansInput(settings.appearance.uiFont())
const terminal = () => terminalInput(settings.appearance.terminalFont())
const soundSelectProps = (
enabled: () => boolean,
current: () => string,
setEnabled: (value: boolean) => void,
set: (id: string) => void,
) => ({
options: soundOptions,
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
value: (o: (typeof soundOptions)[number]) => o.id,
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
playDemoSound(option.id === "none" ? undefined : option.id)
},
onSelect: (option: (typeof soundOptions)[number] | null) => {
if (!option) return
if (option.id === "none") {
setEnabled(false)
stopDemoSound()
return
}
setEnabled(true)
set(option.id)
playDemoSound(option.id)
},
})
const GeneralSection = () => (
<div class="settings-v2-section">
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<SelectV2
appearance="inline"
data-action="settings-language"
options={languageOptions()}
placement="bottom-end"
gutter={6}
current={languageOptions().find((o) => o.value === language.locale())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && language.setLocale(option.value)}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}
>
<div data-action="settings-auto-accept-permissions">
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
placement="bottom-end"
gutter={6}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync.updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")}
>
<div data-action="settings-feed-reasoning-summaries">
<Switch
checked={settings.general.showReasoningSummaries()}
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
>
<div data-action="settings-feed-shell-tool-parts-expanded">
<Switch
checked={settings.general.shellToolPartsExpanded()}
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.editToolPartsExpanded.title")}
description={language.t("settings.general.row.editToolPartsExpanded.description")}
>
<div data-action="settings-feed-edit-tool-parts-expanded">
<Switch
checked={settings.general.editToolPartsExpanded()}
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showSessionProgressBar.title")}
description={language.t("settings.general.row.showSessionProgressBar.description")}
>
<div data-action="settings-show-session-progress-bar">
<Switch
checked={settings.general.showSessionProgressBar()}
onChange={(checked) => settings.general.setShowSessionProgressBar(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.newLayoutDesigns.title")}
description={language.t("settings.general.row.newLayoutDesigns.description")}
>
<div data-action="settings-new-layout-designs">
<Switch
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => {
settings.general.setNewLayoutDesigns(checked)
if (checked) return
void import("@/components/dialog-settings").then((module) => {
dialog.show(() => <module.DialogSettings />)
})
}}
/>
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const AdvancedSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.showFileTree.title")}
description={language.t("settings.general.row.showFileTree.description")}
>
<div data-action="settings-show-file-tree">
<Switch
checked={settings.general.showFileTree()}
onChange={(checked) => settings.general.setShowFileTree(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showNavigation.title")}
description={language.t("settings.general.row.showNavigation.description")}
>
<div data-action="settings-show-navigation">
<Switch
checked={settings.general.showNavigation()}
onChange={(checked) => settings.general.setShowNavigation(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")}
>
<div data-action="settings-show-search">
<Switch
checked={settings.general.showSearch()}
onChange={(checked) => settings.general.setShowSearch(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showTerminal.title")}
description={language.t("settings.general.row.showTerminal.description")}
>
<div data-action="settings-show-terminal">
<Switch
checked={settings.general.showTerminal()}
onChange={(checked) => settings.general.setShowTerminal(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
>
<div data-action="settings-show-status">
<Switch
checked={settings.general.showStatus()}
onChange={(checked) => settings.general.setShowStatus(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showCustomAgents.title")}
description={language.t("settings.general.row.showCustomAgents.description")}
>
<div data-action="settings-show-custom-agents">
<Switch
checked={settings.general.showCustomAgents()}
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
/>
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const AppearanceSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<SelectV2
appearance="inline"
data-action="settings-color-scheme"
options={colorSchemeOptions()}
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
placement="bottom-end"
gutter={6}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
onHighlight={(option) => {
if (!option) return
theme.previewColorScheme(option.value)
return () => theme.cancelPreview()
}}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<Link class="settings-v2-link" href="https://opencode.ai/docs/themes/">
{language.t("common.learnMore")}
</Link>
</>
}
>
<SelectV2
appearance="inline"
data-action="settings-theme"
options={themeOptions()}
current={themeOptions().find((o) => o.id === theme.themeId())}
placement="bottom-end"
gutter={6}
value={(o) => o.id}
label={(o) => o.name}
onSelect={(option) => {
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
}}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.uiFont.title")}
description={language.t("settings.general.row.uiFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action="settings-ui-font"
type="text"
appearance="base"
value={sans()}
onInput={(event) => settings.appearance.setUIFont(event.currentTarget.value)}
placeholder={sansDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.general.row.uiFont.title")}
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.font.title")}
description={language.t("settings.general.row.font.description")}
>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action="settings-code-font"
type="text"
appearance="base"
value={mono()}
onInput={(event) => settings.appearance.setFont(event.currentTarget.value)}
placeholder={monoDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.general.row.font.title")}
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.terminalFont.title")}
description={language.t("settings.general.row.terminalFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action="settings-terminal-font"
type="text"
appearance="base"
value={terminal()}
onInput={(event) => settings.appearance.setTerminalFont(event.currentTarget.value)}
placeholder={terminalDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.general.row.terminalFont.title")}
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
/>
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const NotificationsSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
>
<div data-action="settings-notifications-agent">
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.notifications.permissions.title")}
description={language.t("settings.general.notifications.permissions.description")}
>
<div data-action="settings-notifications-permissions">
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.notifications.errors.title")}
description={language.t("settings.general.notifications.errors.description")}
>
<div data-action="settings-notifications-errors">
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const SoundsSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
<SelectV2
appearance="inline"
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agentEnabled(),
() => settings.sounds.agent(),
(value) => settings.sounds.setAgentEnabled(value),
(id) => settings.sounds.setAgent(id),
)}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<SelectV2
appearance="inline"
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissionsEnabled(),
() => settings.sounds.permissions(),
(value) => settings.sounds.setPermissionsEnabled(value),
(id) => settings.sounds.setPermissions(id),
)}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<SelectV2
appearance="inline"
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errorsEnabled(),
() => settings.sounds.errors(),
(value) => settings.sounds.setErrorsEnabled(value),
(id) => settings.sounds.setErrors(id),
)}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const UpdatesSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
>
<div data-action="settings-updates-startup">
<Switch
checked={settings.updates.startup()}
disabled={!platform.checkUpdate}
onChange={(checked) => settings.updates.setStartup(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
>
<div data-action="settings-release-notes">
<Switch
checked={settings.general.releaseNotes()}
onChange={(checked) => settings.general.setReleaseNotes(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<ButtonV2 size="normal" variant="neutral" disabled={store.checking || !platform.checkUpdate} onClick={check}>
{store.checking
? language.t("settings.updates.action.checking")
: language.t("settings.updates.action.checkNow")}
</ButtonV2>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const DisplaySection = () => (
<Show when={desktop()}>
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.display")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.pinchZoom.title")}
description={language.t("settings.general.row.pinchZoom.description")}
>
<div data-action="settings-pinch-zoom">
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRowV2>
<Show when={linux()}>
<SettingsRowV2
title={
<div class="flex items-center gap-2">
<span>{language.t("settings.general.row.wayland.title")}</span>
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
<span class="text-text-weak">
<Icon name="help" size="small" />
</span>
</Tooltip>
</div>
}
description={language.t("settings.general.row.wayland.description")}
>
<div data-action="settings-wayland">
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
</div>
</SettingsRowV2>
</Show>
</SettingsListV2>
</div>
</Show>
)
return (
<>
<div class="settings-v2-tab-header">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
</div>
<div class="settings-v2-tab-body">
<GeneralSection />
<AppearanceSection />
<NotificationsSection />
<SoundsSection />
<UpdatesSection />
<DisplaySection />
<Show when={desktop()}>
<AdvancedSection />
</Show>
</div>
</>
)
}
@@ -1 +0,0 @@
export { DialogSettings } from "./dialog-settings-v2"
@@ -1,138 +0,0 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import "./settings-v2.css"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
const PROVIDER_ICON_SIZE = 16
export const SettingsModelsV2: Component = () => {
const language = useLanguage()
const models = useModels()
const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(),
key: (x) => `${x.provider.id}:${x.id}`,
filterKeys: ["provider.name", "name", "id"],
sortBy: (a, b) => a.name.localeCompare(b.name),
groupBy: (x) => x.provider.id,
sortGroupsBy: (a, b) => {
const aIndex = popularProviders.indexOf(a.category)
const bIndex = popularProviders.indexOf(b.category)
const aPopular = aIndex >= 0
const bPopular = bIndex >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
if (aPopular && bPopular) return aIndex - bIndex
const aName = a.items[0].provider.name
const bName = b.items[0].provider.name
return aName.localeCompare(bName)
},
})
return (
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
appearance="base"
value={list.filter()}
onInput={(event) => list.onInput(event.currentTarget.value)}
placeholder={language.t("dialog.model.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("dialog.model.search.placeholder")}
/>
<Show when={list.filter()}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => list.clear()}
/>
</Show>
</div>
</div>
<div class="settings-v2-tab-body settings-v2-models">
<Show
when={!list.grouped.loading}
fallback={
<div class="settings-v2-models-status">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<Show
when={list.flat().length > 0}
fallback={
<div class="settings-v2-models-status">
<span>{language.t("dialog.model.empty")}</span>
<Show when={list.filter()}>
<span class="settings-v2-models-status-filter">&quot;{list.filter()}&quot;</span>
</Show>
</div>
}
>
<For each={list.grouped.latest}>
{(group) => (
<div class="settings-v2-section" data-component="settings-models-provider">
<div class="settings-v2-models-group-header">
<ProviderIcon
id={group.category}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-models-provider-icon shrink-0"
/>
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
</div>
<SettingsListV2>
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<SettingsRowV2 title={item.name} description="">
<div>
<Switch
checked={models.visible(key)}
onChange={(checked) => {
models.setVisibility(key, checked)
}}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRowV2>
)
}}
</For>
</SettingsListV2>
</div>
)}
</For>
</Show>
</Show>
</div>
</>
)
}
@@ -1,6 +0,0 @@
import type { Component, JSX } from "solid-js"
import "../settings-v2.css"
export const SettingsListV2: Component<{ children: JSX.Element }> = (props) => {
return <div data-component="settings-v2-list">{props.children}</div>
}
@@ -1,20 +0,0 @@
import type { Component, JSX } from "solid-js"
import "../settings-v2.css"
export interface SettingsRowV2Props {
title: string | JSX.Element
description: string | JSX.Element
children: JSX.Element
}
export const SettingsRowV2: Component<SettingsRowV2Props> = (props) => {
return (
<div data-component="settings-v2-row">
<div data-slot="settings-v2-row-copy">
<div data-slot="settings-v2-row-title">{props.title}</div>
<div data-slot="settings-v2-row-description">{props.description}</div>
</div>
<div data-slot="settings-v2-row-control">{props.children}</div>
</div>
)
}
@@ -1,263 +0,0 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider } from "../dialog-connect-provider"
import { DialogSelectProvider } from "../dialog-select-provider"
import { DialogCustomProvider } from "../dialog-custom-provider"
import { SettingsListV2 } from "./parts/list"
import "./settings-v2.css"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
const PROVIDER_NOTES = [
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
{ match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" },
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
{ match: (id: string) => id === "google", key: "dialog.provider.google.note" },
{ match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" },
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
] as const
const PROVIDER_ICON_SIZE = 16
export const SettingsProvidersV2: Component = () => {
const dialog = useDialog()
const language = useLanguage()
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const providers = useProviders()
const connected = createMemo(() => {
return providers
.connected()
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
})
const popular = createMemo(() => {
const connectedIDs = new Set(connected().map((p) => p.id))
const items = providers
.popular()
.filter((p) => !connectedIDs.has(p.id))
.slice()
items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id))
return items
})
const source = (item: ProviderItem): ProviderSource | undefined => {
if (!("source" in item)) return
const value = item.source
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
return
}
const type = (item: ProviderItem) => {
const current = source(item)
if (current === "env") return language.t("settings.providers.tag.environment")
if (current === "api") return language.t("provider.connect.method.apiKey")
if (current === "config") {
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.config")
}
if (current === "custom") return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.other")
}
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
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
return true
}
const disableProvider = async (providerID: string, name: string) => {
const before = serverSync.data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync.set("config", "disabled_providers", next)
await serverSync
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
})
})
.catch((err: unknown) => {
serverSync.set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSdk.client.auth.remove({ providerID }).catch(() => undefined)
await disableProvider(providerID, name)
return
}
await serverSdk.client.auth
.remove({ providerID })
.then(async () => {
await serverSdk.client.global.dispose()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
})
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
return (
<>
<div class="settings-v2-tab-header">
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
</div>
<div class="settings-v2-tab-body settings-v2-providers">
<div class="settings-v2-section" data-component="connected-providers-section">
<h3 class="settings-v2-section-title">{language.t("settings.providers.section.connected")}</h3>
<SettingsListV2>
<Show
when={connected().length > 0}
fallback={
<div class="settings-v2-provider-empty">{language.t("settings.providers.connected.empty")}</div>
}
>
<For each={connected()}>
{(item) => (
<div class="settings-v2-provider-row group">
<div class="settings-v2-provider-lead">
<ProviderIcon
id={item.id}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-provider-icon shrink-0"
/>
<div class="settings-v2-provider-main">
<span class="settings-v2-provider-name truncate">{item.name}</span>
<Tag>{type(item)}</Tag>
</div>
</div>
<Show
when={canDisconnect(item)}
fallback={
<span class="settings-v2-provider-env-hint">
{language.t("settings.providers.connected.environmentDescription")}
</span>
}
>
<ButtonV2 size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
</ButtonV2>
</Show>
</div>
)}
</For>
</Show>
</SettingsListV2>
</div>
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.providers.section.popular")}</h3>
<SettingsListV2>
<For each={popular()}>
{(item) => (
<div class="settings-v2-provider-row">
<div class="settings-v2-provider-lead">
<ProviderIcon
id={item.id}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-provider-icon shrink-0"
/>
<div class="settings-v2-provider-copy">
<div class="settings-v2-provider-main">
<span class="settings-v2-provider-name">{item.name}</span>
<Show when={item.id === "opencode" || item.id === "opencode-go"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
</div>
<Show when={note(item.id)}>
{(key) => <p class="settings-v2-provider-description">{language.t(key())}</p>}
</Show>
</div>
</div>
<ButtonV2
size="normal"
variant="neutral"
icon="plus"
onClick={() => {
dialog.show(() => <DialogConnectProvider provider={item.id} />)
}}
>
{language.t("common.connect")}
</ButtonV2>
</div>
)}
</For>
<div class="settings-v2-provider-row" data-component="custom-provider-section">
<div class="settings-v2-provider-lead">
<ProviderIcon
id="synthetic"
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-provider-icon shrink-0"
/>
<div class="settings-v2-provider-copy">
<div class="settings-v2-provider-main">
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>
<p class="settings-v2-provider-description">{language.t("settings.providers.custom.description")}</p>
</div>
</div>
<ButtonV2
size="normal"
variant="neutral"
icon="plus"
onClick={() => {
dialog.show(() => <DialogCustomProvider back="close" />)
}}
>
{language.t("common.connect")}
</ButtonV2>
</div>
</SettingsListV2>
<button
type="button"
class="settings-v2-providers-view-all"
onClick={() => {
dialog.show(() => <DialogSelectProvider />)
}}
>
{language.t("dialog.provider.viewAll")}
</button>
</div>
</div>
</>
)
}
@@ -1,143 +0,0 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import fuzzysort from "fuzzysort"
import { type Component, For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server"
import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list"
import { isWslServer, useFilteredWslServers, WslAddServerButton, WslServerSettings } from "@/wsl/settings"
import "./settings-v2.css"
export const SettingsServersV2: Component = () => {
const dialog = useDialog()
const language = useLanguage()
const controller = useServerManagementController()
const [store, setStore] = createStore({ filter: "" })
const wslServers = useFilteredWslServers(() => store.filter)
const showSearch = createMemo(
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
)
const filtered = createMemo(() => {
const items = controller.sortedItems().filter((item) => !isWslServer(item))
const query = store.filter.trim()
if (!query) return items
return fuzzysort
.go(query, items, {
keys: [(item) => serverName(item), (item) => item.http.url],
})
.map((result) => result.obj)
})
const openAdd = () => {
dialog.push(() => <DialogServerV2 mode="add" />)
}
const openEdit = (server: ServerConnection.Http) => {
dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
}
return (
<>
<div
class="settings-v2-tab-header settings-v2-servers-header"
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
>
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
<ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
{language.t("dialog.server.add.button")}
</ButtonV2>
<WslAddServerButton />
</div>
<Show when={showSearch()}>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
appearance="base"
value={store.filter}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("dialog.server.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("dialog.server.search.placeholder")}
/>
<Show when={store.filter}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
</Show>
</div>
</Show>
</div>
<div class="settings-v2-tab-body settings-v2-servers">
<Show
when={filtered().length > 0 || wslServers().length > 0}
fallback={
<div class="settings-v2-servers-status">
<span>{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")}</span>
<Show when={store.filter}>
<span class="settings-v2-servers-status-filter">&quot;{store.filter}&quot;</span>
</Show>
</div>
}
>
<SettingsListV2>
<WslServerSettings controller={controller} servers={wslServers} />
<For each={filtered()}>
{(item) => {
const key = ServerConnection.key(item)
const health = () => controller.status()[key]
const isDefault = () => controller.defaultKey() === key
return (
<div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead">
<ServerHealthIndicator health={health()} />
<div class="settings-v2-servers-copy">
<span class="settings-v2-servers-name">{serverName(item)}</span>
<span class="settings-v2-servers-meta">
<Show when={health()?.version}>v{health()?.version}</Show>
<Show when={health()?.version && item.type === "http"}> </Show>
<Show
when={item.type === "http" && item.http.username}
fallback={<Show when={item.type === "http"}>{language.t("server.row.noUsername")}</Show>}
>
{item.http.username}
</Show>
</span>
</div>
</div>
<div class="settings-v2-servers-actions">
<Show when={controller.canDefault() && isDefault()}>
<Tag>{language.t("dialog.server.status.default")}</Tag>
</Show>
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
</div>
</div>
)
}}
</For>
</SettingsListV2>
</Show>
</div>
</>
)
}
@@ -1,654 +0,0 @@
@import "@opencode-ai/ui/v2/text-input-v2.css";
@import "@opencode-ai/ui/v2/button-v2.css";
[data-component="tabs-v2"][data-variant="settings"] {
height: 100%;
}
[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;
}
.settings-v2-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
}
.settings-v2-panel::-webkit-scrollbar {
display: none;
}
.settings-v2-tab-header {
position: sticky;
top: 0;
z-index: 10;
padding: 40px 40px 32px;
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
}
.settings-v2-tab-title {
font-size: 15px;
font-weight: 640;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-v2-tab-body {
display: flex;
flex-direction: column;
gap: 36px;
width: 100%;
padding: 0 40px 40px;
}
[data-slot="settings-v2-row-description"] a.settings-v2-link {
color: var(--v2-text-text-accent);
cursor: pointer;
text-decoration: none;
}
[data-slot="settings-v2-row-description"] a.settings-v2-link:hover {
color: var(--v2-text-text-accent-hover);
}
.settings-v2-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.settings-v2-section-title {
padding-bottom: 8px;
font-size: 15px;
font-weight: 640;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-v2-section-title + [data-component="settings-v2-list"] {
margin-top: -4px;
margin-bottom: 0;
}
[data-component="settings-v2-list"] {
border-radius: 8px;
background-color: var(--v2-background-bg-layer-01);
padding-inline: 20px;
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
}
[data-component="settings-v2-row"] {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
padding-block: 20px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
[data-component="settings-v2-row"]:last-child {
border-bottom: none;
}
@media (min-width: 640px) {
[data-component="settings-v2-row"] {
flex-wrap: nowrap;
}
}
[data-slot="settings-v2-row-copy"] {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 8px;
}
[data-slot="settings-v2-row-title"] {
font-style: normal;
font-size: 13px;
font-weight: 530;
line-height: 1;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
font-variation-settings: "slnt" 0;
}
[data-slot="settings-v2-row-description"] {
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
}
[data-slot="settings-v2-row-control"] {
display: flex;
width: 100%;
justify-content: flex-end;
}
[data-slot="settings-v2-row-control"] > div:has([data-component="switch"]),
[data-slot="settings-v2-row-control"] > [data-component="switch"] {
display: inline-flex;
align-items: center;
padding: 4px;
}
@media (min-width: 640px) {
[data-slot="settings-v2-row-control"] {
width: auto;
flex-shrink: 0;
}
}
[data-slot="settings-v2-row-control"] [data-component="text-input-v2"] {
width: 100%;
}
[data-component="dialog-v2"][data-variant="settings"] [data-component="select-v2-root"] {
width: fit-content;
max-width: 100%;
}
[data-component="dialog-v2"][data-variant="settings"] [data-component="button-v2"] {
width: fit-content;
max-width: 100%;
}
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-list"] {
background-color: var(--v2-background-bg-layer-01);
}
.settings-v2-nav-footer {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px 0 4px 4px;
}
.settings-v2-nav-footer > span {
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-faint);
}
.settings-v2-legacy-panel {
height: 100%;
overflow: hidden;
}
.settings-v2-legacy-panel [data-component="dialog"] {
display: contents;
}
.settings-v2-provider-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-block: 20px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-provider-row:last-child {
border-bottom: none;
}
@media (min-width: 640px) {
.settings-v2-provider-row {
flex-wrap: nowrap;
}
}
.settings-v2-providers [data-component="provider-icon"] {
color: var(--v2-icon-icon-base);
}
.settings-v2-provider-lead {
display: flex;
min-width: 0;
flex: 1;
align-items: flex-start;
gap: 10px;
}
.settings-v2-provider-lead:not(:has(.settings-v2-provider-copy)) {
align-items: center;
}
.settings-v2-provider-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 6px;
}
.settings-v2-provider-main {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.settings-v2-provider-name {
font-size: 13px;
font-weight: 530;
line-height: 16px;
color: var(--v2-text-text-base);
}
.settings-v2-provider-description {
margin: 0;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
}
.settings-v2-provider-empty {
padding-block: 20px;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
}
.settings-v2-provider-env-hint {
padding-right: 12px;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
opacity: 0;
transition: opacity 200ms ease;
cursor: default;
}
.group:hover .settings-v2-provider-env-hint {
opacity: 1;
}
.settings-v2-providers-view-all {
margin-top: 20px;
padding: 0;
border: 0;
background: transparent;
font-size: 13px;
font-weight: 530;
line-height: 1;
color: var(--v2-text-text-accent);
cursor: pointer;
text-align: left;
}
.settings-v2-providers-view-all:hover {
color: var(--v2-text-text-accent-hover);
}
.settings-v2-tab-body.settings-v2-providers {
gap: 32px;
}
.settings-v2-tab-header:has(+ .settings-v2-tab-body.settings-v2-providers) {
padding-bottom: 32px;
}
.settings-v2-providers .settings-v2-section-title {
padding-bottom: 0;
font-size: 13px;
font-weight: 530;
line-height: 1;
}
.settings-v2-providers .settings-v2-section-title + [data-component="settings-v2-list"] {
margin-top: 16px;
}
.settings-v2-tab-header.settings-v2-tab-header--stacked {
display: flex;
flex-direction: column;
gap: 32px;
padding-bottom: 32px;
}
.settings-v2-tab-header--stacked > .settings-v2-tab-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-v2-tab-search {
position: relative;
width: 100%;
}
.settings-v2-tab-search [data-component="text-input-v2"] {
width: 100%;
}
.settings-v2-tab-search [data-slot="text-input-v2-input"] {
padding-right: 28px;
}
.settings-v2-tab-search-clear {
position: absolute;
top: 50%;
right: 6px;
z-index: 1;
transform: translateY(-50%);
}
.settings-v2-tab-body.settings-v2-models {
gap: 24px;
}
.settings-v2-models-group-header {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 8px;
}
.settings-v2-models .settings-v2-section-title {
padding-bottom: 0;
font-size: 13px;
font-weight: 530;
line-height: 16px;
}
.settings-v2-models [data-component="provider-icon"] {
color: var(--v2-icon-icon-base);
}
.settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
margin-top: 0;
}
.settings-v2-models [data-slot="settings-v2-row-description"]:empty {
display: none;
}
.settings-v2-models [data-slot="settings-v2-row-copy"] {
gap: 0;
}
.settings-v2-models [data-slot="settings-v2-row-title"] {
min-width: 0;
overflow: hidden;
font-size: 13px;
font-weight: 440;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-v2-models-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
text-align: center;
}
.settings-v2-models-status-filter {
color: var(--v2-text-text-base);
}
.settings-v2-shortcuts .settings-v2-section {
gap: 16px;
}
.settings-v2-shortcuts .settings-v2-section-title {
padding-bottom: 0;
font-size: 13px;
font-weight: 530;
line-height: 1;
}
.settings-v2-shortcuts [data-component="settings-v2-list"] {
display: flex;
flex-direction: column;
gap: 0;
padding: 20px;
border-radius: 6px;
}
.settings-v2-shortcuts [data-component="settings-v2-list"] > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 0;
padding-bottom: 0;
border-bottom: none;
}
.settings-v2-shortcuts [data-component="settings-v2-list"] > div:not(:last-child) {
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-shortcuts [data-component="settings-v2-list"] > div > span {
font-weight: 440;
font-size: 13px;
line-height: 1;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
font-variation-settings: "slnt" 0;
}
.settings-v2-keybind-button {
box-sizing: border-box;
flex-shrink: 0;
padding: 6px 8px;
margin: -6px -8px;
border: 0;
border-radius: 2px;
background: transparent;
cursor: pointer;
font-style: normal;
font-weight: 530;
font-size: 11px;
line-height: 1;
letter-spacing: 0.05px;
font-variant-numeric: tabular-nums;
font-feature-settings:
"tnum" on,
"lnum" on;
font-variation-settings: "slnt" 0;
color: var(--v2-text-text-faint);
}
.settings-v2-keybind-button:hover {
background-color: var(--v2-background-bg-layer-02);
}
.settings-v2-keybind-button:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
.settings-v2-keybind-button--active {
color: var(--v2-text-text-faint);
border-radius: 2px;
background-color: var(--v2-background-bg-layer-02);
}
.settings-v2-shortcuts-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
text-align: center;
}
.settings-v2-shortcuts-status-filter {
color: var(--v2-text-text-base);
}
.settings-v2-tab-body.settings-v2-servers {
gap: 0;
}
.settings-v2-tab-header.settings-v2-servers-header {
padding-bottom: 24px;
}
.settings-v2-servers-header .settings-v2-tab-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-v2-tab-header.settings-v2-servers-header.settings-v2-tab-header--stacked {
gap: 24px;
padding-bottom: 24px;
}
.settings-v2-servers [data-component="settings-v2-list"] {
display: flex;
flex-direction: column;
gap: 0;
padding: 20px;
border-radius: 6px;
}
.settings-v2-servers-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-v2-servers-row:not(:last-child) {
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-servers-actions {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.settings-v2-servers-lead {
display: flex;
min-width: 0;
flex: 1;
align-items: flex-start;
gap: 10px;
}
.settings-v2-servers-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 6px;
}
.settings-v2-servers-name {
font-size: 13px;
font-weight: 530;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-v2-servers-meta {
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
}
.settings-v2-servers-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
text-align: center;
}
.settings-v2-servers-status-filter {
color: var(--v2-text-text-base);
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
width: 480px;
max-width: calc(100vw - 32px);
height: auto;
border-radius: 8px;
align-items: stretch;
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-content"] {
align-items: stretch;
width: 100%;
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] {
align-items: center;
padding: 24px 24px 0;
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
align-items: stretch;
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-footer"] {
padding: 24px;
}
.settings-v2-server-dialog-label {
font-size: 13px;
font-weight: 530;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-v2-server-dialog-error {
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-state-fg-danger);
}
@@ -4,21 +4,21 @@ import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation, useQueryClient } from "@tanstack/solid-query" import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { showToast } from "@/utils/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { ServerConnection, useServer } from "@/context/server" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { type ServerHealth } from "@/utils/server-health" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { useQueryOptions } from "@/context/server-sync" import { useQueryOptions } from "@/context/global-sync"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings" const pollMs = 10_000
const pluginEmptyMessage = (value: string, file: string): JSXElement => { const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file) const parts = value.split(file)
@@ -54,11 +54,45 @@ const listServersByHealth = (
}) })
} }
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
const checkServerHealth = useCheckServerHealth()
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {
if (!enabled()) {
setStatus(reconcile({}))
return
}
const list = servers()
let dead = false
const refresh = async () => {
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
if (dead) return
setStatus(reconcile(results))
}
void refresh()
const id = setInterval(() => void refresh(), pollMs)
onCleanup(() => {
dead = true
clearInterval(id)
})
})
return status
}
const useDefaultServerKey = ( const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined, get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => { ) => {
const [state, setState] = createStore({ const [state, setState] = createStore({
key: undefined as ServerConnection.Key | undefined, url: undefined as string | undefined,
tick: 0, tick: 0,
}) })
@@ -67,7 +101,7 @@ const useDefaultServerKey = (
let dead = false let dead = false
const result = get?.() const result = get?.()
if (!result) { if (!result) {
setState("key", undefined) setState("url", undefined)
onCleanup(() => { onCleanup(() => {
dead = true dead = true
}) })
@@ -77,7 +111,7 @@ const useDefaultServerKey = (
if (result instanceof Promise) { if (result instanceof Promise) {
void result.then((next) => { void result.then((next) => {
if (dead) return if (dead) return
setState("key", next ?? undefined) setState("url", next ? normalizeServerUrl(next) : undefined)
}) })
onCleanup(() => { onCleanup(() => {
dead = true dead = true
@@ -85,7 +119,7 @@ const useDefaultServerKey = (
return return
} }
setState("key", ServerConnection.Key.make(result)) setState("url", normalizeServerUrl(result))
onCleanup(() => { onCleanup(() => {
dead = true dead = true
}) })
@@ -93,7 +127,9 @@ const useDefaultServerKey = (
return { return {
key: () => { key: () => {
return state.key const u = state.url
if (!u) return
return ServerConnection.key({ type: "http", http: { url: u } })
}, },
refresh: () => setState("tick", (value) => value + 1), refresh: () => setState("tick", (value) => value + 1),
} }
@@ -130,167 +166,13 @@ const useMcpToggleMutation = () => {
})) }))
} }
type ServerStatusState = {
servers: () => ServerStatusItem[]
defaultKey: () => ServerConnection.Key | undefined
ariaLabel: string
serversLabel: string
defaultLabel: string
manageLabel: string
onManage: () => void
}
type ServerStatusItem = {
key: ServerConnection.Key
conn: ServerConnection.Any
health?: ServerHealth
blocked: boolean
active: boolean
onSelect: () => void
}
export function StatusPopoverServerBody() {
const global = useGlobal()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
dialogDead = true
dialogRun += 1
})
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const serverItems = createMemo(() =>
sortedServers().map((conn) => {
const key = ServerConnection.key(conn)
return {
key,
conn,
health: global.servers.health[key],
blocked: global.servers.health[key]?.healthy === false,
active: !!server.current && key === ServerConnection.key(server.current),
onSelect: () => {
navigate("/")
queueMicrotask(() => server.setActive(key))
},
}
}),
)
return (
<ServerStatusPopoverView
state={{
servers: serverItems,
defaultKey: defaultServer.key,
ariaLabel: language.t("status.popover.ariaLabel"),
serversLabel: language.t("status.popover.tab.servers"),
defaultLabel: language.t("common.default"),
manageLabel: language.t("status.popover.action.manageServers"),
onManage: () => {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
})
},
}}
/>
)
}
function ServerStatusPopoverView(props: { state: ServerStatusState }) {
return (
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
<Tabs
aria-label={props.state.ariaLabel}
class="tabs bg-background-strong rounded-xl overflow-hidden"
data-component="tabs"
data-active="servers"
defaultValue="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">
{props.state.servers().length > 0 ? `${props.state.servers().length} ` : ""}
{props.state.serversLabel}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="servers">
<ServerStatusList state={props.state} />
</Tabs.Content>
</Tabs>
</div>
)
}
function ServerStatusList(props: { state: ServerStatusState }) {
return (
<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={props.state.servers()}>
{(item) => {
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": !item.blocked,
"cursor-not-allowed": item.blocked,
}}
aria-disabled={item.blocked}
onClick={() => {
if (item.blocked) return
item.onSelect()
}}
>
<ServerHealthIndicator health={item.health} />
<ServerRow
conn={item.conn}
dimmed={item.blocked}
status={item.health}
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={item.key === props.state.defaultKey()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{props.state.defaultLabel}
</span>
</Show>
}
>
<div class="flex-1" />
<Show when={item.active}>
<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={props.state.onManage}>
{props.state.manageLabel}
</Button>
</div>
</div>
)
}
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) { export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync() const sync = useSync()
const global = useGlobal()
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const navigate = useNavigate() const navigate = useNavigate()
const settings = useSettings()
const fail = (err: unknown) => { const fail = (err: unknown) => {
showToast({ showToast({
@@ -310,7 +192,15 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
dialogDead = true dialogDead = true
dialogRun += 1 dialogRun += 1
}) })
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health)) const servers = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
})
const health = useServerHealth(servers, props.shown)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const toggleMcp = useMcpToggleMutation() const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer) const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
@@ -330,17 +220,15 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
aria-label={language.t("status.popover.ariaLabel")} aria-label={language.t("status.popover.ariaLabel")}
class="tabs bg-background-strong rounded-xl overflow-hidden" class="tabs bg-background-strong rounded-xl overflow-hidden"
data-component="tabs" data-component="tabs"
data-active={settings.general.newLayoutDesigns() ? "mcp" : "servers"} data-active="servers"
defaultValue={settings.general.newLayoutDesigns() ? "mcp" : "servers"} defaultValue="servers"
variant="alt" variant="alt"
> >
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10"> <Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
{!settings.general.newLayoutDesigns() && (
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular"> <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{global.servers.list().length > 0 ? `${global.servers.list().length} ` : ""} {sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
{language.t("status.popover.tab.servers")} {language.t("status.popover.tab.servers")}
</Tabs.Trigger> </Tabs.Trigger>
)}
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular"> <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""} {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
{language.t("status.popover.tab.mcp")} {language.t("status.popover.tab.mcp")}
@@ -355,14 +243,13 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</Tabs.Trigger> </Tabs.Trigger>
</Tabs.List> </Tabs.List>
{!settings.general.newLayoutDesigns() && (
<Tabs.Content value="servers"> <Tabs.Content value="servers">
<div class="flex flex-col px-2 pb-2"> <div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}> <For each={sortedServers()}>
{(s) => { {(s) => {
const key = ServerConnection.key(s) const key = ServerConnection.key(s)
const blocked = () => global.servers.health[key]?.healthy === false const blocked = () => health[key]?.healthy === false
return ( return (
<button <button
type="button" type="button"
@@ -378,11 +265,11 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
queueMicrotask(() => server.setActive(key)) queueMicrotask(() => server.setActive(key))
}} }}
> >
<ServerHealthIndicator health={global.servers.health[key]} /> <ServerHealthIndicator health={health[key]} />
<ServerRow <ServerRow
conn={s} conn={s}
dimmed={blocked()} dimmed={blocked()}
status={global.servers.health[key]} status={health[key]}
class="flex items-center gap-2 w-full min-w-0" class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate" nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate" versionClass="text-12-regular text-text-weak truncate"
@@ -420,7 +307,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</div> </div>
</div> </div>
</Tabs.Content> </Tabs.Content>
)}
<Tabs.Content value="mcp"> <Tabs.Content value="mcp">
<div class="flex flex-col px-2 pb-2"> <div class="flex flex-col px-2 pb-2">
+7 -144
View File
@@ -1,24 +1,19 @@
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover" import { Popover } from "@opencode-ai/ui/popover"
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js" import { Suspense, createMemo, createSignal, lazy, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
export function StatusPopover() { export function StatusPopover() {
const language = useLanguage() const language = useLanguage()
const server = useServer() const server = useServer()
const global = useGlobal()
const sync = useSync() const sync = useSync()
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync.data.mcp_ready) const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
const mcpIssue = createMemo(() => { const mcpIssue = createMemo(() => {
const mcp = Object.values(sync.data.mcp ?? {}) const mcp = Object.values(sync.data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration") const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
@@ -26,8 +21,7 @@ export function StatusPopover() {
if (failed) return "critical" as const if (failed) return "critical" as const
if (warn) return "warning" as const if (warn) return "warning" as const
}) })
const serverHealthy = () => global.servers.health[server.key]?.healthy === true const healthy = createMemo(() => server.healthy() === true && !mcpIssue())
const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue())
return ( return (
<Popover <Popover
@@ -49,9 +43,10 @@ export function StatusPopover() {
classList={{ classList={{
"absolute -top-px -right-px size-1.5 rounded-full": true, "absolute -top-px -right-px size-1.5 rounded-full": true,
"bg-icon-success-base": ready() && healthy(), "bg-icon-success-base": ready() && healthy(),
"bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning", "bg-icon-warning-base": ready() && server.healthy() === true && mcpIssue() === "warning",
"bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"), "bg-icon-critical-base":
"bg-border-weak-base": serverHealthy() || !ready(), server.healthy() === false || (ready() && server.healthy() === true && mcpIssue() === "critical"),
"bg-border-weak-base": server.healthy() === undefined || !ready(),
}} }}
/> />
</div> </div>
@@ -73,135 +68,3 @@ export function StatusPopover() {
</Popover> </Popover>
) )
} }
export function StatusPopoverV2(props: { scope?: "server" }) {
if (props.scope === "server") return <ServerStatusPopover />
return <DirectoryStatusPopover />
}
function DirectoryStatusPopover() {
const language = useLanguage()
const server = useServer()
const global = useGlobal()
const sync = useSync()
const [shown, setShown] = createSignal(false)
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 ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
if (warn) return "warning" as const
})
const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: ready(),
healthy: healthy(),
serverHealth: serverHealth(),
issue: mcpIssue(),
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<Body shown={shown} />
</StatusPopoverBody>
),
}))
return <StatusPopoverView state={state()} />
}
function ServerStatusPopover() {
const language = useLanguage()
const server = useServer()
const global = useGlobal()
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: serverHealth() !== undefined,
healthy: serverHealth() === true,
serverHealth: serverHealth(),
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<ServerBody />
</StatusPopoverBody>
),
}))
return <StatusPopoverView state={state()} />
}
type StatusPopoverState = {
shown: boolean
ready: boolean
healthy: boolean
serverHealth: boolean | undefined
issue?: "critical" | "warning"
label: string
onOpenChange: (value: boolean) => void
body: () => JSX.Element
}
function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
return (
<Show when={props.shown}>
<Suspense
fallback={<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />}
>
{props.children}
</Suspense>
</Show>
)
}
function StatusPopoverView(props: { state: StatusPopoverState }) {
const statusDotClass = () => ({
"absolute rounded-full": true,
"bg-icon-success-base": props.state.ready && props.state.healthy,
"bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning",
"bg-icon-critical-base":
props.state.serverHealth === false ||
(props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"),
"bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready,
})
const popoverProps = {
class:
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
gutter: 4,
placement: "bottom-end" as const,
shift: -168,
}
return (
<Popover
open={props.state.shown}
onOpenChange={props.state.onOpenChange}
triggerAs={IconButtonV2}
triggerProps={{
variant: "ghost-muted",
size: "large",
class: "!w-9 shrink-0",
state: props.state.shown ? "pressed" : undefined,
"aria-label": props.state.label,
}}
trigger={
<div class="relative size-4">
<IconV2 name={props.state.shown ? "status-active" : "status"} />
<div
classList={statusDotClass()}
class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]"
/>
</div>
}
{...popoverProps}
>
{props.state.body()}
</Popover>
)
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { withAlpha } from "@opencode-ai/ui/theme/color"
import { useTheme } from "@opencode-ai/ui/theme/context" import { useTheme } from "@opencode-ai/ui/theme/context"
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve" import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { HexColor } from "@opencode-ai/ui/theme/types" import type { HexColor } from "@opencode-ai/ui/theme/types"
import { showToast } from "@/utils/toast" import { showToast } from "@opencode-ai/ui/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
import { SerializeAddon } from "@/addons/serialize" import { SerializeAddon } from "@/addons/serialize"
@@ -1,28 +0,0 @@
import { describe, expect, test } from "bun:test"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
describe("titlebar session events", () => {
test("reads valid removed session tab details", () => {
expect(
readSessionTabsRemovedDetail(
new CustomEvent(SESSION_TABS_REMOVED_EVENT, {
detail: { directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2", 1] },
}),
),
).toEqual({
directory: "/tmp/project",
sessionIDs: ["ses_1", "ses_2"],
})
})
test("ignores invalid removed session tab details", () => {
expect(readSessionTabsRemovedDetail(new Event(SESSION_TABS_REMOVED_EVENT))).toBeUndefined()
expect(
readSessionTabsRemovedDetail(
new CustomEvent(SESSION_TABS_REMOVED_EVENT, {
detail: { directory: "/tmp/project", sessionIDs: [] },
}),
),
).toBeUndefined()
})
})
@@ -1,29 +0,0 @@
export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
export type SessionTabsRemovedDetail = {
directory: string
sessionIDs: string[]
}
export function notifySessionTabsRemoved(input: SessionTabsRemovedDetail) {
window.dispatchEvent(new CustomEvent(SESSION_TABS_REMOVED_EVENT, { detail: input }))
}
export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDetail | undefined {
if (!(event instanceof CustomEvent)) return undefined
const detail: unknown = event.detail
if (!detail || typeof detail !== "object") return undefined
if (!("directory" in detail)) return undefined
if (!("sessionIDs" in detail)) return undefined
if (typeof detail.directory !== "string") return undefined
if (!Array.isArray(detail.sessionIDs)) return undefined
const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
if (sessionIDs.length === 0) return undefined
return {
directory: detail.directory,
sessionIDs,
}
}
+161 -507
View File
@@ -1,44 +1,23 @@
import { import { createEffect, createMemo, For, mapArray, Match, Show, startTransition, Switch, untrack } from "solid-js"
createEffect, import { createStore, produce } from "solid-js/store"
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 { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme/context" import { useTheme } from "@opencode-ai/ui/theme/context"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout" import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu" import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history" import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useServerSync } from "@/context/server-sync" import { useGlobalSync } from "@/context/global-sync"
import { base64Encode } from "@opencode-ai/core/util/encode" import { decodeDirectory } from "@/pages/directory-layout"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { iife } from "@opencode-ai/core/util/iife"
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 } 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 = { type TauriDesktopWindow = {
startDragging?: () => Promise<void> startDragging?: () => Promise<void>
@@ -61,42 +40,33 @@ type TauriApi = {
const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__ const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.() const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.() const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
const legacyTitlebarHeight = 40 const titlebarHeight = 40
const v2TitlebarHeight = 36
const minTitlebarZoom = 0.25 const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each. const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
export type TitlebarUpdate = { const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
version: () => string | undefined
installing: () => boolean
install: () => void
}
export function Titlebar(props: { update?: TitlebarUpdate }) { export function Titlebar() {
const layout = useLayout() const layout = useLayout()
const platform = usePlatform() const platform = usePlatform()
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const theme = useTheme() const theme = useTheme()
const server = useServer()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const params = useParams() const params = useParams()
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const electronWindows = createMemo(() => windows() && !tauriApi())
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const web = createMemo(() => platform.platform === "web") const web = createMemo(() => platform.platform === "web")
const zoom = () => platform.webviewZoom?.() ?? 1 const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom()) const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1) const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
const minHeight = () => { const minHeight = () => {
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight if (mac()) return `${titlebarHeight / zoom()}px`
if (mac()) return `${height / zoom()}px` if (windows()) return `${titlebarHeight / Math.min(titlebarZoom(), 1)}px`
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
return undefined return undefined
} }
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px` const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
@@ -128,22 +98,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const canBack = createMemo(() => history.index > 0) const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1) const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0) const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true)) const nav = createMemo(() => import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" || settings.general.showNavigation())
const updateState = createMemo<TitlebarUpdatePillState>(() => {
const installing = props.update?.installing() ?? false
const version = props.update?.version()
return {
visible: version !== undefined || installing,
installing,
label: "Update",
ariaLabel: language.t("toast.update.action.installRestart"),
title: version ? `Update ${version}` : undefined,
onInstall: () => props.update?.install(),
}
})
const v2RightState = createMemo<TitlebarV2RightState>(() => ({
update: updateState(),
}))
const back = () => { const back = () => {
const next = backPath(history) const next = backPath(history)
@@ -228,294 +183,162 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return ( return (
<header <header
classList={{ class="h-10 shrink-0 bg-background-base relative overflow-hidden flex flex-row"
"shrink-0 relative flex flex-row": true, style={{ "min-height": minHeight(), "padding-left": mac() ? `${84 / zoom()}px` : 0 }}
"h-9 bg-v2-background-bg-deep overflow-visible": useV2Titlebar(),
"h-10 bg-background-base overflow-hidden": !useV2Titlebar(),
}}
style={{
"min-height": minHeight(),
"padding-left": mac() ? `${84 / zoom()}px` : 0,
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": electronWindows()
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
: undefined,
"align-self": electronWindows() ? "flex-start" : undefined,
}}
data-tauri-drag-region data-tauri-drag-region
onMouseDown={drag} onMouseDown={drag}
onDblClick={maximize} onDblClick={maximize}
> >
<Switch> <Switch>
<Match when={useV2Titlebar()}> <Match when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
{(_) => { {(_) => {
const serverSync = useServerSync() const globalSync = useGlobalSync()
const navigate = useNavigate() const navigate = useNavigate()
const homeMatch = useMatch(() => "/")
const layout = useLayout()
const newSessionHref = () => { type Tab = { dir: string; sessionId: string; params: any; href: string }
if (params.dir) return `/${params.dir}/session`
const project = layout.projects.list()[0]
if (!project) return "/"
return `/${base64Encode(project.worktree)}/session`
}
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)
})
}
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) => {
const detail = readSessionTabsRemovedDetail(event)
if (!detail) return
tabsStoreActions.removeSessions(detail)
})
const openNewTab = () => navigate(newSessionHref())
command.register("tabs", () => {
const current = currentTab()
const [tabsStore, tabsStoreActions] = iife(() => {
const [store, setStore] = createStore<Tab[]>(
iife(() => {
if (!params.dir || !params.id) return []
return [ return [
{ {
id: "tab.new", dir: decodeDirectory(params.dir) ?? "",
category: "tab", sessionId: params.id,
title: language.t("command.session.new"), params: { id: params.id, dir: params.dir },
keybind: "mod+t", href: makeSessionHref(params.dir, params.id),
hidden: true,
onSelect: openNewTab,
}, },
current && { ]
id: "tab.close", }),
category: "tab", )
title: language.t("command.tab.close"),
keybind: "mod+w", const actions = {
hidden: true, addTab: (tab: Tab) => {
onSelect: () => { setStore(
tabsStoreActions.removeTab(tabsStore.findIndex((tab) => current === tab)) produce((tabs) => {
if (tabs.some((t) => t.href === tab.href)) return
tabs.push(tab)
}),
)
}, },
}, removeTab: (href: string) => {
{ startTransition(() => {
id: `tab.prev`, setStore(
category: "tab", produce((tabs) => {
title: "", const index = tabs.findIndex((t) => t.href === href)
keybind: `mod+option+ArrowLeft`,
hidden: true,
onSelect: () => {
let index = tabsStore.findIndex((tab) => tab === currentTab())
if (index === -1) return if (index === -1) return
tabs.splice(index, 1)
index -= 1 const nextTab = tabs[index] ?? tabs[tabs.length - 1]
if (index === -1) index = tabsStore.length - 1 if (nextTab) navigate(nextTab.href)
else navigate("/")
const next = tabsStore[index] }),
if (next) navigateTab(next) )
}, })
},
{
id: `tab.next`,
category: "tab",
title: "",
keybind: `mod+option+ArrowRight`,
hidden: true,
onSelect: () => {
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) navigateTab(next)
},
},
...Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
return {
id: `tab.${number}`,
category: "tab",
title: "",
keybind: `mod+${number}`,
disabled: layout.projects.list().length <= index,
hidden: true,
onSelect: () => {
const tab = tabsStore[index]
if (tab) navigateTab(tab)
}, },
} }
}),
].filter((v) => v !== undefined) return [store, actions]
}) })
const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false) createEffect(() => {
let tabScrollRef!: HTMLDivElement const params = useParams()
if (!(params.dir && params.id)) return
function refreshTabsAreOverflowing() { tabsStoreActions.addTab({
setTabsAreOverflowing(tabScrollRef.scrollWidth > tabScrollRef.clientWidth) dir: decodeDirectory(params.dir) ?? "",
} sessionId: params.id,
params: { id: params.id, dir: params.dir },
href: makeSessionHref(params.dir, params.id),
})
})
const tabsEnriched = iife(() => {
const base = mapArray(
() => tabsStore,
(tab) => {
const sync = globalSync.createDirSyncContext(tab.dir)
const session = sync.session.get(tab.sessionId)
return session ? { ...tab, info: session } : null
},
)
return () => base().flatMap((s) => (s ? [s] : []))
})
return ( return (
<div <div class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3">
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(),
}}
>
<ChannelIndicator /> <ChannelIndicator />
<Show when={windows() || linux()}> <Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} variant="v2" /> <WindowsAppMenu command={command} platform={platform} />
</Show> </Show>
<IconButtonV2 <IconButtonV2
variant="ghost-muted"
size="large"
as="a" as="a"
href="/" href="/"
class="!w-9 shrink-0"
icon={<IconV2 name="grid-plus" />}
state={!!homeMatch() ? "pressed" : undefined}
/>
<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>
</div>
<Show when={!(creating() && params.dir)}>
<IconButtonV2
type="button"
variant="ghost-muted" variant="ghost-muted"
size="large" size="large"
class="shrink-0" class="!w-8"
icon={<IconV2 name="plus" />} state={!!useMatch(() => "/")() ? "pressed" : undefined}
as="a" >
href={newSessionHref()} <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
aria-label={language.t("command.session.new")} <path
d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z"
stroke="currentColor"
stroke-miterlimit="10"
stroke-linecap="square"
/> />
</Show> </svg>
</IconButtonV2>
<div class="flex flex-row items-center gap-2">
<For each={tabsEnriched()}>
{(tab, i) => (
<>
{i() !== 0 && <div class="w-[1.5px] h-3 rounded-full bg-[var(--v2-background-bg-layer-02)]" />}
<TabNavItem
href={tab.href}
title={tab.info.title}
onClose={() => tabsStoreActions.removeTab(tab.href)}
hideClose={tabsEnriched().length < 2}
/>
</>
)}
</For>
</div>
<button>
<div class="p-1.5">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<path
d="M7.99978 2.88867V13.1109M2.88867 7.99978H13.1109"
stroke="#808080"
stroke-linejoin="round"
/>
</svg>
</div>
</button>
<div class="flex-1" /> <div class="flex-1" />
<TitlebarV2Right state={v2RightState()} /> {/*<button class="px-2.5 py-1.5 bg-[rgba(0,0,0,0.08)] rounded-[6px]">
<Show when={windows() && !electronWindows()}> <svg
<div data-tauri-decorum-tb class="flex flex-row" /> xmlns="http://www.w3.org/2000/svg"
</Show> width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<path
d="M10.4443 2.44436V13.5555M1.55546 13.5554H14.4443V2.44434H1.55542L1.55546 13.5554Z"
stroke="#3A3A3A"
/>
</svg>
</button>*/}
</div> </div>
) )
}} }}
@@ -535,7 +358,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<WindowsAppMenu command={command} platform={platform} /> <WindowsAppMenu command={command} platform={platform} />
</Show> </Show>
<Show when={mac()}> <Show when={mac()}>
{/*<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />*/} <div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center"> <div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton <IconButton
icon="menu" icon="menu"
@@ -679,141 +502,24 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
) )
} }
type TitlebarUpdatePillState = { function TabNavItem(props: { href: string; title: string; hideClose?: boolean; onClose: () => void }) {
visible: boolean const match = useMatch(() => props.href)
installing: boolean const isActive = () => !!match()
label: string
ariaLabel: string
title?: string
onInstall: () => void
}
type TitlebarV2RightState = {
update: TitlebarUpdatePillState
}
function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
return (
<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 TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
return (
<div class="relative isolate mr-3 size-5 shrink-0">
<button
type="button"
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}
>
<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>
</div>
)
}
function TabNavItem(props: {
ref?: HTMLDivElement
href: string
server: ServerConnection.Key
directory: string
sessionId?: string
hideClose?: boolean
onClose: () => void
onNavigate: () => void
active?: boolean
activeServer: boolean
forceTruncate?: boolean
}) {
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 ( return (
<div <div
ref={props.ref} class="group flex flex-row items-center max-w-60 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] bg-[var(--tab-bg)] h-7 rounded-[6px] relative overflow-hidden"
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)
}}
> >
<Show when={session.latest}>
{(session) => {
console.log({ session: session() })
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
return (
<a <a
href={props.href} href={props.href}
onClick={(event) => { class="w-full h-full pl-1.5 flex-1 max-w-full flex flex-row items-center overflow-hidden font-medium"
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"> {props.title}
<ProjectTabAvatar
project={project()}
directory={props.directory}
sessionId={session().id}
activeServer={props.activeServer}
/>
</span>
<span class="min-w-0 flex-1">{session().title}</span>
</a> </a>
)
}}
</Show>
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
<div <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" class="absolute inset-0 bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
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={{ style={{
"--inactive-bg": "linear-gradient(to right, transparent 0%, var(--tab-bg) 80%)", "--inactive-bg": "linear-gradient(to right, transparent 0%, var(--tab-bg) 80%)",
"--active-bg": "linear-gradient(90deg, transparent 0%, var(--tab-bg) 25%)", "--active-bg": "linear-gradient(90deg, transparent 0%, var(--tab-bg) 25%)",
@@ -822,77 +528,25 @@ function TabNavItem(props: {
<IconButtonV2 <IconButtonV2
size="small" size="small"
variant="ghost-muted" variant="ghost-muted"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10" class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
onClick={closeTab} onClick={props.onClose}
icon={<IconV2 name="xmark-small" />} icon={
/> <svg
</div> xmlns="http://www.w3.org/2000/svg"
</div> width="16"
) height="16"
} viewBox="0 0 16 16"
fill="none"
function ProjectTabAvatar(props: { class="size-4"
project?: LocalProject >
directory: string <path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="currentColor" />
sessionId: string </svg>
activeServer: boolean
}) {
const directory = () => props.directory
const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
return (
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
loading={state.loading()}
/>
)
}
function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: string; onClose: () => void }) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
} }
return (
<div
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)
}}
>
<a
href={props.href}
aria-current="page"
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-[var(--v2-text-text-base)]"
>
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
<IconV2 name="edit" />
</span>
<span class="truncate leading-5">{props.title}</span>
</a>
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
<IconButtonV2
size="small"
variant="ghost-muted"
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
aria-label="Close tab"
/> />
</div> </div>
</div> </div>
) )
} }
function ChannelIndicator() { function ChannelIndicator() {
return ( return (
<> <>
@@ -2,8 +2,6 @@ import { Show, type JSX } from "solid-js"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu" import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
@@ -12,7 +10,6 @@ import { usePlatform } from "@/context/platform"
export function WindowsAppMenu(props: { export function WindowsAppMenu(props: {
command: ReturnType<typeof useCommand> command: ReturnType<typeof useCommand>
platform: ReturnType<typeof usePlatform> platform: ReturnType<typeof usePlatform>
variant?: "legacy" | "v2"
}) { }) {
let lastFocused: HTMLElement | undefined let lastFocused: HTMLElement | undefined
@@ -48,22 +45,6 @@ export function WindowsAppMenu(props: {
return ( return (
<DropdownMenu gutter={4} modal={false} placement="bottom-start"> <DropdownMenu gutter={4} modal={false} placement="bottom-start">
{props.variant === "v2" ? (
<div
data-component="desktop-icon-button"
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
>
<DropdownMenu.Trigger
as={IconButtonV2}
variant="ghost-muted"
size="large"
icon={<IconV2 name="menu" />}
aria-label="OpenCode menu"
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
</div>
) : (
<DropdownMenu.Trigger <DropdownMenu.Trigger
as={IconButton} as={IconButton}
icon="menu" icon="menu"
@@ -73,7 +54,6 @@ export function WindowsAppMenu(props: {
onPointerDown={rememberFocus} onPointerDown={rememberFocus}
onKeyDown={rememberFocus} onKeyDown={rememberFocus}
/> />
)}
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content class="desktop-app-menu"> <DropdownMenu.Content class="desktop-app-menu">
<DropdownMenu.Group> <DropdownMenu.Group>
+3 -10
View File
@@ -3,8 +3,6 @@ import { createStore, reconcile, type SetStoreFunction, type Store } from "solid
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { Persist, persisted } from "@/utils/persist" 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 { createScopedCache } from "@/utils/scoped-cache"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
import type { SelectedLineRange } from "@/context/file" import type { SelectedLineRange } from "@/context/file"
@@ -168,11 +166,11 @@ export function createCommentSessionForTest(comments: Record<string, LineComment
return createCommentSessionState(store, setStore) return createCommentSessionState(store, setStore)
} }
function createCommentSession(scope: ServerScope, dir: string, id: string | undefined) { function createCommentSession(dir: string, id: string | undefined) {
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1` const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.serverScoped(scope, dir, id, "comments", [legacy]), Persist.scoped(dir, id, "comments", [legacy]),
createStore<CommentStore>({ createStore<CommentStore>({
comments: {}, comments: {},
}), }),
@@ -202,16 +200,11 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
gate: false, gate: false,
init: () => { init: () => {
const params = useParams() const params = useParams()
const serverSDK = useServerSDK()
const cache = createScopedCache( const cache = createScopedCache(
(key) => { (key) => {
const decoded = decodeSessionKey(key) const decoded = decodeSessionKey(key)
return createRoot((dispose) => ({ return createRoot((dispose) => ({
value: createCommentSession( value: createCommentSession(decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id),
serverSDK.scope,
decoded.dir,
decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
),
dispose, dispose,
})) }))
}, },
+27 -48
View File
@@ -8,11 +8,10 @@ import {
getSessionPrefetchPromise, getSessionPrefetchPromise,
setSessionPrefetch, setSessionPrefetch,
} from "./global-sync/session-prefetch" } from "./global-sync/session-prefetch"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import { useGlobalSync } from "./global-sync"
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs" import { diffs as list, message as clean } from "@/utils/diffs"
import { createServerSdkContext, useServerSDK } from "./server-sdk"
import { type createServerSyncContextInner } from "./server-sync"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -34,12 +33,6 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const isNotFound = (error: unknown) =>
error instanceof Error &&
typeof error.cause === "object" &&
error.cause !== null &&
(error.cause as { status?: unknown }).status === 404
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) { function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
const map = new Map(a.map((item) => [item.id, item] as const)) const map = new Map(a.map((item) => [item.id, item] as const))
for (const item of b) map.set(item.id, item) for (const item of b) map.set(item.id, item)
@@ -171,20 +164,16 @@ function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: Opti
}) })
} }
export const createDirSyncContext = ( export const createDirSyncContext = (client: OpencodeClient, directory: string) => {
directory: string, const globalSync = useGlobalSync()
serverSync: ReturnType<typeof createServerSyncContextInner>,
serverSDK: ReturnType<typeof createServerSdkContext> = useServerSDK(),
) => {
const client = serverSDK.createClient({ directory, throwOnError: true })
type Child = ReturnType<(typeof serverSync)["child"]> type Child = ReturnType<(typeof globalSync)["child"]>
type Setter = Child[1] type Setter = Child[1]
const current = createMemo(() => serverSync.child(directory, { mcp: true })) const current = createMemo(() => globalSync.child(directory))
const target = (directory?: string) => { const target = (directory?: string) => {
if (!directory || directory === directory) return current() if (!directory || directory === directory) return current()
return serverSync.child(directory) return globalSync.child(directory)
} }
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80 const initialMessagePageSize = 80
@@ -250,7 +239,7 @@ export const createDirSyncContext = (
if (!first) break if (!first) break
const stale = [...(seen.get(first) ?? [])] const stale = [...(seen.get(first) ?? [])]
seen.delete(first) seen.delete(first)
const [, setStore] = serverSync.child(first, { bootstrap: false }) const [, setStore] = globalSync.child(first, { bootstrap: false })
evict(first, setStore, stale) evict(first, setStore, stale)
} }
return created return created
@@ -276,9 +265,9 @@ export const createDirSyncContext = (
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => { const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return if (sessionIDs.length === 0) return
clearSessionPrefetch(serverSDK.scope, directory, sessionIDs) clearSessionPrefetch(directory, sessionIDs)
for (const sessionID of sessionIDs) { for (const sessionID of sessionIDs) {
serverSync.todo.set(sessionID, undefined) globalSync.todo.set(sessionID, undefined)
} }
setStore( setStore(
produce((draft) => { produce((draft) => {
@@ -335,7 +324,7 @@ export const createDirSyncContext = (
for (const messageID of next.confirmed) { for (const messageID of next.confirmed) {
clearOptimistic(input.directory, input.sessionID, messageID) clearOptimistic(input.directory, input.sessionID, messageID)
} }
const [store] = serverSync.child(input.directory, { bootstrap: false }) const [store] = globalSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : [] const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
batch(() => { batch(() => {
@@ -348,7 +337,6 @@ export const createDirSyncContext = (
setMeta("cursor", key, next.cursor) setMeta("cursor", key, next.cursor)
setMeta("complete", key, next.complete) setMeta("complete", key, next.complete)
setSessionPrefetch({ setSessionPrefetch({
scope: serverSDK.scope,
directory: input.directory, directory: input.directory,
sessionID: input.sessionID, sessionID: input.sessionID,
limit: message.length, limit: message.length,
@@ -357,10 +345,6 @@ export const createDirSyncContext = (
}) })
}) })
}) })
.catch((error) => {
if (isNotFound(error) && !tracked(input.directory, input.sessionID)) return
throw error
})
.finally(() => { .finally(() => {
setMeta( setMeta(
produce((draft) => { produce((draft) => {
@@ -389,8 +373,8 @@ export const createDirSyncContext = (
}, },
get project() { get project() {
const store = current()[0] const store = current()[0]
const match = Binary.search(serverSync.data.project, store.project, (p) => p.id) const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
if (match.found) return serverSync.data.project[match.index] if (match.found) return globalSync.data.project[match.index]
return undefined return undefined
}, },
session: { session: {
@@ -434,12 +418,12 @@ export const createDirSyncContext = (
}) })
}, },
async sync(sessionID: string, opts?: { force?: boolean }) { async sync(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = serverSync.child(directory) const [store, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID) const key = keyFor(directory, sessionID)
touch(directory, setStore, sessionID) touch(directory, setStore, sessionID)
const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID) const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) { if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => { batch(() => {
setMeta("limit", key, seeded.limit) setMeta("limit", key, seeded.limit)
@@ -450,10 +434,10 @@ export const createDirSyncContext = (
} }
return runInflight(inflight, key, async () => { return runInflight(inflight, key, async () => {
const pending = getSessionPrefetchPromise(serverSDK.scope, directory, sessionID) const pending = getSessionPrefetchPromise(directory, sessionID)
if (pending) { if (pending) {
await pending await pending
const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID) const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) { if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => { batch(() => {
setMeta("limit", key, seeded.limit) setMeta("limit", key, seeded.limit)
@@ -472,8 +456,7 @@ export const createDirSyncContext = (
const sessionReq = const sessionReq =
hasSession && !opts?.force hasSession && !opts?.force
? Promise.resolve() ? Promise.resolve()
: retry(() => client.session.get({ sessionID })) : retry(() => client.session.get({ sessionID })).then((session) => {
.then((session) => {
if (!tracked(directory, sessionID)) return if (!tracked(directory, sessionID)) return
const data = session.data const data = session.data
if (!data) return if (!data) return
@@ -489,10 +472,6 @@ export const createDirSyncContext = (
}), }),
) )
}) })
.catch((error) => {
if (isNotFound(error) && !tracked(directory, sessionID)) return
throw error
})
const messagesReq = const messagesReq =
cached && !opts?.force cached && !opts?.force
@@ -509,7 +488,7 @@ export const createDirSyncContext = (
}) })
}, },
async diff(sessionID: string, opts?: { force?: boolean }) { async diff(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = serverSync.child(directory) const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID) touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined && !opts?.force) return if (store.session_diff[sessionID] !== undefined && !opts?.force) return
@@ -522,13 +501,13 @@ export const createDirSyncContext = (
) )
}, },
async todo(sessionID: string, opts?: { force?: boolean }) { async todo(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = serverSync.child(directory) const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID) touch(directory, setStore, sessionID)
const existing = store.todo[sessionID] const existing = store.todo[sessionID]
const cached = serverSync.data.session_todo[sessionID] const cached = globalSync.data.session_todo[sessionID]
if (existing !== undefined) { if (existing !== undefined) {
if (cached === undefined) { if (cached === undefined) {
serverSync.todo.set(sessionID, existing) globalSync.todo.set(sessionID, existing)
} }
if (!opts?.force) return if (!opts?.force) return
} }
@@ -543,7 +522,7 @@ export const createDirSyncContext = (
if (!tracked(directory, sessionID)) return if (!tracked(directory, sessionID)) return
const list = todo.data ?? [] const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" })) setStore("todo", sessionID, reconcile(list, { key: "id" }))
serverSync.todo.set(sessionID, list) globalSync.todo.set(sessionID, list)
}), }),
) )
}, },
@@ -561,7 +540,7 @@ export const createDirSyncContext = (
return meta.loading[key] ?? false return meta.loading[key] ?? false
}, },
async loadMore(sessionID: string, count?: number) { async loadMore(sessionID: string, count?: number) {
const [, setStore] = serverSync.child(directory) const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID) touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID) const key = keyFor(directory, sessionID)
const step = count ?? historyMessagePageSize const step = count ?? historyMessagePageSize
@@ -582,12 +561,12 @@ export const createDirSyncContext = (
}, },
}, },
evict(sessionID: string, _directory = directory) { evict(sessionID: string, _directory = directory) {
const [, setStore] = serverSync.child(_directory) const [, setStore] = globalSync.child(_directory)
seenFor(_directory).delete(sessionID) seenFor(_directory).delete(sessionID)
evict(_directory, setStore, [sessionID]) evict(_directory, setStore, [sessionID])
}, },
fetch: async (count = 10) => { fetch: async (count = 10) => {
const [store, setStore] = serverSync.child(directory) const [store, setStore] = globalSync.child(directory)
setStore("limit", (x) => x + count) setStore("limit", (x) => x + count)
await client.session.list().then((x) => { await client.session.list().then((x) => {
const sessions = (x.data ?? []) const sessions = (x.data ?? [])
@@ -599,7 +578,7 @@ export const createDirSyncContext = (
}, },
more: createMemo(() => current()[0].session.length >= current()[0].limit), more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => { archive: async (sessionID: string) => {
const [, setStore] = serverSync.child(directory) const [, setStore] = globalSync.child(directory)
await client.session.update({ sessionID, time: { archived: Date.now() } }) await client.session.update({ sessionID, time: { archived: Date.now() } })
setStore( setStore(
produce((draft) => { produce((draft) => {
+3 -8
View File
@@ -1,7 +1,7 @@
import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { showToast } from "@/utils/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
@@ -21,8 +21,6 @@ import {
touchFileContent, touchFileContent,
} from "./file/content-cache" } from "./file/content-cache"
import { createFileViewCache } from "./file/view-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 { createFileTreeStore } from "./file/tree-store"
import { invalidateFromWatcher } from "./file/watcher" import { invalidateFromWatcher } from "./file/watcher"
import { import {
@@ -58,15 +56,12 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const sdk = useSDK() const sdk = useSDK()
useSync() useSync()
const params = useParams() const params = useParams()
const serverSDK = useServerSDK()
const language = useLanguage() const language = useLanguage()
const layout = useLayout() const layout = useLayout()
const scope = createMemo(() => sdk.directory) const scope = createMemo(() => sdk.directory)
const path = createPathHelpers(scope) const path = createPathHelpers(scope)
const tabs = layout.tabs(() => const tabs = layout.tabs(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(params.dir, params.id)),
)
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
@@ -112,7 +107,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}) })
}) })
const viewCache = createFileViewCache(serverSDK.scope) const viewCache = createFileViewCache()
const view = createMemo(() => viewCache.load(scope(), params.id)) const view = createMemo(() => viewCache.load(scope(), params.id))
const ensure = (file: string) => { const ensure = (file: string) => {
+4 -5
View File
@@ -3,7 +3,6 @@ import { createStore, produce } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { createScopedCache } from "@/utils/scoped-cache" import { createScopedCache } from "@/utils/scoped-cache"
import type { FileViewState, SelectedLineRange } from "./types" import type { FileViewState, SelectedLineRange } from "./types"
import type { ServerScope } from "@/utils/server-scope"
const WORKSPACE_KEY = "__workspace__" const WORKSPACE_KEY = "__workspace__"
const MAX_FILE_VIEW_SESSIONS = 20 const MAX_FILE_VIEW_SESSIONS = 20
@@ -34,11 +33,11 @@ function equalSelectedLines(a: SelectedLineRange | null | undefined, b: Selected
) )
} }
function createViewSession(scope: ServerScope, dir: string, id: string | undefined) { function createViewSession(dir: string, id: string | undefined) {
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1` const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
const [view, setView, _, ready] = persisted( const [view, setView, _, ready] = persisted(
Persist.serverScoped(scope, dir, id, "file-view", [legacyViewKey]), Persist.scoped(dir, id, "file-view", [legacyViewKey]),
createStore<{ createStore<{
file: Record<string, FileViewState> file: Record<string, FileViewState>
}>({ }>({
@@ -120,14 +119,14 @@ function createViewSession(scope: ServerScope, dir: string, id: string | undefin
} }
} }
export function createFileViewCache(scope: ServerScope) { export function createFileViewCache() {
const cache = createScopedCache( const cache = createScopedCache(
(key) => { (key) => {
const split = key.lastIndexOf("\n") const split = key.lastIndexOf("\n")
const dir = split >= 0 ? key.slice(0, split) : key const dir = split >= 0 ? key.slice(0, split) : key
const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
return createRoot((dispose) => ({ return createRoot((dispose) => ({
value: createViewSession(scope, dir, id === WORKSPACE_KEY ? undefined : id), value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
dispose, dispose,
})) }))
}, },
+254
View File
@@ -0,0 +1,254 @@
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { batch, onCleanup, onMount } from "solid-js"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { useServer } from "./server"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK",
init: () => {
const language = useLanguage()
const server = useServer()
const platform = usePlatform()
const abort = new AbortController()
const eventFetch = (() => {
if (!platform.fetch || !server.current) return
try {
const url = new URL(server.current.http.url)
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
if (url.protocol === "http:" && !loopback) return platform.fetch
} catch {
return
}
})()
const currentServer = server.current
if (!currentServer) throw new Error(language.t("error.globalSDK.noServerAvailable"))
const eventSdk = createSdkForServer({
signal: abort.signal,
fetch: eventFetch,
server: currentServer.http,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
}>()
type Queued = { directory: string; payload: Event }
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const RECONNECT_DELAY_MS = 250
let queue: Queued[] = []
let buffer: Queued[] = []
const coalesced = new Map<string, number>()
const staleDeltas = new Set<string>()
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
if (payload.type === "message.part.updated") {
const part = payload.properties.part
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
}
}
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
if (queue.length === 0) return
const events = queue
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
queue = buffer
buffer = events
queue.length = 0
coalesced.clear()
staleDeltas.clear()
last = Date.now()
batch(() => {
for (const event of events) {
if (skip && event.payload.type === "message.part.delta") {
const props = event.payload.properties
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
}
emitter.emit(event.directory, event.payload)
}
})
buffer.length = 0
}
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
}
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
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
const resetHeartbeat = () => {
lastEventAt = Date.now()
if (heartbeat) clearTimeout(heartbeat)
heartbeat = setTimeout(() => {
attempt?.abort()
}, HEARTBEAT_TIMEOUT_MS)
}
const clearHeartbeat = () => {
if (!heartbeat) return
clearTimeout(heartbeat)
heartbeat = undefined
}
const start = () => {
if (started) return run
started = true
run = (async () => {
// 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) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (aborted(error)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const directory = event.directory ?? "global"
if (event.payload.type === "sync") {
continue
}
const payload = event.payload as Event
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
}
if (abort.signal.aborted || !started) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => {
run = undefined
flush()
})
return run
}
const stop = () => {
started = false
attempt?.abort()
clearHeartbeat()
}
onMount(() => {
makeEventListener(document, "visibilitychange", () => {
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
})
})
onCleanup(() => {
stop()
abort.abort()
flush()
})
const sdk = createSdkForServer({
server: server.current.http,
fetch: platform.fetch,
throwOnError: true,
})
return {
url: currentServer.http.url,
client: sdk,
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
start,
},
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
return createSdkForServer({
server: s.http,
fetch: platform.fetch,
...opts,
})
},
}
},
})
@@ -1,11 +1,19 @@
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client" import type {
import { showToast } from "@/utils/toast" Config,
OpencodeClient,
Path,
Project,
ProviderAuthResponse,
ProviderListResponse,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { batch, getOwner, onCleanup, onMount, untrack } from "solid-js" import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error" import type { InitError } from "../pages/error"
import { ServerSDK, useServerSDK } from "./server-sdk" import { useGlobalSDK } from "./global-sdk"
import { import {
bootstrapDirectory, bootstrapDirectory,
bootstrapGlobal, bootstrapGlobal,
@@ -29,13 +37,6 @@ import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils" import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/utils/path-key" import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync" 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 = { type GlobalStore = {
ready: boolean ready: boolean
@@ -45,49 +46,44 @@ type GlobalStore = {
session_todo: { session_todo: {
[sessionID: string]: Todo[] [sessionID: string]: Todo[]
} }
provider: NormalizedProviderListResponse provider: ProviderListResponse
provider_auth: ProviderAuthResponse provider_auth: ProviderAuthResponse
config: Config config: Config
reload: undefined | "pending" | "complete" reload: undefined | "pending" | "complete"
} }
export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => export const loadMcpQuery = (directory: string, sdk: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: [scope, directory, "mcp"] as const, queryKey: [directory, "mcp"] as const,
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
}) })
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => export const loadLspQuery = (directory: string, sdk: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: [scope, directory, "lsp"] as const, queryKey: [directory, "lsp"] as const,
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
}) })
function makeQueryOptionsApi( function makeQueryOptionsApi(globalSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
scope: ServerScope,
serverSDK: () => OpencodeClient,
sdkFor: (dir: PathKey) => OpencodeClient,
) {
return { return {
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()), globalConfig: () => loadGlobalConfigQuery(globalSDK()),
projects: () => loadProjectsQuery(scope, serverSDK()), projects: () => loadProjectsQuery(globalSDK()),
providers: (directory: PathKey | null) => providers: (directory: PathKey | null) =>
loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), loadProvidersQuery(directory, directory === null ? globalSDK() : sdkFor(directory)),
path: (directory: PathKey | null) => path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? globalSDK() : sdkFor(directory)),
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), agents: (directory: PathKey) => loadAgentsQuery(directory, sdkFor(directory)),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)), mcp: (directory: PathKey) => loadMcpQuery(directory, sdkFor(directory)),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)), lsp: (directory: PathKey) => loadLspQuery(directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)), sessions: (directory: PathKey) => ({ queryKey: [directory, "loadSessions"] as const }),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
} }
} }
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi> export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
export function createServerSyncContextInner(_serverSDK?: ServerSDK) { function createGlobalSync() {
const serverSDK: ServerSDK = _serverSDK ?? useServerSDK() const globalSDK = useGlobalSDK()
const language = useLanguage() const language = useLanguage()
const owner = getOwner() const owner = getOwner()
if (!owner) throw new Error("ServerSync must be created within owner") if (!owner) throw new Error("GlobalSync must be created within owner")
const sdkCache = new Map<string, OpencodeClient>() const sdkCache = new Map<string, OpencodeClient>()
const booting = new Map<string, Promise<void>>() const booting = new Map<string, Promise<void>>()
@@ -98,7 +94,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
const key = directoryKey(directory) const key = directoryKey(directory)
const cached = sdkCache.get(key) const cached = sdkCache.get(key)
if (cached) return cached if (cached) return cached
const sdk = serverSDK.createClient({ const sdk = globalSDK.createClient({
directory, directory,
throwOnError: true, throwOnError: true,
}) })
@@ -106,7 +102,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
return sdk return sdk
} }
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor) const queryOptionsApi = makeQueryOptionsApi(() => globalSDK.client, sdkFor)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)], queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
@@ -114,7 +110,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
const [globalStore, setGlobalStore] = createStore<GlobalStore>({ const [globalStore, setGlobalStore] = createStore<GlobalStore>({
get ready() { get ready() {
return !bootstrap.isPending return bootstrap.isPending
}, },
project: [], project: [],
session_todo: {}, session_todo: {},
@@ -125,7 +121,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
return pathQuery.data ?? EMPTY return pathQuery.data ?? EMPTY
}, },
get provider() { get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} } const EMPTY = { all: [], connected: [], default: {} }
if (providerQuery.isLoading) return EMPTY if (providerQuery.isLoading) return EMPTY
return providerQuery.data ?? EMPTY return providerQuery.data ?? EMPTY
}, },
@@ -137,7 +133,6 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
return updateConfigMutation.isPending ? "pending" : undefined return updateConfigMutation.isPending ? "pending" : undefined
}, },
}) })
const queryClient = useQueryClient() const queryClient = useQueryClient()
let bootedAt = 0 let bootedAt = 0
@@ -163,11 +158,10 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
}) as typeof setGlobalStore }) as typeof setGlobalStore
const bootstrap = useQuery(() => ({ const bootstrap = useQuery(() => ({
queryKey: [serverSDK.scope, "bootstrap"], queryKey: ["bootstrap"],
queryFn: async () => { queryFn: async () => {
await bootstrapGlobal({ await bootstrapGlobal({
serverSDK: serverSDK.client, globalSDK: globalSDK.client,
scope: serverSDK.scope,
requestFailedTitle: language.t("common.requestFailed"), requestFailedTitle: language.t("common.requestFailed"),
translate: language.t, translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
@@ -206,39 +200,24 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
const queue = createRefreshQueue({ const queue = createRefreshQueue({
paused, paused,
key: directoryKey, key: directoryKey,
bootstrap: () => queryClient.fetchQuery({ queryKey: [serverSDK.scope, "bootstrap"] }), bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }),
bootstrapInstance, bootstrapInstance,
}) })
const children = createChildStoreManager({ const children = createChildStoreManager({
owner, owner,
scope: serverSDK.scope,
persist: persisted,
isBooting: (directory) => booting.has(directory), isBooting: (directory) => booting.has(directory),
isLoadingSessions: (directory) => sessionLoads.has(directory), isLoadingSessions: (directory) => sessionLoads.has(directory),
onBootstrap: (directory) => { onBootstrap: (directory) => {
void bootstrapInstance(directory) void bootstrapInstance(directory)
}, },
onMcp: (directory, setStore) => {
void retry(() =>
sdkFor(directory)
.command.list()
.then((x) => setStore("command", x.data ?? [])),
).catch((err) => {
showToast({
variant: "error",
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
description: formatServerError(err, language.t),
})
})
},
onDispose: (directory) => { onDispose: (directory) => {
const key = directoryKey(directory) const key = directoryKey(directory)
queue.clear(key) queue.clear(key)
sessionMeta.delete(key) sessionMeta.delete(key)
sdkCache.delete(key) sdkCache.delete(key)
clearProviderRev(serverSDK.scope, key) clearProviderRev(key)
clearSessionPrefetchDirectory(serverSDK.scope, key) clearSessionPrefetchDirectory(key)
}, },
translate: language.t, translate: language.t,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
@@ -247,21 +226,17 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
}, },
}) })
async function loadSessions(directory: string, options?: { limit?: number }) { async function loadSessions(directory: string) {
const key = directoryKey(directory) const key = directoryKey(directory)
const pending = sessionLoads.get(key) const pending = sessionLoads.get(key)
if (pending) { if (pending) return pending
await pending
return loadSessions(directory, options)
}
children.pin(key) children.pin(key)
const [store, setStore] = children.child(directory, { bootstrap: false }) const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(key) const meta = sessionMeta.get(key)
const retainedLimit = Math.max(store.limit, options?.limit ?? 0, meta?.limit ?? 0) if (meta && meta.limit >= store.limit) {
if (meta && meta.limit >= retainedLimit) {
const next = trimSessions(store.session, { const next = trimSessions(store.session, {
limit: retainedLimit, limit: store.limit,
permission: store.permission, permission: store.permission,
}) })
if (next.length !== store.session.length) { if (next.length !== store.session.length) {
@@ -272,7 +247,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
return return
} }
const limit = Math.max(retainedLimit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const promise = queryClient const promise = queryClient
.fetchQuery({ .fetchQuery({
...queryOptionsApi.sessions(key), ...queryOptionsApi.sessions(key),
@@ -280,14 +255,14 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
loadRootSessionsWithFallback({ loadRootSessionsWithFallback({
directory, directory,
limit, limit,
list: (query) => serverSDK.client.session.list(query), list: (query) => globalSDK.client.session.list(query),
}) })
.then((x) => { .then((x) => {
const nonArchived = (x.data ?? []) const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id) .filter((s) => !!s?.id)
.filter((s) => !s.time?.archived) .filter((s) => !s.time?.archived)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0) const limit = store.limit
const childSessions = store.session.filter((s) => !!s.parentID) const childSessions = store.session.filter((s) => !!s.parentID)
const sessions = trimSessions([...nonArchived, ...childSessions], { const sessions = trimSessions([...nonArchived, ...childSessions], {
limit, limit,
@@ -342,8 +317,6 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
const sdk = sdkFor(directory) const sdk = sdkFor(directory)
await bootstrapDirectory({ await bootstrapDirectory({
directory, directory,
scope: serverSDK.scope,
mcp: children.mcp(key),
global: { global: {
config: globalStore.config, config: globalStore.config,
path: globalStore.path, path: globalStore.path,
@@ -368,7 +341,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
return promise return promise
} }
const unsub = serverSDK.event.listen((e) => { const unsub = globalSDK.event.listen((e) => {
const directory = e.name const directory = e.name
const key = directoryKey(directory) const key = directoryKey(directory)
const event = e.details const event = e.details
@@ -404,7 +377,6 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
setStore, setStore,
push: queue.push, push: queue.push,
setSessionTodo, setSessionTodo,
retainedLimit: sessionMeta.get(key)?.limit,
vcsCache: children.vcsCache.get(key), vcsCache: children.vcsCache.get(key),
loadLsp: () => { loadLsp: () => {
void queryClient.fetchQuery(queryOptionsApi.lsp(key)) void queryClient.fetchQuery(queryOptionsApi.lsp(key))
@@ -428,13 +400,13 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
eventFrame = undefined eventFrame = undefined
eventTimer = setTimeout(() => { eventTimer = setTimeout(() => {
eventTimer = undefined eventTimer = undefined
void serverSDK.event.start() void globalSDK.event.start()
}, 0) }, 0)
}) })
} else { } else {
eventTimer = setTimeout(() => { eventTimer = setTimeout(() => {
eventTimer = undefined eventTimer = undefined
void serverSDK.event.start() void globalSDK.event.start()
}, 0) }, 0)
} }
}) })
@@ -450,18 +422,19 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
} }
const updateConfigMutation = useMutation(() => ({ const updateConfigMutation = useMutation(() => ({
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }), mutationFn: (config: Config) => globalSDK.client.global.config.update({ config }),
onSuccess: () => { onSuccess: () => {
bootstrap.refetch() bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers // Invalidate all provider queries so newly configured custom providers
// appear immediately in the available provider list across all directories. // appear immediately in the available provider list across all directories.
queryClient.invalidateQueries({ queryKey: [serverSDK.scope, null, "providers"] }) queryClient.invalidateQueries({ queryKey: [null, "providers"] })
queryClient.invalidateQueries({ queryClient.invalidateQueries({ predicate: (query) => query.queryKey[1] === "providers" })
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
})
}, },
})) }))
const dirSyncContexts = new Map<string, ReturnType<typeof createDirSyncContext>>()
const dirSyncContextRefCounts = new Map<string, number>()
return { return {
data: globalStore, data: globalStore,
set, set,
@@ -473,7 +446,6 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
}, },
child: children.child, child: children.child,
peek: children.peek, peek: children.peek,
disableMcp: children.disableMcp,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
// bootstrap, // bootstrap,
updateConfig: updateConfigMutation.mutateAsync, updateConfig: updateConfigMutation.mutateAsync,
@@ -481,36 +453,42 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
todo: { todo: {
set: setSessionTodo, set: setSessionTodo,
}, },
createDirSyncContext: (directory: string) => {
onCleanup(() => {
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) - 1)
if (dirSyncContextRefCounts.get(directory) === 0) {
dirSyncContexts.delete(directory)
dirSyncContextRefCounts.delete(directory)
}
})
const cached = dirSyncContexts.get(directory)
if (cached) {
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) + 1)
return cached
}
const ctx = createDirSyncContext(globalSDK.createClient({ directory, throwOnError: true }), directory)
dirSyncContexts.set(directory, ctx)
dirSyncContextRefCounts.set(directory, 1)
return ctx
},
} }
} }
export function createServerSyncContext(_serverSDK?: ServerSDK) { const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
const inner = createServerSyncContextInner(_serverSDK)
return Object.assign(inner, { export function GlobalSyncProvider(props: ParentProps) {
createDirSyncContext: createRefCountMap( const value = createGlobalSync()
(dir) => createDirSyncContext(dir, inner, _serverSDK), return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
(dir) => inner.disableMcp(dir),
directoryKey,
),
})
} }
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({ export function useGlobalSync() {
name: "ServerSync", const context = useContext(GlobalSyncContext)
gate: false, if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
init: (props: { server?: ServerConnection.Any }) => { return context
const global = useGlobal() }
const language = useLanguage()
const server = useServer()
const conn = props.server ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
const ctx = global.createServerCtx(conn)
return ctx.sync
},
})
export function useQueryOptions() { export function useQueryOptions() {
return useServerSync().queryOptions return useGlobalSync().queryOptions
} }
@@ -1,108 +0,0 @@
import { describe, expect, test } from "bun:test"
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, 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
describe("bootstrapDirectory", () => {
test("marks a loading directory partial during bootstrap and complete after success", async () => {
const mcpReads: string[] = []
const [store, setStore] = createStore<State>({
status: "loading",
agent: [],
command: [],
project: "",
projectMeta: undefined,
icon: undefined,
provider_ready: true,
provider,
config: {},
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
session: [],
sessionTotal: 0,
session_status: {},
session_working(id: string) {
return this.session_status[id]?.type !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp_ready: true,
mcp: {},
lsp_ready: true,
lsp: [],
vcs: undefined,
limit: 5,
message: {},
part: {},
part_text_accum_delta: {},
})
await bootstrapDirectory({
directory: "/project",
scope: ServerScope.local,
mcp: false,
global: {
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
project: [{ id: "project", worktree: "/project" } as Project],
provider,
},
sdk: {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: { get: async () => ({ data: {} }) },
session: { status: async () => ({ data: {} }) },
vcs: { get: async () => ({ data: undefined }) },
command: {
list: async () => {
mcpReads.push("command")
return { data: [] }
},
},
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
mcp: {
status: async () => {
mcpReads.push("status")
return { data: {} }
},
},
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
loadSessions() {},
translate: (key) => key,
queryClient: new QueryClient(),
})
expect(store.status).toBe("partial")
await new Promise((resolve) => setTimeout(resolve, 80))
expect(store.status).toBe("complete")
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",
])
})
})
@@ -5,11 +5,12 @@ import type {
PermissionRequest, PermissionRequest,
Project, Project,
ProviderAuthResponse, ProviderAuthResponse,
ProviderListResponse,
QuestionRequest, QuestionRequest,
Session, Session,
Todo, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast" import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { retry } from "@opencode-ai/core/util/retry" import { retry } from "@opencode-ai/core/util/retry"
import { batch } from "solid-js" import { batch } from "solid-js"
@@ -18,9 +19,7 @@ import type { State, VcsCache } from "./types"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query" import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery } from "../server-sync" import { loadMcpQuery } from "../global-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -29,7 +28,7 @@ type GlobalStore = {
session_todo: { session_todo: {
[sessionID: string]: Todo[] [sessionID: string]: Todo[]
} }
provider: NormalizedProviderListResponse provider: ProviderListResponse
provider_auth: ProviderAuthResponse provider_auth: ProviderAuthResponse
config: Config config: Config
reload: undefined | "pending" | "complete" reload: undefined | "pending" | "complete"
@@ -60,8 +59,8 @@ function errors(list: PromiseSettledResult<unknown>[]) {
const providerRev = new Map<string, number>() const providerRev = new Map<string, number>()
export function clearProviderRev(scope: ServerScope, directory: string) { export function clearProviderRev(directory: string) {
providerRev.delete(ScopedKey.from(scope, directory)) providerRev.delete(directory)
} }
function runAll(list: Array<() => Promise<unknown>>) { function runAll(list: Array<() => Promise<unknown>>) {
@@ -84,15 +83,15 @@ function showErrors(input: {
}) })
} }
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) => export const loadGlobalConfigQuery = (sdk: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: [scope, "config"], queryKey: ["config"],
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)), queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
}) })
export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) => export const loadProjectsQuery = (sdk: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: [scope, "project"], queryKey: ["project"],
queryFn: () => queryFn: () =>
retry(() => retry(() =>
sdk.project.list().then((x) => { sdk.project.list().then((x) => {
@@ -106,8 +105,7 @@ export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
}) })
export async function bootstrapGlobal(input: { export async function bootstrapGlobal(input: {
serverSDK: OpencodeClient globalSDK: OpencodeClient
scope: ServerScope
requestFailedTitle: string requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string formatMoreCount: (count: number) => string
@@ -115,12 +113,12 @@ export async function bootstrapGlobal(input: {
queryClient: QueryClient queryClient: QueryClient
}) { }) {
const slow = [ const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)), () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.globalSDK)),
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)), () => input.queryClient.fetchQuery(loadProvidersQuery(null, input.globalSDK)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)), () => input.queryClient.fetchQuery(loadPathQuery(null, input.globalSDK)),
() => () =>
input.queryClient input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverSDK)) .fetchQuery(loadProjectsQuery(input.globalSDK))
.then((data) => input.setGlobalStore("project", data)), .then((data) => input.setGlobalStore("project", data)),
] ]
await runAll(slow) await runAll(slow)
@@ -180,28 +178,26 @@ function warmSessions(input: {
).then(() => undefined) ).then(() => undefined)
} }
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => export const loadProvidersQuery = (directory: string | null, sdk: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: [scope, directory, "providers"], queryKey: [directory, "providers"],
queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))), queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
}) })
export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => export const loadAgentsQuery = (directory: string | null, sdk: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: [scope, directory, "agents"], queryKey: [directory, "agents"],
queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))), queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
}) })
export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => export const loadPathQuery = (directory: string | null, sdk: OpencodeClient) =>
queryOptions<Path>({ queryOptions<Path>({
queryKey: [scope, directory, "path"], queryKey: [directory, "path"],
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)), queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
}) })
export async function bootstrapDirectory(input: { export async function bootstrapDirectory(input: {
directory: string directory: string
scope: ServerScope
mcp: boolean
sdk: OpencodeClient sdk: OpencodeClient
store: Store<State> store: Store<State>
setStore: SetStoreFunction<State> setStore: SetStoreFunction<State>
@@ -212,7 +208,7 @@ export async function bootstrapDirectory(input: {
config: Config config: Config
path: Path path: Path
project: Project[] project: Project[]
provider: NormalizedProviderListResponse provider: ProviderListResponse
} }
queryClient: QueryClient queryClient: QueryClient
}) { }) {
@@ -226,15 +222,14 @@ export async function bootstrapDirectory(input: {
} }
if (loading) input.setStore("status", "partial") if (loading) input.setStore("status", "partial")
const revKey = ScopedKey.from(input.scope, input.directory) const rev = (providerRev.get(input.directory) ?? 0) + 1
const rev = (providerRev.get(revKey) ?? 0) + 1 providerRev.set(input.directory, rev)
providerRev.set(revKey, rev)
;(async () => { ;(async () => {
const slow = [ const slow = [
() => Promise.resolve(input.loadSessions(input.directory)), () => Promise.resolve(input.loadSessions(input.directory)),
() => () =>
input.queryClient input.queryClient
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk)) .ensureQueryData(loadAgentsQuery(input.directory, input.sdk))
.then((data) => input.setStore("agent", data)), .then((data) => input.setStore("agent", data)),
() => () =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))), retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
@@ -243,7 +238,7 @@ export async function bootstrapDirectory(input: {
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))), (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
!seededPath && !seededPath &&
(() => (() =>
input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => { input.queryClient.ensureQueryData(loadPathQuery(input.directory, input.sdk)).then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project) const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next) if (next) input.setStore("project", next)
})), })),
@@ -255,7 +250,7 @@ export async function bootstrapDirectory(input: {
if (next) input.vcsCache.setStore("value", next) if (next) input.vcsCache.setStore("value", next)
}), }),
), ),
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))), () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
() => () =>
retry(() => retry(() =>
input.sdk.permission.list().then((x) => { input.sdk.permission.list().then((x) => {
@@ -309,9 +304,9 @@ export async function bootstrapDirectory(input: {
}), }),
), ),
() => Promise.resolve(input.loadSessions(input.directory)), () => Promise.resolve(input.loadSessions(input.directory)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))), () => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk)),
() => () =>
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => { input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => {
const project = getFilename(input.directory) const project = getFilename(input.directory)
showToast({ showToast({
variant: "error", variant: "error",

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