Compare commits

..

2 Commits

Author SHA1 Message Date
Sebastian Herrlinger a494997a31 use keymap for run 2026-05-28 16:46:23 +02:00
Sebastian Herrlinger 449f2aeeb4 otui snapshot 2026-05-28 11:04:11 +02:00
2060 changed files with 86977 additions and 168702 deletions
-2
View File
@@ -1,2 +0,0 @@
packages/core/migration/**/snapshot.json linguist-generated
packages/core/src/database/migration.gen.ts linguist-generated
+4 -2
View File
@@ -1,3 +1,5 @@
# web + desktop packages # web + desktop packages
packages/app/ @Hona @Brendonovich packages/app/ @adamdotdevin
packages/desktop/ @Hona @Brendonovich packages/tauri/ @adamdotdevin
packages/desktop/src-tauri/ @brendonovich
packages/desktop/ @adamdotdevin
+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
+5 -15
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="" # Build with fakeHash to trigger hash mismatch and reveal correct hash
MAX_ATTEMPTS=3 nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
for ((ATTEMPT = 1; ATTEMPT <= MAX_ATTEMPTS; ATTEMPT++)); do
# Build with fakeHash to trigger hash mismatch and reveal correct hash
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || 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)"
[ -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 }}
+24 -4
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 --output-logs=errors-only --log-order=grouped --log-prefix=none
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
@@ -74,6 +73,26 @@ jobs:
working-directory: packages/opencode working-directory: packages/opencode
run: bun run test:httpapi run: bun run test:httpapi
- name: Publish unit reports
if: always()
uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0
with:
report_paths: packages/*/.artifacts/unit/junit.xml
check_name: "unit results (${{ matrix.settings.name }})"
detailed_summary: true
include_time_in_summary: true
fail_on_failure: false
- name: Upload unit artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
path: packages/*/.artifacts/unit/junit.xml
e2e: e2e:
name: e2e (${{ matrix.settings.name }}) name: e2e (${{ matrix.settings.name }})
strategy: strategy:
@@ -99,8 +118,7 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
# Playwright 1.59 hangs while extracting Chromium with Node 24.16. node-version: "24"
node-version: "24.15"
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
@@ -132,6 +150,7 @@ jobs:
run: bun --cwd packages/app test:e2e:local run: bun --cwd packages/app test:e2e:local
env: env:
CI: true CI: true
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
timeout-minutes: 30 timeout-minutes: 30
- name: Upload Playwright artifacts - name: Upload Playwright artifacts
@@ -142,5 +161,6 @@ jobs:
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 7 retention-days: 7
path: | path: |
packages/app/e2e/junit-*.xml
packages/app/e2e/test-results packages/app/e2e/test-results
packages/app/e2e/playwright-report packages/app/e2e/playwright-report
-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,7 +1,7 @@
--- ---
mode: primary mode: primary
hidden: true hidden: true
model: opencode/gpt-5.4-mini model: opencode/gpt-5.4-nano
color: "#44BA81" color: "#44BA81"
tools: tools:
"*": false "*": false
+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.
-10
View File
@@ -2,16 +2,6 @@
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"provider": {}, "provider": {},
"permission": {}, "permission": {},
"references": {
"effect": {
"repository": "github.com/Effect-TS/effect-smol",
"description": "Use for Effect v4 and effect-smol implementation details",
},
"opencode-local": {
"path": "~/.local/share/opencode",
"description": "Contains opencode logs and data",
},
},
"mcp": {}, "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).
+1 -26
View File
@@ -2,12 +2,6 @@
- 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.
## Branch Names
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
Examples: `session-recovery`, `fix-scroll-state`, `regenerate-sdk`.
## Commits and PR Titles ## Commits and PR Titles
Use conventional commit-style messages and PR titles: `type(scope): summary`. Use conventional commit-style messages and PR titles: `type(scope): summary`.
@@ -53,13 +47,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.
@@ -137,22 +124,10 @@ const table = sqliteTable("session", {
## Testing ## Testing
- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option. - Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests - Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. - Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
## 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 continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
- Keep 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.
-143
View File
@@ -1,143 +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 initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline.
**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.
**Admitted Prompt**:
A durable user input accepted into the Session inbox but not yet included in **Session History**.
**Prompt Promotion**:
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
**Provider Turn**:
One request to a model provider and the response projected from that request.
**Session Drain**:
One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
**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.
**PTY Environment**:
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
## 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**.
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once.
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity.
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Provider-Turn Boundary**.
- **Context Source** keys are stable and namespaced; duplicate keys fail composition. `SystemContext.combine(...)` preserves caller order; the **System Context Registry** evaluates producers concurrently and combines them in stable contribution-key order so rendered context remains deterministic.
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn.
- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline.
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- A **Context Epoch** begins with one immutable **Baseline System Context**.
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
- 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.
+1095 -1103
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", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] 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}`,
-5
View File
@@ -198,11 +198,6 @@ export const lakeCatalog = $interpolate`${glueCatalogName}/${tableBucket.name}`
export const lakeAthenaWorkgroup = athenaWorkgroup export const lakeAthenaWorkgroup = athenaWorkgroup
const ingestSecret = new random.RandomPassword("LakeIngestSecret", { length: 32 }) 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", { const ingestConfig = new sst.Linkable("LakeIngestConfig", {
properties: { properties: {
+15 -11
View File
@@ -1,6 +1,10 @@
import { lakeAthenaWorkgroup, lakeCatalog, lakeCluster, lakeQueryPermissions, lakeRegion, tableBucket } from "./lake" import { lakeAthenaWorkgroup, lakeCatalog, lakeCluster, lakeQueryPermissions, lakeRegion, tableBucket } from "./lake"
import { EMAILOCTOPUS_API_KEY } from "./app"
import { domain } from "./stage" const domain = (() => {
if ($app.stage === "production") return "stats.opencode.ai"
if ($app.stage === "dev") return "stats.dev.opencode.ai"
return `stats.${$app.stage}.dev.opencode.ai`
})()
//////////////// ////////////////
// LAKE // LAKE
@@ -159,15 +163,15 @@ new sst.x.DevCommand("StatsStudio", {
// APP // APP
//////////////// ////////////////
export const app = new sst.cloudflare.x.SolidStart("Stats", { // export const app = new sst.cloudflare.x.SolidStart("Stats", {
path: "packages/stats/app", // path: "packages/stats/app",
buildCommand: "bun run build", // buildCommand: "bun run build",
domain: `stats.${domain}`, // domain,
link: [database, EMAILOCTOPUS_API_KEY], // link: [database],
environment: { // environment: {
PUBLIC_URL: `https://${domain}/data`, // PUBLIC_URL: `https://${domain}`,
}, // },
}) // })
//////////////// ////////////////
// SERVICES // SERVICES
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-oWSGu+SP66Aquy/0Vaq7Bgp8404ZdOWbQX+O7h3jxHU=", "x86_64-linux": "sha256-KFVkxs0jtN1EXvwuxFMOlWg6BDr8L6K/wJZ/N73p8cI=",
"aarch64-linux": "sha256-UsS0+c+GwtIukmWwQeFbY/3Oaz3t4Q7C6cFMGkmlyAY=", "aarch64-linux": "sha256-7OLglRBBt5prwb9e0I0iMTXi3MrDfDI+8xcDrxtq/JI=",
"aarch64-darwin": "sha256-CArz92ewPmXO+ORFCBkCH8LzMpU/DjyaO4ic7QL0UpI=", "aarch64-darwin": "sha256-Q7iE6xJyomDnvqUgsHh+FlCnJFI6vK0zJSfI9BBYitQ=",
"x86_64-darwin": "sha256-rhnz9gmG6L06wIzfMhTaXDDEf6IbMD32CavqwXoqcUs=" "x86_64-darwin": "sha256-9lVsq2Bw74Vgphg6GSo/HxCtDXUyabxcS/vE1Um/Hfw="
} }
} }
+19 -27
View File
@@ -15,7 +15,7 @@
"lint": "oxlint", "lint": "oxlint",
"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", "sso": "aws sso login --sso-session=opencode --no-browser",
@@ -30,20 +30,17 @@
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@effect/opentelemetry": "4.0.0-beta.83", "@effect/opentelemetry": "4.0.0-beta.66",
"@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node": "4.0.0-beta.66",
"@effect/sql-sqlite-bun": "4.0.0-beta.83", "@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.4", "@opentui/core": "0.0.0-20260528-8a59fd68",
"@opentui/keymap": "0.3.4", "@opentui/keymap": "0.0.0-20260528-8a59fd68",
"@opentui/solid": "0.3.4", "@opentui/solid": "0.0.0-20260528-8a59fd68",
"@tanstack/solid-virtual": "3.13.28",
"@shikijs/stream": "4.2.0",
"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",
@@ -53,15 +50,15 @@
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0", "@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.2.10", "@pierre/diffs": "1.1.0-beta.18",
"opentui-spinner": "0.0.7", "opentui-spinner": "0.0.6",
"@solid-primitives/storage": "4.3.3", "@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
"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.83", "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,9 +75,10 @@
"zod": "4.1.8", "zod": "4.1.8",
"remeda": "2.26.0", "remeda": "2.26.0",
"sst": "4.13.1", "sst": "4.13.1",
"shiki": "4.2.0", "shiki": "3.20.0",
"solid-list": "0.3.0", "solid-list": "0.3.0",
"tailwindcss": "4.1.11", "tailwindcss": "4.1.11",
"virtua": "0.49.1",
"vite": "7.1.4", "vite": "7.1.4",
"@solidjs/meta": "0.29.4", "@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4", "@solidjs/router": "0.15.4",
@@ -89,7 +87,7 @@
"@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": {
@@ -134,25 +132,19 @@
"electron" "electron"
], ],
"overrides": { "overrides": {
"@opentui/core": "catalog:", "@opentui/core": "0.0.0-20260528-8a59fd68",
"@opentui/keymap": "catalog:", "@opentui/keymap": "0.0.0-20260528-8a59fd68",
"@opentui/solid": "catalog:", "@opentui/solid": "0.0.0-20260528-8a59fd68",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "catalog:" "@types/node": "catalog:"
}, },
"patchedDependencies": { "patchedDependencies": {
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@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", "@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", "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",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch"
} }
} }
-5
View File
@@ -1,8 +1,3 @@
## Priorities
- Prioritise, in this order: stability, simplicity, performance.
- Before changing session or timeline code, record a production benchmark baseline and compare it after the change.
## Debugging ## Debugging
- NEVER try to restart the app, or the server process, EVER. - NEVER try to restart the app, or the server process, EVER.
-13
View File
@@ -1,13 +0,0 @@
- Prioritize stability, then simplicity, then measurement overhead.
- Use Playwright for scenario control, isolation, and completion checks.
- Use Chrome Performance traces for generic browser profiling.
- Use Electron `contentTracing` for packaged multi-process profiling.
- Keep custom probes only for product-specific measurements.
- Do not duplicate measurements across the harness, probes, and traces.
- Run benchmarks serially to avoid cross-test contention.
- Run benchmarks against production builds.
- Keep detailed profiling opt-in when it changes workload behavior.
- Preserve raw diagnostic data or use lossless representations.
- Do not enforce machine-dependent performance thresholds.
- Assert scenario completion and metric collection only.
- Keep normal test discovery free of manual benchmarks.
-77
View File
@@ -1,77 +0,0 @@
# Manual app performance suite
The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially.
Run the suite explicitly from `packages/app`:
```sh
bun run test:bench
```
PowerShell:
```powershell
$env:PLAYWRIGHT_WORKERS = "1"
bun run test:bench
```
The suite contains:
- cold and hot session-tab timing
- cached session repaint and mutation tracing
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
All benchmarks import the shared `benchmark` fixture. Pages created through Playwright's `page` fixture automatically capture main-frame navigation history and emit a Chrome trace when `OPENCODE_PERFORMANCE_TRACE_DIR` is set. Benchmarks that need isolated browser contexts use `withBenchmarkPage`, which owns the context and the same diagnostics lifecycle.
New benchmarks should look like normal Playwright tests:
```ts
import { benchmark, expect } from "../benchmark"
benchmark("measures one interaction", async ({ page, report }) => {
// Only scenario-specific setup and interaction belong here.
report({ durationMs: 42 })
})
```
The fixture requires every benchmark to call `report()`, automatically names and closes traces, captures navigation history, attaches that history when a test fails, and emits metrics as a consistent `BENCHMARK` JSON line.
```text
BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}}
```
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows.
This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer).
These Playwright benchmarks profile the shared app renderer in Chromium. A future packaged Electron benchmark that needs main-process and multi-process attribution should use Electron's official [`contentTracing`](https://www.electronjs.org/docs/latest/api/content-tracing/) API rather than extending this renderer harness with bespoke process instrumentation.
CPU and high-volume visual profiling are disabled by default. Set `TIMELINE_CPU_PROFILE=1` to enable both, or additionally set `TIMELINE_VISUAL_PROFILE=0` for CPU-only profiling.
The streaming scenario's 30x CPU throttle is a deterministic stress profile, not a simulated end-user device.
Benchmarks do not assert machine-dependent performance budgets. Streaming processes 160 deltas by default and reports renderer-observed completion time, throughput, RAF callback-gap distributions, frame-budget equivalents, and long tasks through final geometry settlement. Delta count and delivery batch are included in result context when overridden. These are main-thread callback diagnostics, not compositor presentation or dropped-frame measurements. Visual-only and geometry metrics are `null` when their probes are disabled. Tab metrics describe sampled DOM observations. Assertions verify scenario and metric collection completion. Repeated repaint states are run-length grouped, but every original observation timestamp is retained alongside raw mutation batches and layout shifts.
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
## Chrome traces
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
```sh
OPENCODE_PERFORMANCE_TRACE_DIR=/tmp/opencode-performance-traces \
bunx playwright test --config e2e/performance/playwright.config.ts \
timeline/session-tab-switch-benchmark.spec.ts
```
The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies:
Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss.
```sh
bunx devtools-tracing stats <trace-path-from-BENCHMARK_PAGE>
```
INP analysis requires a trace with a supported navigation/interaction insight. Selector statistics require a trace captured with `OPENCODE_PERFORMANCE_SELECTOR_TRACE=1`.
`e2e/performance/playwright.uncapped.config.ts` disables Chromium frame-rate limiting for explicit uncapped diagnostics. Native product benchmarks should use the default Playwright configuration.
-144
View File
@@ -1,144 +0,0 @@
import { expect, test as base, type Browser, type Page, type TestInfo } from "@playwright/test"
import { startChromeTrace } from "./chrome-trace"
type BenchmarkFixtures = {
report: (metrics: Record<string, unknown>, context?: Record<string, unknown>) => void
reportState: { payload?: { metrics: Record<string, unknown>; context: Record<string, unknown> } }
benchmarkResult: void
}
export type PerformancePageDiagnostics = {
navigations: string[]
stop: () => Promise<string | undefined>
}
const pages = new WeakMap<Page, PerformancePageDiagnostics>()
export const benchmark = base.extend<BenchmarkFixtures>({
reportState: async ({}, use) => use({}),
report: async ({ reportState }, use) => {
await use((metrics, context = {}) => {
if (reportState.payload) throw new Error("Benchmark reported metrics more than once")
reportState.payload = { metrics, context }
})
},
benchmarkResult: [
async ({ reportState }, use, testInfo) => {
await use()
const missing = !reportState.payload
console.log(
`BENCHMARK ${JSON.stringify({
schemaVersion: 2,
runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
name: benchmarkName(testInfo),
status: missing ? "failed" : testInfo.status,
expectedStatus: testInfo.expectedStatus,
retry: testInfo.retry,
repeatEachIndex: testInfo.repeatEachIndex,
context: {
project: testInfo.project.name,
platform: process.platform,
...reportState.payload?.context,
},
metrics: reportState.payload?.metrics ?? null,
error: missing ? "Benchmark did not report metrics" : undefined,
})}`,
)
if (missing && testInfo.status === testInfo.expectedStatus)
throw new Error(`Benchmark did not report metrics: ${benchmarkName(testInfo)}`)
},
{ auto: true },
],
page: async ({ page }, use, testInfo) => {
const name = benchmarkName(testInfo)
const diagnostics = await observePerformancePage(page, name)
try {
await use(page)
} finally {
try {
await reportPerformancePage(name, diagnostics, testInfo)
} finally {
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach("performance-navigations", {
body: JSON.stringify(diagnostics.navigations, null, 2),
contentType: "application/json",
})
}
}
}
},
})
function benchmarkName(testInfo: TestInfo) {
return testInfo.titlePath.slice(1).join(" > ")
}
export { expect }
async function observePerformancePage(page: Page, name: string) {
const navigations: string[] = []
const onNavigation = (frame: ReturnType<Page["mainFrame"]>) => {
if (frame === page.mainFrame()) navigations.push(frame.url())
}
page.on("framenavigated", onNavigation)
const stopTrace = await startChromeTrace(page, name).catch((error) => {
page.off("framenavigated", onNavigation)
throw error
})
let stopping: Promise<string | undefined> | undefined
const diagnostics: PerformancePageDiagnostics = {
navigations,
stop() {
page.off("framenavigated", onNavigation)
return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined))
},
}
pages.set(page, diagnostics)
return diagnostics
}
export async function withBenchmarkPage<T>(
browser: Browser,
name: string,
run: (page: Page) => Promise<T>,
testInfo?: TestInfo,
) {
const context = await browser.newContext()
try {
const page = await context.newPage()
const diagnostics = await observePerformancePage(page, name)
try {
return await run(page)
} finally {
await reportPerformancePage(name, diagnostics, testInfo)
}
} finally {
await context.close()
}
}
async function reportPerformancePage(name: string, diagnostics: PerformancePageDiagnostics, testInfo?: TestInfo) {
const trace = await diagnostics.stop()
console.log(
`BENCHMARK_PAGE ${JSON.stringify({
schemaVersion: 2,
runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
name,
test: testInfo ? benchmarkName(testInfo) : undefined,
retry: testInfo?.retry,
repeatEachIndex: testInfo?.repeatEachIndex,
context: {
platform: process.platform,
trace,
selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1",
},
navigations: diagnostics.navigations,
})}`,
)
}
export function benchmarkDiagnostics(page: Page) {
const diagnostics = pages.get(page)
if (!diagnostics) throw new Error("Performance diagnostics are not installed for this page")
return diagnostics
}
@@ -1,95 +0,0 @@
import type { CDPSession, Page } from "@playwright/test"
import path from "node:path"
import { mkdir, open, rename } from "node:fs/promises"
import { Buffer } from "node:buffer"
import { createHash, randomUUID } from "node:crypto"
const categories = [
"-*",
"devtools.timeline",
"v8.execute",
"disabled-by-default-devtools.timeline",
"disabled-by-default-devtools.timeline.frame",
"toplevel",
"blink.console",
"blink.user_timing",
"latencyInfo",
"disabled-by-default-devtools.timeline.stack",
"disabled-by-default-v8.cpu_profiler",
]
export async function startChromeTrace(page: Page, name: string) {
const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR
if (!directory) return
const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1"
const file = await prepareChromeTrace(directory, name, selectors)
const session = await page.context().newCDPSession(page)
try {
await session.send("Tracing.start", {
transferMode: "ReturnAsStream",
traceConfig: {
excludedCategories: categories
.filter((category) => category.startsWith("-"))
.map((category) => category.slice(1)),
includedCategories: [
...categories.filter((category) => !category.startsWith("-")),
...(selectors
? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"]
: []),
],
},
})
} catch (error) {
await Promise.allSettled([session.detach()])
throw error
}
let stopping: Promise<string> | undefined
return () =>
(stopping ??= (async () => {
try {
const complete = new Promise<{ stream?: string; dataLossOccurred: boolean }>((resolve) =>
session.once("Tracing.tracingComplete", resolve),
)
await session.send("Tracing.end")
const result = await complete
if (!result.stream) throw new Error(`Chrome trace stream missing: ${file}`)
const partial = `${file}.partial`
await writeProtocolStream(session, result.stream, partial)
if (result.dataLossOccurred) throw new Error(`Chrome trace lost data; partial capture retained: ${partial}`)
await rename(partial, file)
return file
} finally {
await Promise.allSettled([session.detach()])
}
})())
}
export async function prepareChromeTrace(
directory: string,
name: string,
selectors: boolean,
nonce = randomUUID().slice(0, 8),
) {
await mkdir(directory, { recursive: true })
const run = process.env.OPENCODE_PERFORMANCE_RUN_ID ?? "manual"
const hash = createHash("sha256").update(name).digest("hex").slice(0, 8)
return path.join(
directory,
`${run}-${name.replace(/[^a-zA-Z0-9_-]/g, "-")}-${hash}-${nonce}${selectors ? "-selectors" : ""}.json`,
)
}
async function writeProtocolStream(session: CDPSession, handle: string, file: string) {
const output = await open(file, "wx")
try {
while (true) {
const chunk = await session.send("IO.read", { handle })
await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data)
if (chunk.eof) break
}
} finally {
await Promise.allSettled([output.close(), session.send("IO.close", { handle })])
}
}
@@ -1,20 +0,0 @@
import config from "../../playwright.config"
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
process.env.PLAYWRIGHT_SERVER_PORT = String(port)
process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
export default {
...config,
testDir: ".",
testIgnore: "unit/**",
outputDir: "../test-results/performance",
fullyParallel: false,
workers: 1,
reporter: [["html", { outputFolder: "../playwright-report/performance", open: "never" }], ["line"]],
webServer: {
...config.webServer,
command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
reuseExistingServer: false,
},
}
@@ -1,13 +0,0 @@
import config from "./playwright.config"
export default {
...config,
outputDir: "../test-results/performance-uncapped",
reporter: [["html", { outputFolder: "../playwright-report/performance-uncapped", open: "never" }], ["line"]],
use: {
...config.use,
launchOptions: {
args: ["--disable-frame-rate-limit", "--disable-gpu-vsync"],
},
},
}
@@ -1,49 +0,0 @@
import { benchmark, expect } from "../benchmark"
import { expectSessionTitle } from "../../utils/waits"
import { fixture } from "./session-timeline-stress.fixture"
import {
collectCachedRepaintTrace,
compressCachedRepaintTrace,
installCachedRepaintProbe,
waitForCachedRepaintWindow,
} from "./session-tab-repaint-probe"
import { waitForStableTimeline } from "./session-tab-switch-probe"
import {
installStressSessionTabs,
installTimelineSettings,
mockStressTimeline,
stressSessionHref,
} from "./timeline-test-helpers"
benchmark("samples cached session repaint after the click", async ({ page, report }) => {
benchmark.setTimeout(120_000)
await mockStressTimeline(page)
await installStressSessionTabs(page)
await installTimelineSettings(page)
await page.goto(stressSessionHref(fixture.targetID))
await expectSessionTitle(page, fixture.expected.targetTitle)
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await page
.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`)
.first()
.click()
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
await installCachedRepaintProbe(page, {
targetHref: stressSessionHref(fixture.targetID),
destination: fixture.messages[fixture.targetID].map((message) => message.info.id),
source: fixture.messages[fixture.sourceID].map((message) => message.info.id),
last: fixture.expected.targetMessageIDs.at(-1)!,
windowMs: 1_000,
})
await page
.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`)
.first()
.click()
await Promise.all([expectSessionTitle(page, fixture.expected.targetTitle), waitForCachedRepaintWindow(page, 1_000)])
const result = await collectCachedRepaintTrace(page)
report(compressCachedRepaintTrace(result))
expect(result.samples.length).toBeGreaterThan(0)
})
@@ -1,251 +0,0 @@
import type { Page } from "@playwright/test"
type CachedRepaintTrace = {
timeOriginEpochMs: number
startedAtPerformanceMs: number
samples: {
observedAtMs: number
root: number | undefined
scrollTop: number
scrollHeight: number
bottomErrorPx: number | undefined
last: boolean
rows: { key: string | undefined; node: number; top: number; bottom: number }[]
mounted: number
center: string | undefined
destination: string[]
source: string[]
}[]
mutations: { observedAtMs: number; changed: { type: string; node: number }[] }[]
shifts: { occurredAtMs: number; value: number }[]
windowMs: number
running: boolean
stop: () => void
}
export async function installCachedRepaintProbe(
page: Page,
input: { targetHref: string; destination: string[]; source: string[]; last: string; windowMs: number },
) {
await page.evaluate(({ targetHref, destination, source, last, windowMs }) => {
const destinationIDs = new Set(destination)
const sourceIDs = new Set(source)
const nodeIDs = new WeakMap<Node, number>()
let nextNodeID = 1
const id = (node: Node) => {
const current = nodeIDs.get(node)
if (current) return current
nodeIDs.set(node, nextNodeID)
return nextNodeID++
}
const state: CachedRepaintTrace = {
timeOriginEpochMs: performance.timeOrigin,
startedAtPerformanceMs: 0,
samples: [],
mutations: [],
shifts: [],
windowMs,
running: false,
stop: () => {},
}
const recordShifts = (entries: PerformanceEntry[]) => {
if (!state.running) return
state.shifts.push(
...entries
.map((entry) => {
if (
entry.startTime < state.startedAtPerformanceMs ||
entry.startTime > state.startedAtPerformanceMs + state.windowMs
)
return
return {
occurredAtMs: entry.startTime - state.startedAtPerformanceMs,
value: (entry as PerformanceEntry & { value: number }).value,
}
})
.filter((entry): entry is { occurredAtMs: number; value: number } => entry !== undefined),
)
}
const shiftObserver = new PerformanceObserver((entries) => recordShifts(entries.getEntries()))
shiftObserver.observe({ type: "layout-shift" })
const recordMutations = (entries: MutationRecord[]) => {
if (!state.running) return
const observedAtMs = performance.now() - state.startedAtPerformanceMs
if (observedAtMs > state.windowMs) return
const changed = entries.flatMap((entry) => [
...[...entry.addedNodes].map((node) => ({ type: "add", node: id(node) })),
...[...entry.removedNodes].map((node) => ({ type: "remove", node: id(node) })),
])
if (changed.length) state.mutations.push({ observedAtMs, changed })
}
const mutationObserver = new MutationObserver(recordMutations)
mutationObserver.observe(document.documentElement, { childList: true, subtree: true })
state.stop = () => {
recordShifts(shiftObserver.takeRecords())
recordMutations(mutationObserver.takeRecords())
state.running = false
shiftObserver.disconnect()
mutationObserver.disconnect()
}
const sample = () => {
if (!state.running) return
setTimeout(() => {
if (!state.running) return
const observedAtMs = performance.now() - state.startedAtPerformanceMs
if (observedAtMs > state.windowMs) return
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (root) {
const view = root.getBoundingClientRect()
const rows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
.map((element) => ({
key: element.dataset.timelineKey,
node: id(element),
rect: element.getBoundingClientRect(),
}))
.filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
.map((item) => ({
key: item.key,
node: item.node,
top: item.rect.top - view.top,
bottom: item.rect.bottom - view.top,
}))
const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((element) => element.dataset.messageId!)
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
state.samples.push({
observedAtMs,
root: id(root),
scrollTop: root.scrollTop,
scrollHeight: root.scrollHeight,
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
last: messages.includes(last),
rows,
mounted: root.querySelectorAll("[data-timeline-key]").length,
center: document
.elementFromPoint(view.left + view.width / 2, view.top + view.height / 2)
?.textContent?.slice(0, 80),
destination: messages.filter((messageID) => destinationIDs.has(messageID)),
source: messages.filter((messageID) => sourceIDs.has(messageID)),
})
} else {
state.samples.push({
observedAtMs,
root: undefined,
scrollTop: 0,
scrollHeight: 0,
bottomErrorPx: undefined,
last: false,
rows: [],
mounted: 0,
center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
destination: [],
source: [],
})
}
requestAnimationFrame(sample)
}, 0)
}
document.addEventListener(
"click",
(event) => {
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== targetHref) return
state.startedAtPerformanceMs = performance.now()
state.running = true
requestAnimationFrame(sample)
},
{ capture: true, once: true },
)
;(window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash = state
}, input)
}
export function layoutShiftSample(entry: Pick<PerformanceEntry, "startTime"> & { value: number }, started: number) {
if (entry.startTime < started) return
return { occurredAtMs: entry.startTime - started, value: entry.value }
}
export async function waitForCachedRepaintWindow(page: Page, durationMs: number) {
await page.waitForFunction((durationMs) => {
const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash
return !!state?.running && performance.now() - state.startedAtPerformanceMs >= durationMs
}, durationMs)
}
export async function collectCachedRepaintTrace(page: Page) {
return page.evaluate(() => {
const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash!
state.stop()
return state
})
}
export function summarizeCachedRepaintTrace(trace: CachedRepaintTrace) {
const roots = trace.samples.map((sample) => sample.root)
const bottomErrors = trace.samples.flatMap((sample) =>
sample.bottomErrorPx === undefined ? [] : [Math.abs(sample.bottomErrorPx)],
)
const category = (sample: CachedRepaintTrace["samples"][number]) => {
if (sample.source.length) return "source"
if (sample.root === undefined || sample.rows.length === 0) return "blank"
if (!sample.destination.length) return "unknown"
if (sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) return "correct"
return "wrongDestination"
}
return {
samples: trace.samples.length,
durationMs: trace.samples.at(-1)?.observedAtMs ?? 0,
firstSampleObservedMs: trace.samples[0]?.observedAtMs,
firstSampleCorrect: trace.samples[0] ? category(trace.samples[0]) === "correct" : false,
blankSamples: trace.samples.filter((sample) => category(sample) === "blank").length,
sourceSamples: trace.samples.filter((sample) => category(sample) === "source").length,
wrongDestinationSamples: trace.samples.filter((sample) => category(sample) === "wrongDestination").length,
unknownSamples: trace.samples.filter((sample) => category(sample) === "unknown").length,
rootChanges: roots.slice(1).filter((root, index) => root !== roots[index]).length,
mountedMin: trace.samples.length ? Math.min(...trace.samples.map((sample) => sample.mounted)) : 0,
mountedMax: Math.max(...trace.samples.map((sample) => sample.mounted)),
maxBottomErrorPx: Math.max(0, ...bottomErrors),
mutationBatches: trace.mutations.length,
addedNodes: trace.mutations.reduce(
(sum, batch) => sum + batch.changed.filter((change) => change.type === "add").length,
0,
),
removedNodes: trace.mutations.reduce(
(sum, batch) => sum + batch.changed.filter((change) => change.type === "remove").length,
0,
),
layoutShiftValueSum: trace.shifts.reduce((sum, shift) => sum + shift.value, 0),
maxLayoutShiftValue: Math.max(0, ...trace.shifts.map((shift) => shift.value)),
}
}
export function compressCachedRepaintTrace(trace: CachedRepaintTrace) {
const samples: {
observedAtMs: number[]
state: Omit<CachedRepaintTrace["samples"][number], "observedAtMs">
}[] = []
for (const sample of trace.samples) {
const { observedAtMs, ...state } = sample
const previous = samples.at(-1)
if (previous && JSON.stringify(previous.state) === JSON.stringify(state)) {
previous.observedAtMs.push(observedAtMs)
continue
}
samples.push({ observedAtMs: [observedAtMs], state })
}
return {
timeOriginEpochMs: trace.timeOriginEpochMs,
startedAtPerformanceMs: trace.startedAtPerformanceMs,
windowMs: trace.windowMs,
summary: summarizeCachedRepaintTrace(trace),
samples,
mutations: trace.mutations,
shifts: trace.shifts,
}
}
@@ -1,79 +0,0 @@
import type { Page } from "@playwright/test"
import { expectSessionTitle } from "../../utils/waits"
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
import { fixture } from "./session-timeline-stress.fixture"
import { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
type Result = Awaited<ReturnType<typeof measureSessionSwitch>>
benchmark("benchmarks cold and hot session tab switching", async ({ browser, report }, testInfo) => {
benchmark.setTimeout(180_000)
const results = { cold: [] as Result[], hot: [] as Result[] }
for (const mode of ["cold", "hot"] as const) {
for (let run = 0; run < 5; run++) {
results[mode].push(
await withBenchmarkPage(browser, `session-tab-switch-${mode}-${run}`, (page) => trial(page, mode), testInfo),
)
}
}
report({ results, summary: summarize(results) })
})
async function trial(page: Page, mode: "cold" | "hot") {
await mockStressTimeline(page)
await installStressSessionTabs(page)
if (mode === "hot") {
await page.goto(stressSessionHref(fixture.targetID))
await expectSessionTitle(page, fixture.expected.targetTitle)
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
} else {
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
}
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id)
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id)
const lastID = fixture.expected.targetMessageIDs.at(-1)!
const href = stressSessionHref(fixture.targetID)
const result = await measureSessionSwitch(page, {
destinationIDs,
sourceIDs,
lastID,
href,
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
})
return result
}
function summarize(results: Record<"cold" | "hot", Result[]>) {
const stats = (values: (number | null)[]) => {
const sorted = values.filter((value): value is number => value !== null).sort((a, b) => a - b)
return {
min: sorted[0] ?? null,
median: sorted[Math.floor(sorted.length / 2)] ?? null,
max: sorted.at(-1) ?? null,
missing: values.length - sorted.length,
}
}
return Object.fromEntries(
Object.entries(results).map(([mode, values]) => [
mode,
{
firstDestinationObservedMs: stats(values.map((value) => value.firstDestinationObservedMs)),
firstCorrectObservedMs: stats(values.map((value) => value.firstCorrectObservedMs)),
stableObservedMs: stats(values.map((value) => value.stableObservedMs)),
},
]),
)
}
async function switchSession(page: Page, sessionID: string, title: string) {
const href = stressSessionHref(sessionID)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
await tab.click()
await expectSessionTitle(page, title)
}
@@ -1,46 +0,0 @@
export type SessionSwitchSample = {
observedAtMs: number
destination: string[]
source: string[]
hasVisibleRows: boolean
last: boolean
bottomErrorPx?: number
}
export function classifySessionSwitch(samples: SessionSwitchSample[]) {
const firstDestination = samples.findIndex((sample) => sample.destination.length > 0)
const firstCorrect = samples.findIndex(isCorrectDestination)
const stable = samples.findIndex((_, index) => isStableSessionSwitch(samples.slice(index, index + 3)))
return {
firstDestinationObservedMs: samples[firstDestination]?.observedAtMs ?? null,
firstCorrectObservedMs: samples[firstCorrect]?.observedAtMs ?? null,
stableObservedMs: samples[stable + 2]?.observedAtMs ?? null,
wrongDestinationSamples: samples
.slice(firstDestination)
.filter((sample) => sample.destination.length > 0 && !sample.last).length,
blankSamples: samples.filter((sample) => !sample.hasVisibleRows).length,
unknownSamples: samples.filter(
(sample) => sample.hasVisibleRows && sample.destination.length === 0 && sample.source.length === 0,
).length,
sourceSamples: samples.filter((sample) => sample.source.length > 0).length,
}
}
export function isCorrectDestination(sample: SessionSwitchSample) {
return (
sample.destination.length > 0 &&
sample.source.length === 0 &&
sample.last &&
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1
)
}
export function isStableSessionSwitch(samples: SessionSwitchSample[]) {
return samples.length === 3 && samples.every(isCorrectDestination)
}
export function isStableDestination(samples: Pick<SessionSwitchSample, "last" | "bottomErrorPx">[]) {
return (
samples.length === 3 && samples.every((sample) => sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
)
}
@@ -1,152 +0,0 @@
import { expect, type Page } from "@playwright/test"
import { classifySessionSwitch, isStableDestination, type SessionSwitchSample } from "./session-tab-switch-metrics"
type SessionSwitchProbe = {
samples: SessionSwitchSample[]
stop: () => void
}
async function installSessionSwitchProbe(
page: Page,
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string },
) {
await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => {
const destination = new Set(destinationIDs)
const source = new Set(sourceIDs)
const samples: SessionSwitchSample[] = []
let started: number | undefined
let running = true
const sample = () => {
if (!running || started === undefined) return
setTimeout(() => {
if (!running || started === undefined) return
const observedAtMs = performance.now() - started
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (root) {
const view = root.getBoundingClientRect()
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((element) => element.dataset.messageId!)
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
samples.push({
observedAtMs,
destination: visible.filter((id) => destination.has(id)),
source: visible.filter((id) => source.has(id)),
hasVisibleRows,
last: visible.includes(lastID),
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
})
} else {
samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false })
}
requestAnimationFrame(sample)
}, 0)
}
document.addEventListener(
"click",
(event) => {
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
requestAnimationFrame(sample)
},
{ capture: true, once: true },
)
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
samples,
stop: () => {
running = false
},
}
}, input)
}
async function waitForStableSessionSwitch(page: Page) {
await page.waitForFunction(() => {
const samples = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.samples
if (!samples) return false
return samples.some((_, index) => {
const stable = samples.slice(index, index + 3)
return (
stable.length === 3 &&
stable.every(
(sample) =>
sample.destination.length > 0 &&
sample.source.length === 0 &&
sample.last &&
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1,
)
)
})
})
}
async function collectSessionSwitchResult(page: Page) {
const samples = await page.evaluate(() => {
const probe = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe!
probe.stop()
return probe.samples
})
return classifySessionSwitch(samples)
}
export async function measureSessionSwitch(
page: Page,
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise<void> },
) {
const { switch: run, ...probe } = input
await installSessionSwitchProbe(page, probe)
await run()
await waitForStableSessionSwitch(page)
return collectSessionSwitchResult(page)
}
export async function waitForStableTimeline(page: Page, lastID: string) {
const samples: Pick<SessionSwitchSample, "last" | "bottomErrorPx">[] = []
await expect
.poll(
async () => {
samples.push(
await page.evaluate(
(lastID) =>
new Promise<Pick<SessionSwitchSample, "last" | "bottomErrorPx">>((resolve) => {
requestAnimationFrame(() =>
setTimeout(() => {
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (!root) {
resolve({ last: false })
return
}
const view = root.getBoundingClientRect()
const last = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].some((element) => {
if (element.dataset.messageId !== lastID) return false
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
const spacer = root
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect()
resolve({ last, bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined })
}, 0),
)
}),
lastID,
),
)
return isStableDestination(samples.slice(-3))
},
{ timeout: 30_000, intervals: [0] },
)
.toBe(true)
}
@@ -1,488 +0,0 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import type { Page } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
import { expect } from "../benchmark"
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"
export 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>
}
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],
}
export async function setupTimelineBenchmark(page: Page, options: { historyTurns: number; eventBatch: number }) {
const events: EventPayload[] = []
let eventBatch = options.eventBatch
await mockOpenCodeServer(page, {
directory,
project: project(),
provider: provider(),
sessions: [session()],
pageMessages: () => ({
items: [
...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(),
userMessage,
assistantMessage,
],
}),
events: () => events.splice(0, eventBatch),
eventRetry: 16,
})
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
})
await page.setViewportSize({ width: 1366, height: 768 })
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first()
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expectAppVisible(scroller)
return {
scroller,
text,
transport: {
enqueue(payload: EventPayload | EventPayload[]) {
events.push(...(Array.isArray(payload) ? payload : [payload]))
},
pendingCount() {
return events.length
},
releaseAll() {
eventBatch = events.length
},
},
async scrollToBottom() {
await scroller.evaluate((element) => {
element.scrollTop = element.scrollHeight
})
},
async waitForStableGeometry() {
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
await page.waitForFunction((partID) => {
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector(`[data-timeline-part-id="${partID}"]`),
)
if (!root) return false
return new Promise<boolean>((resolve) => {
const height = root.scrollHeight
requestAnimationFrame(() =>
requestAnimationFrame(() =>
resolve(root.scrollHeight === height && root.scrollHeight - root.clientHeight - root.scrollTop <= 1),
),
)
})
}, textPartID)
},
}
}
export function buildInitialStreamEvent(deltaCount: number): EventPayload {
return {
directory,
payload: {
type: "message.part.updated",
properties: {
part: {
...streamedTextPart,
text: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
},
},
},
}
}
export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] {
return Array.from({ length: deltaCount }, (_, index) => ({
directory,
payload: {
type: "message.part.delta",
properties: {
messageID: assistantMessageID,
partID: textPartID,
field: "text",
delta: streamChunk(index + 1, deltaCount + 1),
},
},
}))
}
function performanceTurn(index: number) {
const suffix = String(index).padStart(4, "0")
const userID = `msg_0000_${suffix}_a_user`
const assistantID = `msg_0000_${suffix}_b_assistant`
const before = historicalSource(index, false)
const after = historicalSource(index, true)
const parts = [
...(index % 5 === 0
? [
{
id: `prt_0000_${suffix}_reasoning`,
sessionID,
messageID: assistantID,
type: "reasoning",
text: `Reviewing the existing implementation. ${"constraint analysis ".repeat(20)}`,
time: { start: 1690000001000 + index * 2_000, end: 1690000001200 + index * 2_000 },
},
]
: []),
{
id: `prt_0000_${suffix}_assistant`,
sessionID,
messageID: assistantID,
type: "text",
text: historicalMarkdown(index),
},
...(index % 8 === 0
? [
{
id: `prt_0000_${suffix}_edit`,
sessionID,
messageID: assistantID,
type: "tool",
callID: `call_0000_${suffix}_edit`,
tool: "edit",
state: {
status: "completed",
input: { filePath: `src/history-${index}.ts` },
output: `Edited src/history-${index}.ts`,
title: `src/history-${index}.ts`,
metadata: {
filediff: { file: `src/history-${index}.ts`, additions: 48, deletions: 48, before, after },
},
time: { start: 1690000001200 + index * 2_000, end: 1690000001400 + index * 2_000 },
},
},
]
: []),
...(index % 12 === 0
? [
{
id: `prt_0000_${suffix}_write`,
sessionID,
messageID: assistantID,
type: "tool",
callID: `call_0000_${suffix}_write`,
tool: "write",
state: {
status: "completed",
input: { filePath: `src/generated-${index}.tsx`, content: after },
output: `Wrote src/generated-${index}.tsx`,
title: `src/generated-${index}.tsx`,
metadata: {
filediff: { file: `src/generated-${index}.tsx`, additions: 32, deletions: 0, before: "", after },
},
time: { start: 1690000001400 + index * 2_000, end: 1690000001500 + index * 2_000 },
},
},
]
: []),
...(index % 16 === 0
? [
{
id: `prt_0000_${suffix}_patch`,
sessionID,
messageID: assistantID,
type: "tool",
callID: `call_0000_${suffix}_patch`,
tool: "apply_patch",
state: {
status: "completed",
input: { patchText: realisticPatch(index) },
output: "Success. Updated src/components/SessionCard.tsx",
title: "src/components/SessionCard.tsx",
metadata: {
files: [
{
filePath: "src/components/SessionCard.tsx",
relativePath: "src/components/SessionCard.tsx",
type: "update",
additions: 8,
deletions: 3,
patch: realisticPatch(index),
before,
after,
},
],
},
time: { start: 1690000001500 + index * 2_000, end: 1690000001700 + index * 2_000 },
},
},
]
: []),
]
return [
{
info: {
id: userID,
sessionID,
role: "user",
time: { created: 1690000000000 + index * 2_000 },
summary: { diffs: [] },
agent: "build",
model,
},
parts: [
{
id: `prt_0000_${suffix}_user`,
sessionID,
messageID: userID,
type: "text",
text: `Historical prompt ${index}`,
},
],
},
{
info: {
id: assistantID,
sessionID,
role: "assistant",
time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 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,
},
]
}
function historicalMarkdown(index: number) {
const code = `import { For, Show, createSignal } from "solid-js"
type SessionRow = { id: string; title: string; active: boolean }
export function SessionList(props: { rows: SessionRow[] }) {
const [selected, setSelected] = createSignal<string>()
return (
<section aria-label="Sessions">
<For each={props.rows}>{(row) => (
<button classList={{ active: row.active }} onClick={() => setSelected(row.id)}>
<Show when={selected() === row.id} fallback={row.title}>{row.title.toUpperCase()}</Show>
</button>
)}</For>
</section>
)
}`
return `## Session renderer review ${index}
The active session keeps **semantic row identity** while reconciling measured content. See [Solid documentation](https://docs.solidjs.com/) and the inline \`measureElement(node)\` call.
| Concern | Current behavior | Verification |
| --- | --- | --- |
| streaming | appends Markdown blocks | painted frames |
| geometry | anchors visible rows | DOM coordinates |
| tools | preserves expanded state | keyed remount probe |
> Long sessions combine Markdown, syntax highlighting, tool output, and asynchronously rendered diffs.
${index % 4 === 0 ? `\`\`\`tsx\n${code}\n\`\`\`\n\n\`\`\`bash\nbun typecheck\nbun test --preload ./happydom.ts ./src/pages/session\ngit diff --check\n\`\`\`` : "- preserve the viewport anchor\n- avoid replacing stable Markdown nodes\n- process provider deltas without blocking input"}`
}
function historicalSource(index: number, updated: boolean) {
const method = updated ? "toLocaleUpperCase(props.locale)" : "toUpperCase()"
const limit = updated ? 24 : 20
return `import { createMemo, For } from "solid-js"
type Message = {
id: string
role: "user" | "assistant"
text: string
tokens: { input: number; output: number }
}
export function MessageSummary(props: { messages: Message[]; locale: string }) {
const visible = createMemo(() => props.messages.filter((message) => message.text.trim()).slice(-${limit}))
const total = createMemo(() => visible().reduce((sum, message) => sum + message.tokens.output, 0))
return (
<article data-session-index="${index}">
<header>{total().toLocaleString(props.locale)} output tokens</header>
<For each={visible()}>{(message) => <p data-role={message.role}>{message.text.${method}}</p>}</For>
</article>
)
}
`
}
function realisticPatch(index: number) {
return `*** Begin Patch
*** Update File: src/components/SessionCard.tsx
@@
-const title = props.session.title.toUpperCase()
-const messages = props.messages.slice(-20)
+const title = props.session.title.toLocaleUpperCase(props.locale)
+const messages = props.messages.filter((message) => message.text.trim()).slice(-24)
+const outputTokens = messages.reduce((sum, message) => sum + message.tokens.output, 0)
@@
- <h2>{title}</h2>
+ <h2 data-session-index="${index}">{title}</h2>
+ <span>{outputTokens.toLocaleString(props.locale)} output tokens</span>
*** End Patch`
}
export function streamChunk(index: number, count: number) {
if (index === 0) return `\n\n## Implementation plan\n\nStreaming **bold analysis`
if (index === count - 1)
return `\n\`\`\`\n\n## Verification\n\n- **Typecheck:** passed\n- **Timeline geometry:** stable\n- **Streaming output:** benchmark-complete <!-- stream-${index} -->`
const section = Math.floor(index / 18) + 1
const fragments = [
` continues across three`,
` or four word`,
` provider deltas and`,
` closes in this fragment**. <!-- stream-${index} -->\n\n`,
`| Concern | State`,
` | Verification |\n|`,
` --- | ---`,
` | --- |\n|`,
` markdown | incremental |`,
` painted frames | <!-- stream-${index} -->\n\n`,
`\`\`\`tsx\nconst row: SessionRow`,
` = rows[index] ??`,
` fallback\nconst title =`,
` row.title.toLocaleUpperCase(locale)\n`,
`const selected = createMemo(()`,
` => row.id ===`,
` activeID()) // stream-${index}\n`,
`// stream-${index}\n\`\`\`\n\n### Iteration ${section}\n\nStreaming **bold analysis`,
]
return fragments[(index - 1) % fragments.length]!
}
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" },
}
}
@@ -1,85 +0,0 @@
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
import {
buildInitialStreamEvent,
buildStreamDeltaEvents,
setupTimelineBenchmark,
textPartID,
} from "./session-timeline-benchmark.fixture"
import { startTimelineProfile } from "./session-timeline-profile"
import {
collectTimelineStreamMetrics,
installTimelineStreamProbe,
startTimelineStreamProbe,
} from "./session-timeline-stream-probe"
benchmark.describe("performance: session timeline streaming", () => {
benchmark("streams assistant text without remounting or oscillating", async ({ page, report }) => {
benchmark.setTimeout(480_000)
const cpuThrottle = Number(process.env.TIMELINE_CPU_THROTTLE ?? 30)
const deltaCount = Number(process.env.TIMELINE_DELTA_COUNT ?? 160)
const historyTurns = Number(process.env.TIMELINE_HISTORY_TURNS ?? 320)
const eventBatch = Number(process.env.TIMELINE_EVENT_BATCH ?? 1)
const minimal = process.env.TIMELINE_MINIMAL === "1"
const profileCPU = process.env.TIMELINE_CPU_PROFILE === "1"
const profileVisual = !minimal && profileCPU && process.env.TIMELINE_VISUAL_PROFILE !== "0"
const fixture = await setupTimelineBenchmark(page, {
historyTurns,
eventBatch,
})
fixture.transport.enqueue(buildInitialStreamEvent(deltaCount))
const contentStart = performance.now()
await expect(fixture.text).toBeVisible()
await expect(fixture.text).toContainText("Implementation plan")
const initialContentObservedMs = performance.now() - contentStart
await fixture.scrollToBottom()
await fixture.waitForStableGeometry()
const profile = await startTimelineProfile(page, { cpuThrottle, profileCPU })
await installTimelineStreamProbe(page, { textPartID, finalIndex: deltaCount, profileVisual, minimal })
const deltas = buildStreamDeltaEvents(deltaCount)
await startTimelineStreamProbe(page)
fixture.transport.enqueue(deltas)
await page.waitForFunction(
(finalIndex) =>
(
window as Window & {
__timelineStreamBenchmark?: { applied: { index: number }[] }
}
).__timelineStreamBenchmark?.applied.some((value) => value.index === finalIndex),
deltaCount,
{ timeout: 420_000 },
)
await expect(fixture.text).toContainText("benchmark-complete")
await expect(fixture.text).toContainText("Streaming")
await fixture.waitForStableGeometry()
const metrics = await collectTimelineStreamMetrics(page, {
textPartID,
finalIndex: deltaCount,
navigations: benchmarkDiagnostics(page).navigations,
})
const delivered = deltas.length - fixture.transport.pendingCount()
await profile.stop()
report(
{
endToEndInitialContentObservedMs: initialContentObservedMs,
...metrics,
deliveredDeltas: delivered,
pendingDeltas: fixture.transport.pendingCount(),
},
{
cpuThrottle,
profileCPU,
profileVisual,
minimal,
queuedDeltas: deltas.length,
historyTurns,
eventBatch,
},
)
await profile.reset()
})
})
@@ -1,40 +0,0 @@
import type { CDPSession, Page } from "@playwright/test"
export async function startTimelineProfile(page: Page, options: { cpuThrottle: number; profileCPU: boolean }) {
const cdp = await page.context().newCDPSession(page)
if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: options.cpuThrottle })
if (options.profileCPU) {
await cdp.send("Profiler.enable")
await cdp.send("Profiler.setSamplingInterval", { interval: 100 })
await cdp.send("Profiler.start")
}
return {
async stop() {
if (!options.profileCPU) return
const result = await cdp.send("Profiler.stop")
const self = new Map<number, number>()
result.profile.samples?.forEach((id, index) => {
const duration = (result.profile.timeDeltas?.[index] ?? 0) / 1_000
self.set(id, (self.get(id) ?? 0) + duration)
})
console.log(
"timeline cpu profile",
JSON.stringify(
result.profile.nodes
.map((node) => ({
function: node.callFrame.functionName || "(anonymous)",
url: node.callFrame.url,
line: node.callFrame.lineNumber + 1,
selfMs: self.get(node.id) ?? 0,
}))
.filter((node) => node.selfMs > 1)
.sort((a, b) => b.selfMs - a.selfMs)
.slice(0, 40),
),
)
},
async reset() {
if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 })
},
}
}
@@ -1,547 +0,0 @@
import type { Page } from "@playwright/test"
const STREAM_MARKER_PATTERN = "stream-(\\d+)"
const STREAM_FRAGMENT_COUNT = 18
type TimelineProbeState = {
started: number
ended: number
profileVisual: boolean
minimal: boolean
frames: number[]
frameAt: number[]
applied: { at: number; index: number }[]
geometry: {
scrollTop: number
scrollHeight: number
clientHeight: number
distance: number
virtualHeight: number
headerHeight: number
}[]
blanks: number
longTasks: number[]
layoutShifts: number[]
visibleMounts: number
visibleUnmounts: number
visibleRows: Set<Element>
visibleSubtreeMounts: string[]
visibleSubtreeUnmounts: string[]
visibleSubtreeReplacements: number
visibleSubtreeDropouts: string[]
visibleSubtrees: Map<string, Element>
subtreeKeys: WeakMap<Element, string>
maxOverlap: number
maxGap: number
maxPartTopMovement: number
previousPartTop: number
slowFrames: {
duration: number
index: number
phase: "stream" | "boundary" | "complete" | "unknown"
tokenSpans: number
blocks: number
codeBlocks: number
height: number
distance: number
}[]
scroll: {
calls: number
callNoops: number
sameFrameCalls: number
assignments: number
assignmentNoops: number
lastCallFrame: number
frame: number
}
row: HTMLElement
markdown: HTMLElement
running: boolean
previous: number
cleanup: () => void
start: () => void
}
export async function installTimelineStreamProbe(
page: Page,
options: { textPartID: string; finalIndex: number; profileVisual: boolean; minimal: boolean },
) {
await page.evaluate(
({ textPartID, finalIndex, profileVisual, minimal, markerPattern, fragmentCount }) => {
const part = document.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
const row = part?.closest<HTMLElement>("[data-timeline-row]")
const markdown = part?.querySelector<HTMLElement>('[data-component="markdown"]')
const root = part?.closest<HTMLElement>(".scroll-view__viewport")
if (!part || !row || !markdown || !root) throw new Error("missing streaming benchmark nodes")
const viewport = root.getBoundingClientRect()
const state: TimelineProbeState = {
started: 0,
ended: Infinity,
profileVisual,
minimal,
frames: [],
frameAt: [],
applied: [],
geometry: [],
blanks: 0,
longTasks: [],
layoutShifts: [],
visibleMounts: 0,
visibleUnmounts: 0,
visibleRows: new Set(
[...root.querySelectorAll("[data-timeline-key]")].filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > viewport.top && rect.top < viewport.bottom
}),
),
visibleSubtreeMounts: [],
visibleSubtreeUnmounts: [],
visibleSubtreeReplacements: 0,
visibleSubtreeDropouts: [],
visibleSubtrees: new Map<string, Element>(),
subtreeKeys: new WeakMap<Element, string>(),
maxOverlap: 0,
maxGap: 0,
maxPartTopMovement: 0,
previousPartTop: part.getBoundingClientRect().top,
slowFrames: [],
scroll: {
calls: 0,
callNoops: 0,
sameFrameCalls: 0,
assignments: 0,
assignmentNoops: 0,
lastCallFrame: -1,
frame: 0,
},
row,
markdown,
running: false,
previous: 0,
cleanup: () => {},
start: () => {},
}
;(window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark = state
const scrollTo = Element.prototype.scrollTo
const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
if (profileVisual) {
Element.prototype.scrollTo = function (...args) {
state.scroll.calls += 1
const top = typeof args[0] === "object" ? args[0]?.top : args[1]
if (typeof top === "number") {
const target = Math.min(top, this.scrollHeight - this.clientHeight)
if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
}
if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
state.scroll.lastCallFrame = state.scroll.frame
return scrollTo.apply(this, args)
}
Object.defineProperty(Element.prototype, "scrollTop", {
configurable: true,
get: scrollTop.get,
set(value) {
state.scroll.assignments += 1
if (Math.abs(this.scrollTop - value) < 1) state.scroll.assignmentNoops += 1
scrollTop.set!.call(this, value)
},
})
}
const recordLongTasks = (entries: PerformanceEntry[]) => {
if (!state.running) return
state.longTasks.push(
...entries
.filter((entry) => entry.startTime >= state.started && entry.startTime <= state.ended)
.map((entry) => entry.duration),
)
}
const longTaskObserver = new PerformanceObserver((list) => recordLongTasks(list.getEntries()))
longTaskObserver.observe({ type: "longtask" })
const recordLayoutShifts = (entries: PerformanceEntry[]) => {
if (!state.running) return
state.layoutShifts.push(
...entries
.map((entry) => {
const shift = entry as LayoutShiftEntry
if (shift.startTime < state.started || shift.hadRecentInput) return
return shift.value
})
.filter((value): value is number => value !== undefined),
)
}
const layoutShiftObserver = profileVisual
? new PerformanceObserver((list) => recordLayoutShifts(list.getEntries()))
: undefined
layoutShiftObserver?.observe({ type: "layout-shift", buffered: true })
const visible = (element: Element) => {
const rect = element.getBoundingClientRect()
const viewport = root.getBoundingClientRect()
const style = getComputedStyle(element)
return (
element.isConnected &&
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > viewport.top &&
rect.top < viewport.bottom &&
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) > 0
)
}
const critical = [
"[data-timeline-part-id]",
'[data-component="edit-content"]',
'[data-component="apply-patch-file-diff"]',
'[data-component="file"]',
'[data-component="markdown-code"]',
"[data-markdown-block]",
].join(",")
const describe = (element: Element) => {
const cached = state.subtreeKeys.get(element)
if (!element.isConnected && cached) return cached
const part = element.closest<HTMLElement>("[data-timeline-part-id]")?.dataset.timelinePartId ?? "unknown"
const block = element
.closest<HTMLElement>("[data-markdown-key]")
?.dataset.markdownKey?.replace(/:(?:code|full|live)$/, "")
const component =
element.getAttribute("data-component") ?? element.getAttribute("data-markdown-block") ?? element.tagName
const key = `${part}:${block ?? "root"}:${component}`
state.subtreeKeys.set(element, key)
return key
}
const recordMutations = (records: MutationRecord[]) => {
if (!state.running) return
records.forEach((record) => {
record.addedNodes.forEach((node) => {
if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && visible(node)) {
state.visibleMounts += 1
state.visibleRows.add(node)
}
if (!(node instanceof Element)) return
const added = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
added.forEach((element) => {
if (visible(element)) state.visibleSubtreeMounts.push(describe(element))
})
})
record.removedNodes.forEach((node) => {
if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && state.visibleRows.delete(node))
state.visibleUnmounts += 1
if (!(node instanceof Element)) return
const removed = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
removed.forEach((element) => {
const key = describe(element)
if (state.visibleSubtrees.get(key) === element) state.visibleSubtreeUnmounts.push(key)
})
})
})
}
const mutationObserver = profileVisual ? new MutationObserver(recordMutations) : undefined
mutationObserver?.observe(root, { childList: true, subtree: true })
const currentPart = () => root.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
const observeProgress = (at: number) => {
if (!state.running) return
const content = currentPart()?.textContent ?? ""
const index = content.includes("benchmark-complete")
? finalIndex
: Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
if (index >= 0 && index !== state.applied.at(-1)?.index) state.applied.push({ at, index })
}
const progressObserver = new MutationObserver(() => observeProgress(performance.now()))
progressObserver.observe(root, { characterData: true, childList: true, subtree: true })
state.cleanup = () => {
recordLongTasks(longTaskObserver.takeRecords())
recordLayoutShifts(layoutShiftObserver?.takeRecords() ?? [])
recordMutations(mutationObserver?.takeRecords() ?? [])
if (progressObserver.takeRecords().length) observeProgress(performance.now())
longTaskObserver.disconnect()
layoutShiftObserver?.disconnect()
mutationObserver?.disconnect()
progressObserver.disconnect()
if (!profileVisual) return
Element.prototype.scrollTo = scrollTo
Object.defineProperty(Element.prototype, "scrollTop", scrollTop)
}
const sample = (now: number) => {
if (!state.running) return
state.frameAt.push(now)
observeProgress(now)
if (minimal) {
state.frames.push(now - state.previous)
state.previous = now
requestAnimationFrame(sample)
return
}
setTimeout(() => {
if (!state.running) return
state.scroll.frame += 1
const duration = now - state.previous
state.frames.push(duration)
state.previous = now
const virtualRoot = root.querySelector<HTMLElement>("[data-timeline-virtual-content]")
const header = root.querySelector<HTMLElement>("[data-session-title]")
state.geometry.push({
scrollTop: root.scrollTop,
scrollHeight: root.scrollHeight,
clientHeight: root.clientHeight,
distance: root.scrollHeight - root.clientHeight - root.scrollTop,
virtualHeight: virtualRoot?.getBoundingClientRect().height ?? 0,
headerHeight: header?.getBoundingClientRect().height ?? 0,
})
const viewport = root.getBoundingClientRect()
if (profileVisual) {
const visibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
.filter((item) => item.rect.bottom > viewport.top && item.rect.top < viewport.bottom)
.sort((a, b) => a.rect.top - b.rect.top)
state.visibleRows = new Set(visibleRows.map((item) => item.element))
const rows = visibleRows.map((item) => item.rect)
rows.slice(1).forEach((rect, index) => {
const previous = rows[index]!
state.maxOverlap = Math.max(state.maxOverlap, previous.bottom - rect.top)
state.maxGap = Math.max(state.maxGap, rect.top - previous.bottom)
})
const partTop = part.getBoundingClientRect().top
state.maxPartTopMovement = Math.max(state.maxPartTopMovement, Math.abs(partTop - state.previousPartTop))
state.previousPartTop = partTop
}
const visibleRow = [...root.querySelectorAll<HTMLElement>("[data-timeline-row]")].some((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > viewport.top && rect.top < viewport.bottom
})
if (!visibleRow) state.blanks += 1
if (profileVisual) {
const subtrees = new Map<string, { element: Element; rendered: boolean }>()
const visibleSubtrees = new Map<string, Element>()
root.querySelectorAll(critical).forEach((element) => {
const key = describe(element)
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
const rendered =
element.isConnected &&
rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) > 0
subtrees.set(key, { element, rendered })
if (rendered && rect.bottom > viewport.top && rect.top < viewport.bottom) {
const previous = state.visibleSubtrees.get(key)
if (previous && previous !== element && key.startsWith(`${textPartID}:`))
state.visibleSubtreeReplacements += 1
visibleSubtrees.set(key, element)
}
})
state.visibleSubtrees.forEach((element, key) => {
const current = subtrees.get(key)
if (key.startsWith(`${textPartID}:`) && !current?.rendered) {
const markdown = part.querySelector<HTMLElement>('[data-component="markdown"]')
state.visibleSubtreeDropouts.push(
`${key}:projection=${markdown?.dataset.markdownProjectionLength}/${markdown?.dataset.markdownProjectionBlocks}:result=${markdown?.dataset.markdownResultLength}/${markdown?.dataset.markdownResultBlocks}:applied=${markdown?.dataset.markdownAppliedBlocks}:dom=${markdown?.children.length}`,
)
}
if (element.matches('[data-component="file"]')) {
const hadLines = element.hasAttribute("data-profiler-had-lines")
const hasLines = element.shadowRoot?.querySelector("[data-line]") != null
if (hasLines) element.setAttribute("data-profiler-had-lines", "")
if (hadLines && !hasLines) state.visibleSubtreeDropouts.push(`${key}:shadow-lines`)
}
})
state.visibleSubtrees = visibleSubtrees
}
if (profileVisual && duration > 33.34) {
const livePart = currentPart()
const content = livePart?.textContent ?? ""
const complete = content.includes("benchmark-complete")
const index = complete
? finalIndex
: Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
state.slowFrames.push({
duration,
index,
phase: complete
? "complete"
: index >= 0 && index % fragmentCount === 0
? "boundary"
: index >= 0
? "stream"
: "unknown",
tokenSpans: livePart?.querySelectorAll(".shiki span").length ?? 0,
blocks: livePart?.querySelectorAll("[data-markdown-block]").length ?? 0,
codeBlocks: livePart?.querySelectorAll('[data-component="markdown-code"]').length ?? 0,
height: livePart?.getBoundingClientRect().height ?? 0,
distance: root.scrollHeight - root.clientHeight - root.scrollTop,
})
}
requestAnimationFrame(sample)
}, 0)
}
state.start = () => {
state.started = performance.now()
state.previous = state.started
state.running = true
requestAnimationFrame(sample)
}
},
{ ...options, markerPattern: STREAM_MARKER_PATTERN, fragmentCount: STREAM_FRAGMENT_COUNT },
)
}
export function startTimelineStreamProbe(page: Page) {
return page.evaluate(() => {
const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
if (!state) throw new Error("missing streaming benchmark state")
state.start()
})
}
type LayoutShiftEntry = PerformanceEntry & { value: number; hadRecentInput?: boolean }
export function layoutShiftValue(
entry: Pick<LayoutShiftEntry, "startTime" | "value" | "hadRecentInput">,
start: number,
) {
if (entry.startTime < start || entry.hadRecentInput) return
return entry.value
}
export function removeVisibleRow<T>(visible: Set<T>, row: T) {
return visible.delete(row)
}
export function streamProgress(content: string) {
const index = Number(content.match(new RegExp(STREAM_MARKER_PATTERN, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
return {
index,
phase: content.includes("benchmark-complete")
? ("complete" as const)
: index >= 0 && index % STREAM_FRAGMENT_COUNT === 0
? ("boundary" as const)
: index >= 0
? ("stream" as const)
: ("unknown" as const),
}
}
export async function collectTimelineStreamMetrics(
page: Page,
options: { textPartID: string; finalIndex: number; navigations: string[] },
) {
return page.evaluate(({ textPartID, finalIndex, navigations }) => {
const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
if (!state) throw new Error(`missing streaming benchmark state after navigation: ${JSON.stringify(navigations)}`)
state.ended = performance.now()
state.cleanup()
state.running = false
const part = document.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
const row = part?.closest<HTMLElement>("[data-timeline-row]")
const markdown = part?.querySelector<HTMLElement>('[data-component="markdown"]')
const sorted = state.frames.slice().sort((a, b) => a - b)
const duration = state.frames.reduce((sum, value) => sum + value, 0)
const longestSlowStreak = state.frames.reduce(
(result, value) => {
const current = value > 33.34 ? result.current + 1 : 0
return { current, longest: Math.max(result.longest, current) }
},
{ current: 0, longest: 0 },
).longest
const busyStart = state.applied.at(0)?.at
const completion = state.applied.find((value) => value.index === finalIndex)
const busyEnd = completion?.at
const busyFrames =
busyStart === undefined || busyEnd === undefined
? []
: state.frames.filter((_, index) => state.frameAt[index]! >= busyStart && state.frameAt[index]! <= busyEnd)
const busySorted = busyFrames.slice().sort((a, b) => a - b)
const busyDuration = busyFrames.reduce((sum, value) => sum + value, 0)
const completionObservedMs = (completion?.at ?? NaN) - state.started
const visual = state.profileVisual
? {
layoutShiftValueSum: state.layoutShifts.reduce((sum, value) => sum + value, 0),
maxLayoutShiftValue: Math.max(0, ...state.layoutShifts),
visibleMounts: state.visibleMounts,
visibleUnmounts: state.visibleUnmounts,
visibleSubtreeMounts: state.visibleSubtreeMounts,
visibleSubtreeUnmounts: [...new Set(state.visibleSubtreeUnmounts)],
visibleSubtreeReplacements: state.visibleSubtreeReplacements,
visibleSubtreeDropouts: [...new Set(state.visibleSubtreeDropouts)],
maxOverlapPx: state.maxOverlap,
maxGapPx: state.maxGap,
maxPartTopMovementPx: state.maxPartTopMovement,
slowestRafGaps: state.slowFrames
.sort((a, b) => b.duration - a.duration)
.slice(0, 20)
.map((frame) => ({
durationMs: frame.duration,
index: frame.index,
phase: frame.phase,
tokenSpans: frame.tokenSpans,
blocks: frame.blocks,
codeBlocks: frame.codeBlocks,
heightPx: frame.height,
distancePx: frame.distance,
})),
slowRafGapPhases: Object.fromEntries(
["stream", "boundary", "complete", "unknown"].map((phase) => {
const frames = state.slowFrames.filter((frame) => frame.phase === phase)
return [
phase,
{
count: frames.length,
totalMs: frames.reduce((sum, frame) => sum + frame.duration, 0),
maxMs: Math.max(0, ...frames.map((frame) => frame.duration)),
},
]
}),
),
scroll: state.scroll,
}
: null
const geometry = state.minimal
? null
: {
maxDistancePx: Math.max(0, ...state.geometry.map((sample) => sample.distance)),
finalDistancePx: state.geometry.at(-1)?.distance ?? 0,
final: state.geometry.at(-1),
distanceTransitionsPx: state.geometry
.map((sample) => Math.round(sample.distance))
.filter((value, index, values) => index === 0 || value !== values[index - 1]),
bottomDriftTransitions: state.geometry.slice(1).filter((value, index) => {
const previous = state.geometry[index]?.distance ?? 0
return previous <= 1 && value.distance > 1
}).length,
blankSamples: state.blanks,
}
return {
capabilities: { visual: state.profileVisual, geometry: !state.minimal },
completionObservedMs,
deltasPerSecond: Number.isFinite(completionObservedMs) ? finalIndex / (completionObservedMs / 1_000) : null,
rafGapSamples: state.frames.length,
rafCallbackRate: duration ? (state.frames.length * 1000) / duration : 0,
observedProgressWindowRafCallbackRate: busyDuration ? (busyFrames.length * 1000) / busyDuration : null,
observedProgressWindowRafGapP95Ms: busySorted[Math.floor(busySorted.length * 0.95)] ?? null,
observedProgressWindowRafGaps: busyFrames.length,
maxObservedProgressIndex: Math.max(-1, ...state.applied.map((value) => value.index)),
observedProgressTransitions: state.applied.length,
rafGapP50Ms: sorted[Math.floor(sorted.length * 0.5)] ?? 0,
rafGapP95Ms: sorted[Math.floor(sorted.length * 0.95)] ?? 0,
rafGapP99Ms: sorted[Math.floor(sorted.length * 0.99)] ?? 0,
maxRafGapMs: sorted.at(-1) ?? 0,
rafGapsOver33Ms: state.frames.filter((value) => value > 33.34).length,
rafGapsOver50Ms: state.frames.filter((value) => value > 50).length,
missedFrameBudgetEquivalents: state.frames.reduce(
(sum, value) => sum + Math.max(0, Math.round(value / 16.67) - 1),
0,
),
longestRafGapOver33MsStreak: longestSlowStreak,
longTaskCount: state.longTasks.length,
longTaskTimeMs: state.longTasks.reduce((sum, value) => sum + value, 0),
visual,
geometry,
rowReplaced: row !== state.row,
markdownReplaced: markdown !== state.markdown,
domTextCharacters: part?.textContent?.length ?? 0,
}
}, options)
}
@@ -1,335 +0,0 @@
const words = [
"alpha",
"bravo",
"charlie",
"delta",
"echo",
"foxtrot",
"golf",
"hotel",
"india",
"juliet",
"kilo",
"lima",
"metro",
"nova",
"orbit",
"pixel",
"quartz",
"river",
"signal",
"vector",
]
const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
type Message = { info: MessageInfo; parts: MessagePart[] }
function lorem(seed: number, length: number) {
let out = ""
let i = seed
while (out.length < length) {
const word = words[i % words.length]
out += (out ? " " : "") + word
if (i % 17 === 0) out += ".\n\n"
i += 7
}
return out.slice(0, length)
}
function id(prefix: string, value: number) {
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
}
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
const messageID = id("msg_user", index)
return {
info: {
id: messageID,
sessionID,
role: "user",
time: { created: 1700000000000 + index * 10_000 },
summary: { diffs },
agent: "build",
model,
},
parts: [
{
id: id("prt_user_text", index),
sessionID,
messageID,
type: "text",
text: lorem(index, textLength),
},
],
}
}
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
const messageID = id("msg_assistant", index)
return {
info: {
id: messageID,
sessionID,
role: "assistant",
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
parentID,
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: parts.map((part) => ({
...part,
sessionID,
messageID,
})),
}
}
function textPart(index: number, partIndex: number, length: number): MessagePart {
const prose = lorem(index * 13 + partIndex, length)
const text =
index % 12 === 0
? `${prose}\n\n\`\`\`ts\n${code(index, 80)}\n\`\`\``
: index % 5 === 0
? `${prose}\n\n\`\`\`ts\nexport const value = "${lorem(index, 220)}"\n\`\`\``
: index % 7 === 0
? `${prose}\n\nThe wrapped inline value is \`${lorem(index, 180)}\`.`
: prose
return { id: id(`prt_text_${partIndex}`, index), type: "text", text }
}
function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
return {
id: id(`prt_reasoning_${partIndex}`, index),
type: "reasoning",
text: lorem(index * 19 + partIndex, length),
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
}
}
function toolPart(
index: number,
partIndex: number,
tool: string,
input: Record<string, unknown>,
outputLength = 160,
): MessagePart {
const metadata =
tool === "apply_patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write"
? {
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
diff: patch(index, outputLength),
preview: patch(index + 1, 420),
}
: tool === "question"
? { answers: [["Proceed"], ["Keep sample output"]] }
: {}
return {
id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool",
callID: id("call", index * 10 + partIndex),
tool,
state: {
status: "completed",
input,
output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
metadata,
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
},
}
}
function patchFile(seed: number, type: "add" | "update" | "delete") {
return {
filePath: `src/generated/patch-${seed}.ts`,
relativePath: `src/generated/patch-${seed}.ts`,
type,
additions: (seed % 7) + 1,
deletions: type === "add" ? 0 : seed % 4,
patch: patch(seed, 520),
before: type === "add" ? undefined : code(seed, 18),
after: type === "delete" ? undefined : code(seed + 1, 24),
}
}
function fileDiff(file: string, seed: number) {
const lines = seed % 12 === 0 ? 300 : seed % 8 === 0 ? 2 : 38
const before = code(seed, lines, seed % 10 === 0 ? 280 : 32)
const after =
lines === 2
? before.replace("value1", "updatedValue1")
: lines === 300
? code(seed + 1, lines, seed % 10 === 0 ? 280 : 32)
: before.replace("value4", "updatedValue4").replace("value20", "updatedValue20")
return {
file,
additions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
deletions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
before,
after,
}
}
function patch(seed: number, length: number) {
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
}
function code(seed: number, lines: number, width = 32) {
return Array.from(
{ length: lines },
(_, index) => `export const value${index} = "${lorem(seed + index, width)}"`,
).join("\n")
}
function turn(index: number): Message[] {
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
const parts = [
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
...(index % 3 === 0
? [
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
]
: []),
textPart(index, 2, 160 + (index % 6) * 90),
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
...(index % 6 === 0
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []),
...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []),
...(index % 7 === 0
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
: []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
...(index % 13 === 0
? [
toolPart(
index,
11,
"question",
{ questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
120,
),
]
: []),
...(index % 17 === 0
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
: []),
]
return [user, assistantMessage(targetID, index, user.info.id, parts)]
}
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
userMessage(sourceID, index + 1000, 120),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
]).flat()
function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim()
if (part.type === "reasoning") return !!part.text.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
}
function orderedParts(message: Message) {
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}
export const fixture = {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "smoke-project",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [
{
id: sourceID,
slug: "source",
projectID,
directory,
title: "Uncommitted changes inquiry",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
{
id: targetID,
slug: "target",
projectID,
directory,
title: "Example Game: sample jump movement & sample physics analysis",
version: "dev",
time: { created: 1700000001000, updated: 1700000001000 },
},
],
sourceID,
targetID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
expected: {
sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis",
sourceMessageIDs: sourceMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetMessageIDs: targetMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap((message) =>
orderedParts(message)
.filter(renderable)
.map((part) => part.id),
),
},
}
export function pageMessages(sessionID: string, limit: number, before?: string) {
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
const end = before
? Math.max(
0,
messages.findIndex((message) => message.info.id === before),
)
: messages.length
const start = Math.max(0, end - limit)
return {
items: messages.slice(start, end),
cursor: start > 0 ? messages[start]!.info.id : undefined,
}
}
@@ -1,67 +0,0 @@
import type { Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { fixture } from "./session-timeline-stress.fixture"
export async function installTimelineSettings(page: Page) {
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
})
}
export function mockStressTimeline(page: Page) {
return mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
})
}
export async function installStressSessionTabs(page: Page) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ directory, sourceID, targetID, dirBase64, server }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
server,
dirBase64,
sessionId,
})),
),
)
},
{
directory: fixture.directory,
sourceID: fixture.sourceID,
targetID: fixture.targetID,
dirBase64: base64Encode(fixture.directory),
server,
},
)
}
export function stressSessionHref(sessionID: string) {
return `/${base64Encode(fixture.directory)}/session/${sessionID}`
}
@@ -1,15 +0,0 @@
import { expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import path from "node:path"
import os from "node:os"
import { prepareChromeTrace } from "../chrome-trace"
test("creates the configured trace directory", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-trace-"))
try {
const file = await prepareChromeTrace(path.join(root, "nested", "traces"), "session/tab", false, "test")
expect(file).toEndWith("-session-tab-458ed9e3-test.json")
} finally {
await rm(root, { recursive: true, force: true })
}
})
@@ -1,42 +0,0 @@
import { expect, test } from "bun:test"
import { compressCachedRepaintTrace, layoutShiftSample } from "../timeline/session-tab-repaint-probe"
test("compresses repeated repaint states without losing frame samples", () => {
const state = {
root: 1,
scrollTop: 10,
scrollHeight: 20,
bottomErrorPx: 0,
last: true,
rows: [{ key: "row", node: 2, top: 0, bottom: 10 }],
mounted: 1,
center: "content",
}
const trace = {
timeOriginEpochMs: 1_000,
startedAtPerformanceMs: 100,
samples: [
{ observedAtMs: 16, ...state, destination: ["target"], source: [] },
{ observedAtMs: 32, ...state, destination: ["target"], source: [] },
{ observedAtMs: 48, ...state, scrollTop: 11, destination: ["target"], source: [] },
],
mutations: [{ observedAtMs: 20, changed: [{ type: "add", node: 2 }] }],
shifts: [{ occurredAtMs: 24, value: 0.1 }],
windowMs: 1_000,
running: false,
stop() {},
}
const compressed = compressCachedRepaintTrace(trace)
const samples = compressed.samples.flatMap((group) =>
group.observedAtMs.map((observedAtMs) => ({ observedAtMs, ...group.state })),
)
expect(samples).toEqual(trace.samples)
expect(compressed.mutations).toEqual(trace.mutations)
expect(compressed.shifts).toEqual(trace.shifts)
})
test("records layout shifts at occurrence time within the probe window", () => {
expect(layoutShiftSample({ startTime: 99, value: 0.1 }, 100)).toBeUndefined()
expect(layoutShiftSample({ startTime: 124, value: 0.2 }, 100)).toEqual({ occurredAtMs: 24, value: 0.2 })
})
@@ -1,54 +0,0 @@
import { expect, test } from "bun:test"
import { classifySessionSwitch } from "../timeline/session-tab-switch-metrics"
test("counts source and blank samples before the destination is observed", () => {
const result = classifySessionSwitch([
{ observedAtMs: 16, destination: [], source: ["source"], hasVisibleRows: true, last: false },
{ observedAtMs: 32, destination: [], source: [], hasVisibleRows: false, last: false },
{ observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
{ observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
{ observedAtMs: 80, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
])
expect(result.blankSamples).toBe(1)
expect(result.sourceSamples).toBe(1)
expect(result.unknownSamples).toBe(0)
expect(result.firstDestinationObservedMs).toBe(48)
expect(result.stableObservedMs).toBe(80)
})
test("does not classify mixed source and destination content as correct", () => {
const result = classifySessionSwitch([
{
observedAtMs: 16,
destination: ["destination"],
source: ["source"],
hasVisibleRows: true,
last: true,
bottomErrorPx: 0,
},
{ observedAtMs: 32, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
{ observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
{ observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
])
expect(result.firstCorrectObservedMs).toBe(32)
expect(result.stableObservedMs).toBe(64)
})
test("reports missing correctness without throwing", () => {
const result = classifySessionSwitch([
{
observedAtMs: 16,
destination: ["destination"],
source: ["source"],
hasVisibleRows: true,
last: true,
bottomErrorPx: 0,
},
])
expect(result.firstDestinationObservedMs).toBe(16)
expect(result.firstCorrectObservedMs).toBeNull()
expect(result.stableObservedMs).toBeNull()
})
@@ -1,14 +0,0 @@
import { expect, test } from "bun:test"
import { streamChunk } from "../timeline/session-timeline-benchmark.fixture"
import { streamProgress } from "../timeline/session-timeline-stream-probe"
test("classifies emitted stream markers using the fixture cycle", () => {
expect(streamProgress("before stream-17 after stream-18")).toEqual({ index: 18, phase: "boundary" })
expect(streamProgress("before stream-18 after stream-19")).toEqual({ index: 19, phase: "stream" })
expect(streamProgress("benchmark-complete stream-36")).toEqual({ index: 36, phase: "complete" })
expect(streamProgress("no marker")).toEqual({ index: -1, phase: "unknown" })
})
test("emits progress markers at fixture boundaries", () => {
expect(streamProgress(streamChunk(18, 160))).toEqual({ index: 18, phase: "boundary" })
})
@@ -1,16 +0,0 @@
import { expect, test } from "bun:test"
import { layoutShiftValue, removeVisibleRow } from "../timeline/session-timeline-stream-probe"
test("excludes layout shifts before the probe window and recent input", () => {
expect(layoutShiftValue({ startTime: 9, value: 0.1 }, 10)).toBeUndefined()
expect(layoutShiftValue({ startTime: 10, value: 0.2, hadRecentInput: true }, 10)).toBeUndefined()
expect(layoutShiftValue({ startTime: 11, value: 0.3 }, 10)).toBe(0.3)
})
test("classifies removed rows from their last painted visibility", () => {
const row = {}
const visible = new Set([row])
expect(removeVisibleRow(visible, row)).toBe(true)
expect(removeVisibleRow(visible, row)).toBe(false)
})
@@ -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,6 +1,5 @@
import { expect, test, type Locator, type Page } from "@playwright/test" import { expect, test, type Locator, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/TimelineStateRegression" const directory = "C:/OpenCode/TimelineStateRegression"
const projectID = "proj_timeline_state_regression" const projectID = "proj_timeline_state_regression"
@@ -107,10 +106,10 @@ test.describe("regression: session timeline local row state", () => {
await configurePage(page) await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title) await expect(page.getByRole("heading", { name: title })).toBeVisible()
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
await expectAppVisible(wrapper) await expect(wrapper).toBeVisible()
await expectExpanded(wrapper, true) await expectExpanded(wrapper, true)
await wrapper.evaluate((element) => { await wrapper.evaluate((element) => {
@@ -143,12 +142,11 @@ test.describe("regression: session timeline local row state", () => {
await configurePage(page) await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title) await expect(page.getByRole("heading", { name: title })).toBeVisible()
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
await expectAppVisible(wrapper) await expect(wrapper).toBeVisible()
const file = wrapper.locator('[data-component="file"][data-mode="diff"]').first() await expect(wrapper.locator('[data-component="file"][data-mode="diff"]').first()).toBeVisible()
await expectAppVisible(file)
await markDiffProbe(page) await markDiffProbe(page)
events.push({ events.push({
@@ -160,15 +158,7 @@ test.describe("regression: session timeline local row state", () => {
}) })
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 }) await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
const siblingProbe = await readDiffProbe(page) expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
expect(siblingProbe).toEqual({
fileMarker: "before",
frameMarker: "before",
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
rowMarker: "before",
shadowRoots: 0,
toolMarker: "before",
})
await markDiffProbe(page) await markDiffProbe(page)
events.push({ events.push({
@@ -182,73 +172,7 @@ test.describe("regression: session timeline local row state", () => {
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible( await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
{ timeout: 10_000 }, { timeout: 10_000 },
) )
expect(await readDiffProbe(page)).toEqual({ expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
fileMarker: "before",
frameMarker: "before",
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
rowMarker: "before",
shadowRoots: 0,
toolMarker: "before",
})
})
test("keeps a sticky edit header aligned with a multi-hunk diff", async ({ page }) => {
const events: EventPayload[] = []
const lines = Array.from({ length: 1_000 }, (_, index) => `export const value${index} = ${index}\n`).join("")
const after = [100, 300, 500, 700, 900].reduce(
(result, index) =>
result.replace(`export const value${index} = ${index}`, `export const value${index} = compute(${index})`),
lines,
)
const part = {
...editPart,
state: {
...editPart.state,
metadata: {
...editPart.state.metadata,
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: lines,
after,
},
},
},
}
await mockServer(page, events, [userMessage, { ...assistantMessage, parts: [part] }])
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]').first()
const diff = wrapper.locator('[data-component="edit-content"]').first()
await expectAppVisible(diff)
await expect.poll(() => wrapper.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(500)
const samples = await wrapper.evaluate(async (element) => {
const root = element.closest<HTMLElement>(".scroll-view__viewport")!
element.scrollIntoView({ block: "start" })
const result = []
for (const offset of [0, 120, 240, 360, 480]) {
root.scrollBy(0, offset - (result.at(-1)?.offset ?? 0))
await new Promise(requestAnimationFrame)
const trigger = element.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')!
const diff = element.querySelector<HTMLElement>('[data-component="edit-content"]')!
result.push({
offset,
trigger: trigger.getBoundingClientRect().y,
diff: diff.getBoundingClientRect().y,
bottom: element.getBoundingClientRect().bottom,
})
}
return result
})
expect(samples[0]!.trigger).toBeLessThan(samples[0]!.diff)
expect(samples.every((sample) => Math.abs(sample.trigger - samples[0]!.trigger) <= 1)).toBe(true)
expect(samples.every((sample) => sample.trigger < sample.bottom)).toBe(true)
}) })
}) })
@@ -322,16 +246,10 @@ async function markDiffProbe(page: Page) {
.evaluate((element) => { .evaluate((element) => {
const tool = element as HTMLElement const tool = element as HTMLElement
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]') const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
const row = tool.closest<HTMLElement>("[data-timeline-key]")
const frame = tool.closest<HTMLElement>("[data-timeline-row]")
if (!file) throw new Error("missing edit diff file") if (!file) throw new Error("missing edit diff file")
if (!row) throw new Error("missing virtual timeline row")
if (!frame) throw new Error("missing timeline row frame")
tool.dataset.timelineProbe = "before" tool.dataset.timelineProbe = "before"
file.dataset.timelineProbe = "before" file.dataset.timelineProbe = "before"
row.dataset.timelineProbe = "before"
frame.dataset.timelineProbe = "before"
window.__timelineDiffProbe.reset() window.__timelineDiffProbe.reset()
}) })
} }
@@ -343,15 +261,10 @@ async function readDiffProbe(page: Page) {
.evaluate((element) => { .evaluate((element) => {
const tool = element as HTMLElement const tool = element as HTMLElement
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]') const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
const row = tool.closest<HTMLElement>("[data-timeline-key]")
const frame = tool.closest<HTMLElement>("[data-timeline-row]")
return { return {
fileMarker: file?.dataset.timelineProbe, fileMarker: file?.dataset.timelineProbe,
shadowRoots: window.__timelineDiffProbe.shadowRoots(), shadowRoots: window.__timelineDiffProbe.shadowRoots(),
toolMarker: tool.dataset.timelineProbe, toolMarker: tool.dataset.timelineProbe,
rowMarker: row?.dataset.timelineProbe,
rowKey: row?.dataset.timelineKey,
frameMarker: frame?.dataset.timelineProbe,
} }
}) })
} }
@@ -386,15 +299,14 @@ function readExpanded(element: Element) {
return !!content && content.getBoundingClientRect().height > 0 return !!content && content.getBoundingClientRect().height > 0
} }
async function mockServer(page: Page, events: EventPayload[], messages = [userMessage, assistantMessage]) { async function mockServer(page: Page, events: EventPayload[]) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
directory, directory,
project: project(), project: project(),
provider: provider(), provider: provider(),
sessions: [session()], sessions: [session()],
pageMessages: () => ({ items: messages }), pageMessages: () => ({ items: [userMessage, assistantMessage] }),
events: () => events.splice(0, 1), events: () => events.splice(0),
eventRetry: 16,
}) })
} }
@@ -1,6 +1,5 @@
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ContextResizeRegression" const directory = "C:/OpenCode/ContextResizeRegression"
const projectID = "proj_context_resize_regression" const projectID = "proj_context_resize_regression"
@@ -24,14 +23,16 @@ test.describe("regression: session timeline context group resize", () => {
await configurePage(page) await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title) await expect(page.getByRole("heading", { name: title })).toBeVisible()
await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()) await expect(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()).toBeVisible()
await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()) await expect(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()).toBeVisible()
await settle(page) await settle(page)
const samples = await sampleExpansion(page) const samples = await sampleExpansion(page)
const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5) 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(samples[0]?.overlap).toBe(0)
expect(visibleOverlap).toEqual([]) expect(visibleOverlap).toEqual([])
expect(samples.at(-1)?.expanded).toBe("true") expect(samples.at(-1)?.expanded).toBe("true")
@@ -113,15 +114,13 @@ async function sampleExpansion(page: Page) {
let frame = 1 let frame = 1
const tick = () => { const tick = () => {
setTimeout(() => { capture(frame, "raf")
capture(frame, "painted") frame += 1
frame += 1 if (frame > 8) {
if (frame > 8) { resolve(samples)
resolve(samples) return
return }
} requestAnimationFrame(tick)
requestAnimationFrame(tick)
}, 0)
} }
requestAnimationFrame(tick) requestAnimationFrame(tick)
}), }),
+4 -294
View File
@@ -3,7 +3,6 @@ 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"]
@@ -30,284 +29,6 @@ type SmokeWindow = Window & {
test.describe("smoke: session timeline", () => { test.describe("smoke: session timeline", () => {
test.setTimeout(240_000) test.setTimeout(240_000)
test("keeps the visible message fixed while prepending history", async ({ page }) => {
const requests: { before?: string; phase: "start" | "end"; at: number }[] = []
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
messageDelay: 3_000,
onMessages: (input) => requests.push({ before: input.before, phase: input.phase, at: performance.now() }),
})
await configureSmokePage(page, fixture.directory)
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
await waitForTimelineStable(page)
const scroller = timelineScroller(page)
await pointAtTimeline(page)
const deadline = Date.now() + 120_000
while (!requests.some((request) => request.before && request.phase === "start")) {
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
await page.mouse.wheel(0, -240)
await page.waitForTimeout(20)
}
expect(requests.some((request) => request.before && request.phase === "end")).toBe(false)
for (let index = 0; index < 12; index++) {
await page.mouse.wheel(0, -120)
await page.waitForTimeout(20)
}
const keys = ["prt_user_text_smoke_0032", "prt_text_2_smoke_0032", "prt_tool_apply_patch_8_smoke_0032"]
const positions = () =>
scroller.evaluate((element, keys) => {
const top = element.getBoundingClientRect().top
return Object.fromEntries(
keys.map((key) => {
const row = element.querySelector<HTMLElement>(`[data-timeline-part-id="${key}"]`)
if (!row) throw new Error(`Missing stable timeline key: ${key}`)
return [key, Math.round((row.getBoundingClientRect().top - top) * devicePixelRatio) / devicePixelRatio]
}),
)
}, keys)
const before = await positions()
expect(requests.some((request) => request.before && request.phase === "end")).toBe(false)
await expect.poll(() => requests.some((request) => request.before && request.phase === "end")).toBe(true)
await waitForTimelineStable(page)
await expect.poll(positions).toEqual(before)
})
test("preserves the timeline gap above the composer", async ({ page }) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await configureSmokePage(page, fixture.directory)
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
await waitForTimelineStable(page)
const scroller = timelineScroller(page)
await scroller.evaluate((element) => {
element.scrollTop = element.scrollHeight
})
await waitForTimelineStable(page)
const spacer = scroller.locator('[data-timeline-row="bottom-spacer"]')
await expect(spacer).toBeVisible()
expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(64)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
})
test("paints cached session tabs at the latest message", async ({ page }) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
})
await configureSmokePage(page, fixture.directory)
await page.addInitScript(
({ dirBase64, sourceID, targetID }) => {
localStorage.setItem(
"opencode.global.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
server: "http://127.0.0.1:4096",
dirBase64,
sessionId,
})),
),
)
},
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
)
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
await expectSessionTitle(page, fixture.expected.targetTitle)
await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
const last = fixture.expected.targetMessageIDs.at(-1)!
await page.evaluate(
({ destination, last }) => {
const ids = new Set(destination)
const samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> = []
const firstPaintNodes = new WeakSet<Node>()
let firstPaint = false
let removedFirstPaintNodes = 0
let running = true
new MutationObserver((records) => {
if (!firstPaint || !running) return
records.forEach((record) =>
record.removedNodes.forEach((node) => {
if (firstPaintNodes.has(node)) removedFirstPaintNodes += 1
if (!(node instanceof Element)) return
node.querySelectorAll("*").forEach((element) => {
if (firstPaintNodes.has(element)) removedFirstPaintNodes += 1
})
}),
)
}).observe(document.documentElement, { childList: true, subtree: true })
const sample = () => {
if (!running) return
setTimeout(() => {
if (!running) return
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (root) {
const view = root.getBoundingClientRect()
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((element) => element.dataset.messageId!)
.filter((id) => ids.has(id))
const bottom = root
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect()
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
firstPaint = true
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
const rect = row.getBoundingClientRect()
if (rect.bottom <= view.top || rect.top >= view.bottom) return
firstPaintNodes.add(row)
row.querySelectorAll("*").forEach((element) => firstPaintNodes.add(element))
})
}
}
requestAnimationFrame(sample)
}, 0)
}
;(
window as Window & {
__sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
}
).__sessionTabPaint = {
samples,
removed: () => removedFirstPaintNodes,
stop: () => {
running = false
},
}
requestAnimationFrame(sample)
},
{ destination, last },
)
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
await page.waitForFunction(() =>
(
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }
).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0),
)
await page.waitForTimeout(200)
const first = await page.evaluate(() => {
const probe = (
window as Window & {
__sessionTabPaint?: {
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
removed: () => number
stop: () => void
}
}
).__sessionTabPaint!
probe.stop()
return { first: probe.samples.find((sample) => sample.ids.length > 0), removed: probe.removed() }
})
expect(first.first?.last).toBe(true)
expect(Math.abs(first.first?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
expect(first.removed).toBe(0)
})
test("paints a cold session tab at the latest message", async ({ page }) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
})
await configureSmokePage(page, fixture.directory)
await page.addInitScript(
({ dirBase64, sourceID, targetID }) => {
localStorage.setItem(
"opencode.global.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
server: "http://127.0.0.1:4096",
dirBase64,
sessionId,
})),
),
)
},
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
)
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
await expectSessionTitle(page, fixture.expected.sourceTitle)
const last = fixture.expected.targetMessageIDs.at(-1)!
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
await page.evaluate(
({ destination, last }) => {
const ids = new Set(destination)
const samples: Array<{ destination: boolean; last: boolean; bottomError?: number }> = []
const sample = () => {
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (root) {
const view = root.getBoundingClientRect()
const spacer = root
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect()
const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
samples.push({
destination: messages.some((element) => ids.has(element.dataset.messageId!)),
last: messages.some((element) => element.dataset.messageId === last),
bottomError: spacer ? spacer.bottom - view.bottom : undefined,
})
}
requestAnimationFrame(() => setTimeout(sample, 0))
}
;(window as Window & { __coldTabSamples?: typeof samples }).__coldTabSamples = samples
requestAnimationFrame(() => setTimeout(sample, 0))
},
{ destination, last },
)
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
await page.waitForFunction(() =>
(window as Window & { __coldTabSamples?: Array<{ destination: boolean }> }).__coldTabSamples?.some(
(sample) => sample.destination,
),
)
const result = await page.evaluate(() => {
const samples = (
window as Window & {
__coldTabSamples?: Array<{ destination: boolean; last: boolean; bottomError?: number }>
}
).__coldTabSamples!
return samples.find((sample) => sample.destination)!
})
expect(result.last).toBe(true)
expect(Math.abs(result.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
})
test("renders seeded timeline in order while paging through history", async ({ page }) => { test("renders seeded timeline in order while paging through history", async ({ page }) => {
const errors = trackPageErrors(page) const errors = trackPageErrors(page)
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
@@ -690,29 +411,18 @@ function expectCompleteScroll(
async function selectHomeProject(page: Page, projectName: string) { async function selectHomeProject(page: Page, projectName: string) {
await page.goto("/") await page.goto("/")
const row = page await page
.locator('[data-component="home-project-row"]') .locator('[data-component="home-project-row"]')
.filter({ hasText: new RegExp(projectName, "i") }) .filter({ hasText: new RegExp(projectName, "i") })
.first() .click()
await expectAppVisible(row)
await row.click()
await expect(row).toHaveAttribute("data-selected", "", { timeout: APP_READY_TIMEOUT })
await expect(page).toHaveURL(/\/$/) await expect(page).toHaveURL(/\/$/)
} }
async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) { async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`) await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
await expectSessionTitle(page, expectedTitle) await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
}
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
const href = `/${base64Encode(fixture.directory)}/session/${sessionID}`
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
await tab.click()
await expectSessionTitle(page, title)
} }
async function expectSessionReady(page: Page) { async function expectSessionReady(page: Page) {
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i })) await expect(page.getByRole("textbox", { name: /Ask anything/i })).toBeVisible()
} }
+6 -16
View File
@@ -18,10 +18,7 @@ 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 }
messageDelay?: number
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
events?: () => unknown[] events?: () => unknown[]
eventRetry?: number
} }
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
@@ -44,13 +41,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
await page.route("**/*", async (route) => { await page.route("**/*", async (route) => {
const url = new URL(route.request().url()) const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const appPort = new URL( if (url.port !== targetPort) return route.fallback()
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
).port
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
const path = url.pathname const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) if (path === "/global/event" || path === "/event") return sse(route, config.events?.())
if (path === "/global/health") return json(route, { healthy: true }) 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, [])
@@ -66,17 +60,13 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) { if (messagesMatch) {
const before = url.searchParams.get("before") ?? undefined
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const limit = Number(url.searchParams.get("limit") ?? 80) const limit = Number(url.searchParams.get("limit") ?? 80)
const before = url.searchParams.get("before") ?? undefined
const pageData = config.pageMessages(messagesMatch[1], limit, before) const pageData = config.pageMessages(messagesMatch[1], limit, before)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined) return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
} }
if (url.port === targetPort && targetPort !== appPort) return json(route, {}) return json(route, {})
return route.fallback()
}) })
} }
@@ -93,10 +83,10 @@ function json(route: Route, body: unknown, headers?: Record<string, string>) {
}) })
} }
function sse(route: Route, events?: unknown[], retry?: number) { function sse(route: Route, events?: unknown[]) {
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: "text/event-stream", contentType: "text/event-stream",
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`, 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 }))
}
+7 -11
View File
@@ -1,13 +1,11 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.17.9", "version": "1.15.11",
"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",
"./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts",
"./vite": "./vite.js", "./vite": "./vite.js",
"./index.css": "./src/index.css" "./index.css": "./src/index.css"
}, },
@@ -17,15 +15,14 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"test": "bun run test:unit && bun run test:virtualizer", "test": "bun run test:unit",
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src", "test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts", "test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:local": "playwright test", "test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report", "test:e2e:report": "playwright show-report e2e/playwright-report"
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
@@ -48,7 +45,6 @@
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:", "@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2", "@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3", "@solid-primitives/active-element": "2.1.3",
@@ -66,7 +62,6 @@
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4", "@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -79,6 +74,7 @@
"shiki": "catalog:", "shiki": "catalog:",
"solid-js": "catalog:", "solid-js": "catalog:",
"solid-list": "catalog:", "solid-list": "catalog:",
"tailwindcss": "catalog:" "tailwindcss": "catalog:",
"virtua": "catalog:"
} }
} }
+7 -2
View File
@@ -7,9 +7,14 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}` const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
}
export default defineConfig({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
testIgnore: process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
outputDir: "./e2e/test-results", outputDir: "./e2e/test-results",
timeout: 60_000, timeout: 60_000,
expect: { expect: {
@@ -19,7 +24,7 @@ export default defineConfig({
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
workers, workers,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], reporter,
webServer: { webServer: {
command, command,
url: baseURL, url: baseURL,
+71 -194
View File
@@ -9,7 +9,7 @@ import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo" import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect" import { Effect } from "effect"
import { import {
@@ -25,6 +25,7 @@ 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"
@@ -32,7 +33,6 @@ import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk" import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync" import { ServerSyncProvider } from "@/context/server-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"
@@ -43,123 +43,24 @@ 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, useSettings } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal" import { TerminalProvider } from "@/context/terminal"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import DirectoryLayout from "@/pages/directory-layout"
import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context"
import DirectoryLayout, { DirectoryDataProvider } 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"
import { ServersProvider } from "./context/servers"
const HomeRoute = lazy(() => import("@/pages/home")) const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
const NewSession = lazy(() => import("@/pages/new-session"))
const SessionRoute = Object.assign( const SessionRoute = Object.assign(
() => { () => (
const settings = useSettings() <SessionProviders>
const params = useParams() <Session />
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>() </SessionProviders>
const sdk = useSDK() ),
const server = useServer()
const tabs = useTabs()
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
// is replaced by a draft at /new-session?draftId=…
createEffect(() => {
if (!settings.general.newLayoutDesigns()) return
if (params.id || search.draftId) return
if (!tabs.ready() || !sdk().directory) return
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
})
return (
<SessionProviders>
<Session />
</SessionProviders>
)
},
{ preload: Session.preload }, { preload: Session.preload },
) )
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
// Notification/Models + the visual Layout) for that server.
function SelectedServerLayout(props: ParentProps) {
return (
<ServerKey>
<ServerSDKProvider>
<ServerSyncProvider>
<ServerScopedShell>{props.children}</ServerScopedShell>
</ServerSyncProvider>
</ServerSDKProvider>
</ServerKey>
)
}
// Wraps /new-session. It resolves the draft's target server and provides the
// server-scoped shell for that server — without ServerKey, so the page never depends
// on the globally "selected" server.
function DraftServerLayout(props: ParentProps) {
const server = useServer()
const tabs = useTabs()
const [search] = useSearchParams<{ draftId?: string }>()
const conn = createMemo(() => {
const id = search.draftId
if (!id) return undefined
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
if (!draft) return undefined
return server.list.find((c) => ServerConnection.key(c) === draft.server)
})
return (
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ServerScopedShell>{props.children}</ServerScopedShell>
</ServerSyncProvider>
</ServerSDKProvider>
)
}
function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
return (
<Show when={tabs.ready()}>
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
{(draftID) => <ResolvedDraftRoute draftID={draftID} />}
</Show>
</Show>
)
}
function ResolvedDraftRoute(props: { draftID: string }) {
const tabs = useTabs()
const draft = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
)
// Key on the directory so retargeting the draft's project re-instantiates the
// directory-scoped providers while keeping the same draft id. The draft's target
// server is provided by DraftServerLayout, so changing only the server updates the
// SDK/sync hooks without remounting the composer.
const directory = () => draft()?.directory
return (
<Show when={directory()} keyed>
{(dir) => (
<SDKProvider directory={dir}>
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
<DraftProviders>
<NewSession />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
)}
</Show>
)
}
function UiI18nBridge(props: ParentProps) { 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>
@@ -168,7 +69,9 @@ function UiI18nBridge(props: ParentProps) {
declare global { declare global {
interface Window { interface Window {
__OPENCODE__?: { __OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[] deepLinks?: string[]
wsl?: boolean
} }
api?: { api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void> setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
@@ -206,36 +109,27 @@ function BodyDesignClass() {
return null return null
} }
// Server-agnostic providers shared across every route. These live in the shared function AppShellProviders(props: ParentProps) {
// shell (router root) so they stay mounted regardless of the active server/route.
function SharedProviders(props: ParentProps) {
return ( return (
<SettingsProvider> <SettingsProvider>
<BodyDesignClass /> <BodyDesignClass />
<CommandProvider> <PermissionProvider>
<HighlightsProvider>{props.children}</HighlightsProvider> <LayoutProvider>
</CommandProvider> <NotificationProvider>
<ModelsProvider>
<CommandProvider>
<HighlightsProvider>
<Layout>{props.children}</Layout>
</HighlightsProvider>
</CommandProvider>
</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
</SettingsProvider> </SettingsProvider>
) )
} }
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
// each per-route server layout so they resolve to that route's server (selected vs
// draft). The Layout remounts when crossing between those groups.
function ServerScopedShell(props: ParentProps) {
return (
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<ModelsProvider>
<Layout>{props.children}</Layout>
</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
)
}
function SessionProviders(props: ParentProps) { function SessionProviders(props: ParentProps) {
return ( return (
<TerminalProvider> <TerminalProvider>
@@ -248,15 +142,14 @@ function SessionProviders(props: ParentProps) {
) )
} }
// The draft page only renders the prompt composer, so it drops TerminalProvider. function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
function DraftProviders(props: ParentProps) {
return ( return (
<FileProvider> <AppShellProviders>
<PromptProvider> {/*<Suspense fallback={<Loading />}>*/}
<CommentsProvider>{props.children}</CommentsProvider> {props.appChildren}
</PromptProvider> {props.children}
</FileProvider> {/*</Suspense>*/}
</AppShellProviders>
) )
} }
@@ -278,13 +171,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>
@@ -320,21 +211,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={() => {
@@ -350,7 +246,8 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
> >
{props.children} {props.children}
</Show> </Show>
</Show> {/*</Show>*/}
</Suspense>
) )
} }
@@ -413,54 +310,34 @@ 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
}) { }) {
// The shared shell holds only server-agnostic providers (QueryClient + Settings/
// Command/Highlights) and stays mounted across every route. The server-scoped
// providers and the visual Layout live in the per-route layouts below, so they
// resolve to that route's server (selected for most routes, the draft's server for
// /new-session). appChildren is server-agnostic, so it renders here once.
const ServerShell = (shellProps: ParentProps) => (
<QueryProvider>
<SharedProviders>
{props.children}
{shellProps.children}
</SharedProviders>
</QueryProvider>
)
return ( return (
<ServerProvider <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
defaultServer={props.defaultServer} <ServersProvider>
canonicalLocalServer={props.canonicalLocalServer}
servers={props.servers}
>
<GlobalProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}> <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<Dynamic <ServerKey>
component={props.router ?? Router} <QueryProvider>
root={(routerProps) => ( <ServerSDKProvider>
<TabsProvider> <ServerSyncProvider>
<ServerShell>{routerProps.children}</ServerShell> <Dynamic
</TabsProvider> component={props.router ?? Router}
)} root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
> >
<Route component={SelectedServerLayout}> <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={() => <Navigate href="session" />} /> <Route path="/session/:id?" component={SessionRoute} />
<Route path="/session/:id?" component={SessionRoute} /> </Route>
</Route> </Dynamic>
</Route> </ServerSyncProvider>
<Route component={DraftServerLayout}> </ServerSDKProvider>
<Route path="/new-session" component={DraftRoute} /> </QueryProvider>
</Route> </ServerKey>
</Dynamic>
</ConnectionGate> </ConnectionGate>
</GlobalProvider> </ServersProvider>
</ServerProvider> </ServerProvider>
) )
} }
@@ -8,7 +8,7 @@ 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"
@@ -41,7 +41,7 @@ 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().get(props.provider) ?? serverSync.data.provider.all.get(props.provider)!,
) )
const fallback = createMemo<ProviderAuthMethod[]>(() => [ const fallback = createMemo<ProviderAuthMethod[]>(() => [
{ {
@@ -52,16 +52,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 = serverSync.data.provider_auth[props.provider]
if (cached) return cached if (cached) return cached
const res = await serverSDK().client.provider.auth() const res = await serverSDK.client.provider.auth()
if (!alive.value) return fallback() if (!alive.value) return fallback()
serverSync().set("provider_auth", res.data ?? {}) serverSync.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 && !serverSync.data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()) const methods = createMemo(() => auth.latest ?? serverSync.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,8 +158,8 @@ export function DialogConnectProvider(props: { provider: string }) {
} }
dispatch({ type: "auth.pending" }) dispatch({ type: "auth.pending" })
const start = Date.now() const start = Date.now()
await serverSDK() await serverSDK.client.provider.oauth
.client.provider.oauth.authorize( .authorize(
{ {
providerID: props.provider, providerID: props.provider,
method: index, method: index,
@@ -277,7 +277,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 +330,7 @@ export function DialogConnectProvider(props: { provider: string }) {
}) })
async function complete() { async function complete() {
await serverSDK().client.global.dispose() await serverSDK.client.global.dispose()
dialog.close() dialog.close()
showToast({ showToast({
variant: "success", variant: "success",
@@ -365,7 +364,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 +407,7 @@ export function DialogConnectProvider(props: { provider: string }) {
} }
setFormStore("error", undefined) setFormStore("error", undefined)
await serverSDK().client.auth.set({ await serverSDK.client.auth.set({
providerID: props.provider, providerID: props.provider,
auth: { auth: {
type: "api", type: "api",
@@ -480,8 +478,8 @@ export function DialogConnectProvider(props: { provider: string }) {
} }
setFormStore("error", undefined) setFormStore("error", undefined)
const result = await serverSDK() const result = await serverSDK.client.provider.oauth
.client.provider.oauth.callback({ .callback({
providerID: props.provider, providerID: props.provider,
method: store.methodIndex, method: store.methodIndex,
code, code,
@@ -533,8 +531,8 @@ export function DialogConnectProvider(props: { provider: string }) {
onMount(() => { onMount(() => {
void (async () => { void (async () => {
const result = await serverSDK() const result = await serverSDK.client.provider.oauth
.client.provider.oauth.callback({ .callback({
providerID: props.provider, providerID: props.provider,
method: store.methodIndex, method: store.methodIndex,
}) })
@@ -5,7 +5,7 @@ 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"
@@ -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: serverSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync().data.provider.all.keys()), existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
}) })
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 = serverSync.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 serverSDK.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 serverSync.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 { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-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/helpers"
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 serverSDK = useServerSDK()
const serverSync = useServerSync()
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 serverSDK.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) serverSync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close() dialog.close()
return return
} }
serverSync().project.meta(props.project.worktree, { serverSync.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 },
+9 -9
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"
@@ -35,13 +35,13 @@ export const DialogFork: Component = () => {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return [] if (!sessionID) return []
const msgs = sync().data.message[sessionID] ?? [] const msgs = sync.data.message[sessionID] ?? []
const result: ForkableMessage[] = [] const result: ForkableMessage[] = []
for (const message of msgs) { for (const message of msgs) {
if (message.role !== "user") continue if (message.role !== "user") continue
const parts = sync().data.part[message.id] ?? [] const parts = sync.data.part[message.id] ?? []
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored) const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
if (!textPart) continue if (!textPart) continue
@@ -61,15 +61,15 @@ export const DialogFork: Component = () => {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return if (!sessionID) return
const parts = sync().data.part[item.id] ?? [] const parts = sync.data.part[item.id] ?? []
const restored = extractPromptFromParts(parts, { const restored = extractPromptFromParts(parts, {
directory: sdk().directory, directory: sdk.directory,
attachmentName: language.t("common.attachment"), attachmentName: language.t("common.attachment"),
}) })
const dir = base64Encode(sdk().directory) const dir = base64Encode(sdk.directory)
sdk() sdk.client.session
.client.session.fork({ sessionID, messageID: item.id }) .fork({ sessionID, messageID: item.id })
.then((forked) => { .then((forked) => {
if (!forked.data) { if (!forked.data) {
showToast({ title: language.t("common.requestFailed") }) showToast({ title: language.t("common.requestFailed") })
@@ -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}`}
@@ -1,107 +0,0 @@
.directory-picker-v2-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 12px;
padding: 2px 16px 0;
}
.directory-picker-v2-path {
position: relative;
z-index: 10;
display: flex;
gap: 8px;
}
.directory-picker-v2-actions {
display: flex;
flex-shrink: 0;
gap: 2px;
}
.directory-picker-v2-suggestions {
position: absolute;
z-index: 20;
top: 36px;
right: 0;
left: 0;
display: flex;
flex-direction: column;
padding: 4px;
border: 1px solid var(--v2-border-border-base);
border-radius: 6px;
background: var(--v2-background-bg-layer-02);
box-shadow: var(--v2-elevation-overlay);
}
.directory-picker-v2-suggestions button {
overflow: hidden;
padding: 6px 8px;
border-radius: 4px;
color: var(--v2-text-text-muted);
font-size: 12px;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.directory-picker-v2-suggestions button:hover,
.directory-picker-v2-suggestions button[data-active] {
color: var(--v2-text-text-base);
background: var(--v2-overlay-simple-overlay-hover);
}
.directory-picker-v2-browser {
position: relative;
z-index: 0;
isolation: isolate;
min-height: 0;
flex: 1;
overflow: auto;
border: 1px solid var(--v2-border-border-base);
border-radius: 6px;
background: transparent;
}
.directory-picker-v2-tree {
display: block;
width: 100%;
height: 100%;
--trees-bg-override: transparent;
--trees-fg-override: var(--v2-text-text-base);
--trees-fg-muted-override: var(--v2-text-text-muted);
--trees-bg-muted-override: transparent;
--trees-selected-bg-override: transparent;
--trees-selected-fg-override: var(--v2-text-text-base);
--trees-selected-focused-border-color-override: transparent;
--trees-focus-ring-color-override: transparent;
--trees-focus-ring-width-override: 0px;
--trees-focus-ring-offset-override: 0px;
--trees-border-color-override: var(--v2-border-border-base);
--trees-font-family-override: var(--font-family-sans);
--trees-font-size-override: 12px;
--trees-item-height: 24px;
--trees-border-radius-override: 4px;
}
.directory-picker-v2-state {
position: absolute;
z-index: 1;
inset: 0;
display: grid;
place-items: center;
color: var(--v2-text-text-muted);
font-size: 12px;
pointer-events: none;
}
.directory-picker-v2-selection {
overflow: hidden;
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -1,356 +0,0 @@
import "@pierre/trees/web-components"
import { FileTree } from "@pierre/trees"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import {
absoluteTreePath,
activeTreeNavigation,
advanceTreePreload,
nextSuggestionIndex,
nextTreeScrollTop,
pickerFileSearchQuery,
pickerAbsoluteInput,
pickerMode,
preloadTreeDirectories,
cleanPickerInput,
createDirectorySearch,
currentPickerSuggestions,
displayPickerPath,
pickerParent,
pickerRoot,
} from "./directory-picker-domain"
import "./dialog-select-directory-v2.css"
interface DialogSelectDirectoryV2Props {
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
server: ServerConnection.Any
mode?: "directory" | "file"
start?: string
}
export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const global = useGlobal()
const { sync, sdk } = global.createServerCtx(props.server)
const dialog = useDialog()
const language = useLanguage()
const policy = pickerMode(props.mode ?? "directory", props.start)
const action = {
file: language.t("dialog.directory.action.selectFile"),
directory: language.t("dialog.directory.action.selectFolder"),
}
const [root, setRoot] = createSignal("")
const [input, setInput] = createSignal("")
const [selected, setSelected] = createSignal("")
const [suggestionsOpen, setSuggestionsOpen] = createSignal(false)
const [activeSuggestion, setActiveSuggestion] = createSignal(-1)
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal(false)
const [rootValid, setRootValid] = createSignal(false)
const listings = new Map<string, Promise<Array<{ name: string; type: "file" | "directory" }> | undefined>>()
const advanced = new Set<string>()
let tree: FileTree | undefined
let container: HTMLDivElement | undefined
let pathArea: HTMLDivElement | undefined
let navigation = 0
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
() =>
sdk.client.path
.get()
.then((result) => result.data)
.catch(() => undefined),
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
const start = createMemo(
() =>
props.start ||
sync.data.path.home ||
sync.data.path.directory ||
fallbackPath()?.home ||
fallbackPath()?.directory,
)
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
const [suggestions] = createResource(input, async (value) => {
const typed = cleanPickerInput(value).replace(/\/+$/, "")
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
if (!typed || typed === current) return { query: value, items: [] }
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.client.find
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
.then((result) => result.data ?? [])
.catch(() => [])
const results = [
...directories,
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
]
return {
query: value,
items: Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8),
}
})
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
async function load(path: string, generation: number, preload = true) {
const key = path.replace(/\/+$/, "")
setError(false)
const absolute = absoluteTreePath(root(), key)
const request =
listings.get(key) ??
sdk.client.file
.list({ directory: absolute, path: "" })
.then((result) => result.data ?? [])
.catch(() => undefined)
listings.set(key, request)
const nodes = await request
if (!activeTreeNavigation(generation, navigation)) return false
if (!nodes) {
listings.delete(key)
if (!key) setError(true)
return false
}
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
if (preload && advanceTreePreload(advanced, key)) {
void Promise.all(preloadTreeDirectories(key, nodes).map((directory) => load(directory, generation, false)))
}
return true
}
async function navigate(path: string) {
const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home(), root() || start() || home()))
if (!value) return
const token = ++navigation
setLoading(true)
setRootValid(false)
setSelected("")
setSuggestionsOpen(false)
setActiveSuggestion(-1)
setRoot(value)
setInput(displayPickerPath(value, value, home()))
listings.clear()
advanced.clear()
tree?.resetPaths([])
const valid = await load("", token)
if (!activeTreeNavigation(token, navigation)) return
setRootValid(valid)
setLoading(false)
}
function complete() {
const items = currentSuggestions()
const match = items[activeSuggestion()] ?? items[0]
if (!match) return
const value = displayPickerPath(match.absolute, input(), home())
setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value)
if (match.type === "file") {
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "")
setSuggestionsOpen(false)
setActiveSuggestion(-1)
}
}
function chooseSuggestion(suggestion: { absolute: string; type: "file" | "directory" }) {
if (suggestion.type === "directory") {
void navigate(suggestion.absolute)
return
}
setInput(displayPickerPath(suggestion.absolute, input(), home()))
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "")
setSuggestionsOpen(false)
setActiveSuggestion(-1)
}
function moveSuggestion(delta: -1 | 1) {
setSuggestionsOpen(true)
setActiveSuggestion((current) => nextSuggestionIndex(current, delta, currentSuggestions().length))
}
function activeSuggestionValue() {
const items = currentSuggestions()
return items[activeSuggestion()] ?? items[0]
}
const keyActions: Partial<Record<string, () => void>> = {
ArrowDown: () => moveSuggestion(1),
ArrowUp: () => moveSuggestion(-1),
Enter: () => {
const suggestion = activeSuggestionValue()
if (suggestion) chooseSuggestion(suggestion)
if (!suggestion) void navigate(input())
},
Tab: complete,
}
function handleInputKey(event: KeyboardEvent) {
const action = keyActions[event.key]
if (!action) return
if (event.key === "Tab" && event.shiftKey) return
event.preventDefault()
action()
}
function resolve() {
const path = policy.result(root(), selected(), rootValid())
if (!path) return
props.onSelect(props.multiple ? [path] : path)
dialog.close()
}
onMount(() => {
const closeSuggestions = (event: PointerEvent) => {
if (pathArea?.contains(event.target as Node)) return
setSuggestionsOpen(false)
setActiveSuggestion(-1)
}
document.addEventListener("pointerdown", closeSuggestions)
onCleanup(() => document.removeEventListener("pointerdown", closeSuggestions))
tree = new FileTree({
paths: [],
flattenEmptyDirectories: false,
initialExpansion: "closed",
stickyFolders: true,
unsafeCSS: `
button[data-type="item"] {
background: transparent !important;
box-shadow: none !important;
}
button[data-type="item"]:hover {
background: var(--v2-overlay-simple-overlay-hover) !important;
}
button[data-type="item"]:focus-visible {
outline: none !important;
box-shadow: none !important;
}
[data-file-tree-virtualized-scroll] {
overscroll-behavior: contain;
scrollbar-width: thin;
}
`,
onExpansionChange(change) {
if (change.expanded) void load(change.path, navigation)
},
onSelectionChange(paths) {
const path = paths.at(-1)
setSelected(path ? (policy.selection(root(), path) ?? "") : "")
},
})
if (!container) return
tree.render({ containerWrapper: container })
tree.getFileTreeContainer()?.classList.add("directory-picker-v2-tree")
})
createEffect(() => {
const path = start()
if (!path || root()) return
void navigate(path)
})
onCleanup(() => tree?.cleanUp())
return (
<Dialog title={props.title ?? language.t("command.project.open")} size="large" class="directory-picker-v2">
<div class="directory-picker-v2-body">
<div class="directory-picker-v2-path" ref={pathArea}>
<TextInputV2
value={input()}
autofocus
autocomplete="off"
spellcheck={false}
class="!w-full"
onInput={(event) => {
setInput(cleanPickerInput(event.currentTarget.value))
setSelected("")
setSuggestionsOpen(true)
setActiveSuggestion(-1)
}}
role="combobox"
aria-autocomplete="list"
aria-expanded={suggestionsOpen()}
aria-controls="directory-picker-v2-suggestions"
aria-activedescendant={
activeSuggestion() >= 0 ? `directory-picker-v2-suggestion-${activeSuggestion()}` : undefined
}
onKeyDown={handleInputKey}
/>
<div class="directory-picker-v2-actions">
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(home())}>
~
</ButtonV2>
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerRoot(root()) || root())}>
{language.t("dialog.directory.root")}
</ButtonV2>
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerParent(root()))}>
{language.t("dialog.directory.parent")}
</ButtonV2>
</div>
<Show when={suggestionsOpen() && currentSuggestions().length > 0}>
<div id="directory-picker-v2-suggestions" role="listbox" class="directory-picker-v2-suggestions">
<For each={currentSuggestions()}>
{(suggestion, index) => (
<button
id={`directory-picker-v2-suggestion-${index()}`}
role="option"
aria-selected={index() === activeSuggestion()}
data-active={index() === activeSuggestion() ? "" : undefined}
onPointerMove={() => setActiveSuggestion(index())}
onClick={() => chooseSuggestion(suggestion)}
>
{displayPickerPath(suggestion.absolute, input(), home())}
{suggestion.type === "directory" ? "/" : ""}
</button>
)}
</For>
</div>
</Show>
</div>
<div
class="directory-picker-v2-browser"
ref={container}
onWheel={(event) => {
const scroller = tree
?.getFileTreeContainer()
?.shadowRoot?.querySelector<HTMLElement>("[data-file-tree-virtualized-scroll]")
if (!scroller) return
const next = nextTreeScrollTop(
scroller.scrollTop,
event.deltaY,
scroller.scrollHeight,
scroller.clientHeight,
)
if (next === scroller.scrollTop) return
event.preventDefault()
scroller.scrollTop = next
scroller.dispatchEvent(new Event("scroll"))
}}
>
<Show when={loading()}>
<div class="directory-picker-v2-state">{language.t("common.loading")}</div>
</Show>
<Show when={!loading() && error()}>
<div class="directory-picker-v2-state">{language.t("dialog.directory.readError")}</div>
</Show>
</div>
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
</div>
<DialogFooter>
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="contrast" disabled={!policy.result(root(), selected(), rootValid())} onClick={resolve}>
{action[policy.action]}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
@@ -4,17 +4,17 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import type { ListRef } from "@opencode-ai/ui/list" 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 { createMemo, createResource, createSignal } from "solid-js" import { createMemo, createResource, createSignal } from "solid-js"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLayout } from "@/context/layout"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global"
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
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 = {
@@ -23,9 +23,89 @@ type Row = {
group: "recent" | "folders" group: "recent" | "folders"
} }
function cleanInput(value: string) {
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
}
function normalizePath(input: string) {
const v = input.replaceAll("\\", "/")
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
return v.replace(/\/+/g, "/")
}
function normalizeDriveRoot(input: string) {
const v = normalizePath(input)
if (/^[A-Za-z]:$/.test(v)) return v + "/"
return v
}
function trimTrailing(input: string) {
const v = normalizeDriveRoot(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
return v.replace(/\/+$/, "")
}
function joinPath(base: string | undefined, rel: string) {
const b = trimTrailing(base ?? "")
const r = trimTrailing(rel).replace(/^\/+/, "")
if (!b) return r
if (!r) return b
if (b.endsWith("/")) return b + r
return b + "/" + r
}
function rootOf(input: string) {
const v = normalizeDriveRoot(input)
if (v.startsWith("//")) return "//"
if (v.startsWith("/")) return "/"
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
return ""
}
function parentOf(input: string) {
const v = trimTrailing(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
const i = v.lastIndexOf("/")
if (i <= 0) return "/"
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
return v.slice(0, i)
}
function modeOf(input: string) {
const raw = normalizeDriveRoot(input.trim())
if (!raw) return "relative" as const
if (raw.startsWith("~")) return "tilde" as const
if (rootOf(raw)) return "absolute" as const
return "relative" as const
}
function tildeOf(absolute: string, home: string) {
const full = trimTrailing(absolute)
if (!home) return ""
const hn = trimTrailing(home)
const lc = full.toLowerCase()
const hc = hn.toLowerCase()
if (lc === hc) return "~"
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
return ""
}
function displayPath(path: string, input: string, home: string) {
const full = trimTrailing(path)
if (modeOf(input) === "absolute") return full
return tildeOf(full, home) || full
}
function toRow(absolute: string, home: string, group: Row["group"]): Row { function toRow(absolute: string, home: string, group: Row["group"]): Row {
const full = displayPickerPath(absolute, "", "") const full = trimTrailing(absolute)
const tilde = displayPickerPath(full, "~", home) const tilde = tildeOf(full, home)
const withSlash = (value: string) => { const withSlash = (value: string) => {
if (!value) return "" if (!value) return ""
if (value.endsWith("/")) return value if (value.endsWith("/")) return value
@@ -47,9 +127,128 @@ function uniqueRows(rows: Row[]) {
}) })
} }
function useDirectorySearch(args: {
sdk: ReturnType<typeof useServerSDK>
start: () => string | undefined
home: () => string
}) {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0
const scoped = (value: string) => {
const base = args.start()
if (!base) return
const raw = normalizeDriveRoot(value)
if (!raw) return { directory: trimTrailing(base), path: "" }
const h = args.home()
if (raw === "~") return { directory: trimTrailing(h || base), path: "" }
if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) }
const root = rootOf(raw)
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
return { directory: trimTrailing(base), path: raw }
}
const dirs = async (dir: string) => {
const key = trimTrailing(dir)
const existing = cache.get(key)
if (existing) return existing
const request = args.sdk.client.file
.list({ directory: key, path: "" })
.then((x) => x.data ?? [])
.catch(() => [])
.then((nodes) =>
nodes
.filter((n) => n.type === "directory")
.map((n) => ({
name: n.name,
absolute: trimTrailing(normalizeDriveRoot(n.absolute)),
})),
)
cache.set(key, request)
return request
}
const match = async (dir: string, query: string, limit: number) => {
const items = await dirs(dir)
if (!query) return items.slice(0, limit).map((x) => x.absolute)
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
}
return async (filter: string) => {
const token = ++current
const active = () => token === current
const value = cleanInput(filter)
const scopedInput = scoped(value)
if (!scopedInput) return [] as string[]
const raw = normalizeDriveRoot(value)
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
const query = normalizeDriveRoot(scopedInput.path)
const find = () =>
args.sdk.client.find
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
.then((x) => x.data ?? [])
.catch(() => [])
if (!isPath) {
const results = await find()
if (!active()) return []
return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50)
}
const segments = query.replace(/^\/+/, "").split("/")
const head = segments.slice(0, segments.length - 1).filter((x) => x && x !== ".")
const tail = segments[segments.length - 1] ?? ""
const cap = 12
const branch = 4
let paths = [scopedInput.directory]
for (const part of head) {
if (!active()) return []
if (part === "..") {
paths = paths.map(parentOf)
continue
}
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
if (!active()) return []
paths = Array.from(new Set(next)).slice(0, cap)
if (paths.length === 0) return [] as string[]
}
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
if (!active()) return []
const deduped = Array.from(new Set(out))
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
const expand = !raw.endsWith("/")
if (!expand || !tail) {
const items = base ? Array.from(new Set([base, ...deduped])) : deduped
return items.slice(0, 50)
}
const needle = tail.toLowerCase()
const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle)
const target = exact[0]
if (!target) return deduped.slice(0, 50)
const children = await match(target, "", 30)
if (!active()) return []
const items = Array.from(new Set([...deduped, ...children]))
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
}
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const global = useGlobal() const sync = useServerSync()
const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server) const sdk = useServerSDK()
const layout = useLayout()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
@@ -73,14 +272,14 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
) )
const directories = createDirectorySearch({ const directories = useDirectorySearch({
sdk, sdk,
home, home,
base: start, start,
}) })
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) {
@@ -125,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")}
@@ -141,7 +339,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open") group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
} }
ref={(r) => (list = r)} ref={(r) => (list = r)}
onFilter={(value) => setFilter(cleanPickerInput(value))} onFilter={(value) => setFilter(cleanInput(value))}
onKeyEvent={(e, item) => { onKeyEvent={(e, item) => {
if (e.key !== "Tab") return if (e.key !== "Tab") return
if (e.shiftKey) return if (e.shiftKey) return
@@ -150,7 +348,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
const value = displayPickerPath(item.absolute, filter(), home()) const value = displayPath(item.absolute, filter(), home())
list?.setFilter(value.endsWith("/") ? value : value + "/") list?.setFilter(value.endsWith("/") ? value : value + "/")
}} }}
onSelect={(path) => { onSelect={(path) => {
@@ -159,7 +357,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}} }}
> >
{(item) => { {(item) => {
const path = displayPickerPath(item.absolute, filter(), home()) const path = displayPath(item.absolute, filter(), home())
if (path === "~") { if (path === "~") {
return ( return (
<div class="w-full flex items-center justify-between rounded-md"> <div class="w-full flex items-center justify-between rounded-md">
@@ -7,25 +7,18 @@ import { List } from "@opencode-ai/ui/list"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { createMemo, createSignal, lazy, 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, type ServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-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"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
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 { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time" import { getRelativeTime } from "@/utils/time"
const DialogSelectFileV2 = lazy(() =>
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
)
type EntryType = "command" | "file" | "session" type EntryType = "command" | "file" | "session"
type Entry = { type Entry = {
@@ -182,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: ServerSDK serverSDK: ReturnType<typeof useServerSDK>
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}) { }) {
const state: { const state: {
@@ -271,9 +264,6 @@ function createSessionEntries(props: {
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
const platform = usePlatform()
const server = useServer()
const settings = useSettings()
const layout = useLayout() const layout = useLayout()
const file = useFile() const file = useFile()
const dialog = useDialog() const dialog = useDialog()
@@ -302,21 +292,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
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(() => serverSync.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] = serverSync.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: serverSDK(), language }) const { sessions } = createSessionEntries({ workspaces, label, serverSDK, language })
const items = async (text: string) => { const items = async (text: string) => {
const query = text.trim() const query = text.trim()
@@ -393,25 +383,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
state.cleanup?.() state.cleanup?.()
}) })
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns() && server.current) {
return (
<DialogSelectFileV2
server={server.current}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
onSelect={(result) => {
if (typeof result !== "string") return
open(result)
}}
/>
)
}
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")
@@ -424,7 +398,6 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
items={items} items={items}
key={(item) => item.id} key={(item) => item.id}
filterKeys={["title", "description", "category"]} filterKeys={["title", "description", "category"]}
skipFilter={(item) => item.type === "file"}
groupBy={grouped() ? (item) => item.category : () => ""} groupBy={grouped() ? (item) => item.category : () => ""}
onMove={handleMove} onMove={handleMove}
onSelect={handleSelect} onSelect={handleSelect}
@@ -1,10 +1,13 @@
import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { Component, createMemo, Show } from "solid-js" import { Component, createMemo, Show } from "solid-js"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
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 { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useMcpToggle } from "@/context/mcp" import { useQueryOptions } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
const statusLabels = { const statusLabels = {
connected: "mcp.status.connected", connected: "mcp.status.connected",
@@ -16,15 +19,32 @@ const statusLabels = {
export const DialogSelectMcp: Component = () => { export const DialogSelectMcp: Component = () => {
const sync = useSync() const sync = useSync()
const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const queryClient = useQueryClient()
const queryOptions = useQueryOptions()
const items = createMemo(() => const items = createMemo(() =>
Object.entries(sync().data.mcp ?? {}) Object.entries(sync.data.mcp ?? {})
.map(([name, status]) => ({ name, status: status.status })) .map(([name, status]) => ({ name, status: status.status }))
.sort((a, b) => a.name.localeCompare(b.name)), .sort((a, b) => a.name.localeCompare(b.name)),
) )
const toggle = useMcpToggle() const toggle = useMutation(() => ({
mutationFn: async (name: string) => {
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
return
}
if (status?.status === "needs_auth") {
await sdk.client.mcp.auth.authenticate({ name })
return
}
await sdk.client.mcp.connect({ name })
},
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
}))
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
const totalCount = createMemo(() => items().length) const totalCount = createMemo(() => items().length)
@@ -35,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 ?? ""}
@@ -48,7 +67,7 @@ export const DialogSelectMcp: Component = () => {
}} }}
> >
{(i) => { {(i) => {
const mcpStatus = () => sync().data.mcp[i.name] const mcpStatus = () => sync.data.mcp[i.name]
const status = () => mcpStatus()?.status const status = () => mcpStatus()?.status
const statusLabel = () => { const statusLabel = () => {
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
@@ -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,7 +90,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
<div class="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div> <div class="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={(p) => p.id}
items={providers.popular} items={providers.popular}
activeIcon="plus-small" activeIcon="plus-small"
@@ -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,7 +29,6 @@ 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"
@@ -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,
}) })
} }
@@ -509,196 +495,155 @@ export function useServerManagementController(options: { onSelect?: () => void;
resetEdit() resetEdit()
}) })
async function handleRemove(key: ServerConnection.Key) { async function handleRemove(url: ServerConnection.Key) {
try { server.remove(url)
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key) if ((await platform.getDefaultServer?.()) === url) {
tabs.removeServer(key) void platform.setDefaultServer?.(null)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) {
await setDefault(null)
}
} catch (err) {
showRequestError(language, err)
} }
} }
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()}>
<List <div class="flex flex-1 min-h-0 flex-col gap-2">
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" <Show
search={{ when={!isFormMode()}
placeholder: language.t("dialog.server.search.placeholder"), fallback={
autofocus: false, <ServerForm
}} value={isAddMode() ? store.addServer.url : store.editServer.value}
noInitialSelection name={isAddMode() ? store.addServer.name : store.editServer.name}
emptyMessage={language.t("dialog.server.empty")} username={isAddMode() ? store.addServer.username : store.editServer.username}
items={props.controller.sortedItems} password={isAddMode() ? store.addServer.password : store.editServer.password}
key={(x) => x.http.url} placeholder={language.t("dialog.server.add.placeholder")}
onSelect={(x) => { busy={formBusy()}
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x) error={isAddMode() ? store.addServer.error : store.editServer.error}
}} status={isAddMode() ? store.addServer.status : store.editServer.status}
divider={true} onChange={isAddMode() ? handleAddChange : handleEditChange}
> onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
{(i) => { onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
const key = ServerConnection.key(i) onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
return ( onSubmit={submitForm}
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item"> onBack={resetForm}
<div class="flex flex-col h-full items-center w-5"> />
<ServerHealthIndicator health={props.controller.status()[key]} /> }
</div>
<ServerRow
conn={i}
dimmed={props.controller.status()[key]?.healthy === false}
status={props.controller.status()[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}>
<Icon name="check" class="h-6" />
</Show>
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
props.controller.startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</div>
</div>
)
}}
</List>
<div class="shrink-0 pb-5">
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={props.controller.startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
> >
{language.t("dialog.server.add.button")} <List
</Button> search={{
placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false,
}}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => x.http.url}
onSelect={(x) => {
if (x) void select(x)
}}
divider={true}
class="flex-1 min-h-0 px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
>
{(i) => {
const key = ServerConnection.key(i)
return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-start w-5">
<ServerHealthIndicator health={store.status[key]} />
</div>
<ServerRow
conn={i}
dimmed={store.status[key]?.healthy === false}
status={store.status[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<Show when={ServerConnection.key(current()) === key}>
<Icon name="check" class="h-6" />
</Show>
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultKey() === key}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</div>
</div>
)
}}
</List>
</Show>
<div class="shrink-0 px-5 pb-5">
<Show
when={isFormMode()}
fallback={
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
}
>
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
{formBusy()
? language.t("dialog.server.add.checking")
: isAddMode()
? language.t("dialog.server.add.button")
: language.t("common.save")}
</Button>
</Show>
</div>
</div> </div>
</div> </Dialog>
)
}
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) {
const language = useLanguage()
return (
<div class="flex flex-1 min-h-0 flex-col gap-4">
<ServerForm
value={props.controller.formValue()}
name={props.controller.formName()}
username={props.controller.formUsername()}
password={props.controller.formPassword()}
placeholder={language.t("dialog.server.add.placeholder")}
busy={props.controller.formBusy()}
error={props.controller.formError()}
status={props.controller.formStatus()}
onChange={props.controller.handleFormChange()}
onNameChange={props.controller.handleFormNameChange()}
onUsernameChange={props.controller.handleFormUsernameChange()}
onPasswordChange={props.controller.handleFormPasswordChange()}
onSubmit={props.controller.submitForm}
onBack={props.controller.resetForm}
/>
<div class="shrink-0 pb-5">
<Button
variant="primary"
size="large"
onClick={props.controller.submitForm}
disabled={props.controller.formBusy()}
class="px-3 py-1.5"
>
{props.controller.formBusy()
? language.t("dialog.server.add.checking")
: props.controller.isAddMode()
? language.t("dialog.server.add.button")
: language.t("common.save")}
</Button>
</div>
</div>
) )
} }
@@ -8,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>
@@ -1,189 +0,0 @@
import { expect, test } from "bun:test"
import {
absoluteTreePath,
activeTreeNavigation,
advanceTreePreload,
nextSuggestionIndex,
nextTreeScrollTop,
pickerTreeEntries,
pickerSearchEntries,
pickerFileSearchQuery,
pickerMode,
preloadTreeDirectories,
selectedTreePath,
treeEntries,
treePathWithin,
currentPickerSuggestions,
createDirectorySearch,
displayPickerPath,
pickerParent,
pickerRoot,
pickerAbsoluteInput,
} from "./directory-picker-domain"
test("maps server directory entries into Pierre paths", () => {
expect(
treeEntries("src/", [
{ name: "components", type: "directory" },
{ name: "index.ts", type: "file" },
]),
).toEqual(["src/components/", "src/index.ts"])
})
test("maps Pierre paths back to the selected server root", () => {
expect(absoluteTreePath("C:/Users/luke", "src/components/")).toBe("C:/Users/luke/src/components")
expect(absoluteTreePath("C:/", "")).toBe("C:/")
expect(absoluteTreePath("C:/", "README.md")).toBe("C:/README.md")
expect(absoluteTreePath("/home/luke", "README.md")).toBe("/home/luke/README.md")
})
test("includes files only when the picker selects files", () => {
const nodes = [
{ name: "components", type: "directory" as const },
{ name: "index.ts", type: "file" as const },
]
expect(pickerTreeEntries("", nodes, "directory")).toEqual(["components/"])
expect(pickerTreeEntries("", nodes, "file")).toEqual(["components/", "index.ts"])
})
test("includes files in file autocomplete while preserving directory navigation", () => {
const nodes = [
{ name: "src", absolute: "/repo/src", type: "directory" as const },
{ name: "README.md", absolute: "/repo/README.md", type: "file" as const },
]
expect(pickerSearchEntries(nodes, "directory")).toEqual([nodes[0]])
expect(pickerSearchEntries(nodes, "file")).toEqual(nodes)
})
test("centralizes file and directory selection policy", () => {
const file = pickerMode("file", "/repo")
expect(file.includeFiles).toBeTrue()
expect(file.selection("/repo/src", "index.ts")).toBe("src/index.ts")
expect(file.selection("/repo", "src/")).toBeUndefined()
expect(file.result("/repo", "src/index.ts")).toBe("src/index.ts")
expect(file.selection("/tmp", "example.txt")).toBeUndefined()
expect(file.navigation("/repo/src")).toBe("/repo/src")
expect(file.navigation("/tmp")).toBeUndefined()
const directory = pickerMode("directory")
expect(directory.includeFiles).toBeFalse()
expect(directory.selection("/repo", "src/")).toBe("/repo/src")
expect(directory.selection("C:/Users/luke", "repos/")).toBe("C:\\Users\\luke\\repos")
expect(directory.selection("//Server/Share", "repo/")).toBe("\\\\Server\\Share\\repo")
expect(directory.navigation("/tmp")).toBe("/tmp")
expect(directory.result("/repo", "")).toBe("/repo")
expect(directory.result("C:/Users/luke", "")).toBe("C:\\Users\\luke")
expect(directory.result("//Server/Share/repo", "")).toBe("\\\\Server\\Share\\repo")
expect(directory.result("/repo", "", false)).toBeUndefined()
})
test("accepts mutations only from the active navigation", () => {
expect(activeTreeNavigation(3, 3)).toBeTrue()
expect(activeTreeNavigation(2, 3)).toBeFalse()
})
test("preserves POSIX case while matching Windows drives case-insensitively", () => {
expect(treePathWithin("/repo", "/Repo")).toBeFalse()
expect(treePathWithin("C:/Repo", "c:/repo/src")).toBeTrue()
expect(treePathWithin("//Server/Share/Repo", "//server/share/repo/src")).toBeTrue()
expect(pickerMode("file", "//Server/Share/Repo").selection("//server/share/repo/src", "file.ts")).toBe("src/file.ts")
expect(treePathWithin("/repo", "/repo/../tmp")).toBeFalse()
expect(treePathWithin("/", "/src")).toBeTrue()
expect(pickerMode("file", "C:/Repo").selection("c:/repo/src", "file.ts")).toBe("src/file.ts")
expect(pickerMode("file", "C:/").selection("C:/", "file.ts")).toBe("file.ts")
})
test("displays paths using the selected server path format", () => {
expect(displayPickerPath("C:/Users/luke/repos", "C:/Users/luke/repos", "C:/Users/luke")).toBe(
"C:\\Users\\luke\\repos",
)
expect(displayPickerPath("C:/Users/luke/repos", "C:\\Users\\luke\\repos", "C:/Users/luke")).toBe(
"C:\\Users\\luke\\repos",
)
expect(displayPickerPath("/home/luke/repos", "repos", "/home/luke")).toBe("~/repos")
expect(displayPickerPath("/home/luke/repos", "~/repos", "/home/luke")).toBe("~/repos")
})
test("treats the server share prefix as the UNC root", () => {
expect(pickerRoot("//Server/Share/repo/src")).toBe("//Server/Share")
expect(pickerRoot("\\\\Server\\Share\\repo\\src")).toBe("//Server/Share")
expect(pickerParent("//Server/Share")).toBe("//Server/Share")
expect(pickerParent("//Server/Share/repo")).toBe("//Server/Share")
})
test("resolves relative input against the current picker root", () => {
expect(pickerAbsoluteInput("src", "/home/luke", "/home/luke/repo")).toBe("/home/luke/repo/src")
expect(pickerAbsoluteInput("../other", "/home/luke", "/home/luke/repo")).toBe("/home/luke/other")
expect(pickerAbsoluteInput("~/.config", "/home/luke", "/home/luke/repo")).toBe("/home/luke/.config")
expect(pickerAbsoluteInput("src", "C:/Users/luke", "C:/Users/luke/repo")).toBe("C:/Users/luke/repo/src")
})
test("exposes autocomplete results only for their source query", () => {
const result = { query: "/repo/src", items: ["/repo/src/index.ts"] }
expect(currentPickerSuggestions(result, "/repo/src")).toEqual(result.items)
expect(currentPickerSuggestions(result, "/repo/test")).toEqual([])
})
test("scopes file autocomplete to the current browser root", () => {
expect(pickerFileSearchQuery("/home/luke/repos", "/home/luke/repos/src/in", "/home/luke")).toBe("src/in")
expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op")
})
test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = []
const sdk = {
client: {
find: {
files: (input: { directory: string }) => {
directories.push(input.directory)
return Promise.resolve({ data: [] })
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
let base = "/repo"
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
await search("components")
base = "/repo/src"
await search("components")
expect(directories).toEqual(["/repo", "/repo/src"])
})
test("identifies the next directory level to preload", () => {
expect(
preloadTreeDirectories("src/", [
{ name: "components", type: "directory" },
{ name: "index.ts", type: "file" },
{ name: "utils", type: "directory" },
]),
).toEqual(["src/components/", "src/utils/"])
})
test("advances preloading once for every expanded directory", () => {
const advanced = new Set<string>()
expect(advanceTreePreload(advanced, "")).toBeTrue()
expect(advanceTreePreload(advanced, "")).toBeFalse()
expect(advanceTreePreload(advanced, "repos/")).toBeTrue()
})
test("clamps bridged tree wheel scrolling", () => {
expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
expect(nextTreeScrollTop(290, 40, 500, 200)).toBe(300)
})
test("wraps autocomplete keyboard navigation", () => {
expect(nextSuggestionIndex(-1, 1, 4)).toBe(0)
expect(nextSuggestionIndex(3, 1, 4)).toBe(0)
expect(nextSuggestionIndex(0, -1, 4)).toBe(3)
expect(nextSuggestionIndex(0, 1, 0)).toBe(-1)
})
test("returns absolute directories and relative files", () => {
expect(selectedTreePath("/home/luke/repo", "src/", "directory")).toBe("/home/luke/repo/src")
expect(selectedTreePath("/home/luke/repo", "src/index.ts", "file")).toBe("src/index.ts")
expect(selectedTreePath("/home/luke/repo/src", "index.ts", "file", "/home/luke/repo")).toBe("src/index.ts")
expect(selectedTreePath("/home/luke/repo", "src/", "file")).toBeUndefined()
})
@@ -1,331 +0,0 @@
export function treeEntries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
const prefix = parent.replace(/^\/+|\/+$/g, "")
return nodes.map((node) => {
const path = prefix ? `${prefix}/${node.name}` : node.name
return node.type === "directory" ? path + "/" : path
})
}
export function pickerTreeEntries(
parent: string,
nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>,
mode: "directory" | "file",
) {
return treeEntries(parent, mode === "directory" ? nodes.filter((node) => node.type === "directory") : nodes)
}
export function pickerSearchEntries<T extends { type: "file" | "directory" }>(
nodes: readonly T[],
mode: "directory" | "file",
) {
return mode === "directory" ? nodes.filter((node) => node.type === "directory") : [...nodes]
}
export function pickerMode(mode: "directory" | "file", base?: string) {
if (mode === "file") {
return {
includeFiles: true,
action: "file" as const,
entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
return treeEntries(parent, nodes)
},
navigation(path: string) {
return treePathWithin(base, path) ? path : undefined
},
result(root: string, selected: string) {
return selected || undefined
},
selection(root: string, path: string) {
if (!treePathWithin(base, root)) return
return selectedTreePath(root, path, "file", base)
},
}
}
return {
includeFiles: false,
action: "directory" as const,
entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
return treeEntries(
parent,
nodes.filter((node) => node.type === "directory"),
)
},
navigation(path: string) {
return path
},
result(root: string, selected: string, valid = true) {
if (!valid) return
return selected || (root ? nativePickerPath(root) : undefined)
},
selection(root: string, path: string) {
return selectedTreePath(root, path, "directory")
},
}
}
export function pickerFileSearchQuery(root: string, input: string, home: string) {
const value = input
.replace(/\\/g, "/")
.replace(/^~(?=\/|$)/, home)
.replace(/\/+$/, "")
const base = root.replace(/\\/g, "/").replace(/\/+$/, "")
if (value === base) return ""
if (value.startsWith(base + "/")) return value.slice(base.length + 1)
return value
}
export function pickerAbsoluteInput(input: string, home: string, current: string) {
const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home))
const absolute = pickerRoot(value) ? value : joinPickerPath(current, value)
return canonicalPickerPath(absolute)
}
export function treePathWithin(base: string | undefined, path: string) {
return pickerRelativePath(base, path) !== undefined
}
export function canonicalPickerPath(path: string) {
const value = normalizePickerDrive(path)
const root = pickerRoot(value)
const parts = value.slice(root.length).split("/")
const resolved = parts.reduce<string[]>((output, part) => {
if (!part || part === ".") return output
if (part === "..") {
output.pop()
return output
}
output.push(part)
return output
}, [])
return joinPickerPath(root, resolved.join("/"))
}
export function pickerRelativePath(base: string | undefined, path: string) {
if (!base) return
const rootPath = canonicalPickerPath(base)
const targetPath = canonicalPickerPath(path)
const insensitive = /^[A-Za-z]:\//.test(rootPath) || rootPath.startsWith("//")
const root = insensitive ? rootPath.toLowerCase() : rootPath
const target = insensitive ? targetPath.toLowerCase() : targetPath
if (target === root) return ""
const prefix = root.endsWith("/") ? root : root + "/"
if (!target.startsWith(prefix)) return
return targetPath.slice(prefix.length)
}
export function currentPickerSuggestions<T>(result: { query: string; items: readonly T[] } | undefined, query: string) {
if (result?.query !== query) return []
return result.items
}
export function preloadTreeDirectories(
parent: string,
nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>,
) {
return treeEntries(
parent,
nodes.filter((node) => node.type === "directory"),
)
}
export function advanceTreePreload(advanced: Set<string>, path: string) {
if (advanced.has(path)) return false
advanced.add(path)
return true
}
export function activeTreeNavigation(request: number, current: number) {
return request === current
}
export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) {
return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
}
export function nextSuggestionIndex(current: number, delta: -1 | 1, count: number) {
if (count === 0) return -1
return (current + delta + count) % count
}
export function absoluteTreePath(root: string, path: string) {
const base = trimPickerPath(root)
const relative = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
if (!relative) return base || "/"
if (!base || base === "/") return "/" + relative
if (base.endsWith("/")) return base + relative
return `${base}/${relative}`
}
export function selectedTreePath(root: string, path: string, mode: "directory" | "file", base?: string) {
const directory = path.endsWith("/")
if (mode === "file") {
if (directory) return
if (!base) return path
const absolute = absoluteTreePath(root, path)
return pickerRelativePath(base, absolute)
}
return directory ? nativePickerPath(absoluteTreePath(root, path)) : undefined
}
export function nativePickerPath(path: string) {
const value = trimPickerPath(path)
if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\")
return value
}
import { getFilename } from "@opencode-ai/core/util/path"
import fuzzysort from "fuzzysort"
import { ServerSDK } from "@/context/server-sdk"
export function cleanPickerInput(value: string) {
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
}
export function normalizePickerPath(input: string) {
const value = input.replaceAll("\\", "/")
if (value.startsWith("//") && !value.startsWith("///")) return "//" + value.slice(2).replace(/\/+/g, "/")
return value.replace(/\/+/g, "/")
}
export function normalizePickerDrive(input: string) {
const value = normalizePickerPath(input)
if (/^[A-Za-z]:$/.test(value)) return value + "/"
return value
}
export function trimPickerPath(input: string) {
const value = normalizePickerDrive(input)
if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value
return value.replace(/\/+$/, "")
}
export function joinPickerPath(base: string | undefined, relative: string) {
const root = trimPickerPath(base ?? "")
const path = trimPickerPath(relative).replace(/^\/+/, "")
if (!root) return path
if (!path) return root
if (root.endsWith("/")) return root + path
return root + "/" + path
}
export function pickerRoot(input: string) {
const value = normalizePickerDrive(input)
if (value.startsWith("//")) {
const [server, share] = value.slice(2).split("/")
if (server && share) return `//${server}/${share}`
return "//"
}
if (value.startsWith("/")) return "/"
if (/^[A-Za-z]:\//.test(value)) return value.slice(0, 3)
return ""
}
export function pickerParent(input: string) {
const value = trimPickerPath(input)
const root = pickerRoot(value)
if (value === root) return value
if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value
const index = value.lastIndexOf("/")
if (index < root.length) return root
if (index <= 0) return "/"
if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3)
return value.slice(0, index)
}
function pickerTilde(absolute: string, home: string) {
const path = trimPickerPath(absolute)
if (!home) return ""
const root = trimPickerPath(home)
if (/^[A-Za-z]:\//.test(root)) return ""
if (path === root) return "~"
if (path.startsWith(root + "/")) return "~" + path.slice(root.length)
return ""
}
export function displayPickerPath(path: string, input: string, home: string) {
const value = trimPickerPath(path)
if (/^[A-Za-z]:\//.test(trimPickerPath(home)) || /^[A-Za-z]:\//.test(value)) return value.replaceAll("/", "\\")
return pickerTilde(value, home) || value
}
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0
const scoped = (value: string) => {
const base = args.base()
if (!base) return
const raw = normalizePickerDrive(value)
if (!raw) return { directory: trimPickerPath(base), path: "" }
const home = args.home()
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
const root = pickerRoot(raw)
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
return { directory: trimPickerPath(base), path: raw }
}
const directories = async (directory: string) => {
const key = trimPickerPath(directory)
const existing = cache.get(key)
if (existing) return existing
const request = args.sdk.client.file
.list({ directory: key, path: "" })
.then((result) => result.data ?? [])
.catch(() => [])
.then((nodes) =>
nodes
.filter((node) => node.type === "directory")
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
)
cache.set(key, request)
return request
}
const match = async (directory: string, query: string, limit: number) => {
const items = await directories(directory)
if (!query) return items.slice(0, limit).map((item) => item.absolute)
return fuzzysort.go(query, items, { key: "name", limit }).map((item) => item.obj.absolute)
}
return async (filter: string) => {
const token = ++current
const active = () => token === current
const value = cleanPickerInput(filter)
const input = scoped(value)
if (!input) return [] as string[]
const raw = normalizePickerDrive(value)
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path)
if (!pathInput) {
const results = await args.sdk.client.find
.files({ directory: input.directory, query, type: "directory", limit: 50 })
.then((result) => result.data ?? [])
.catch(() => [])
if (!active()) return []
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
}
const segments = query.replace(/^\/+/, "").split("/")
const head = segments.slice(0, -1).filter((part) => part && part !== ".")
const tail = segments.at(-1) ?? ""
let paths = [input.directory]
for (const part of head) {
if (!active()) return []
if (part === "..") {
paths = paths.map(pickerParent)
continue
}
paths = Array.from(new Set((await Promise.all(paths.map((path) => match(path, part, 4)))).flat())).slice(0, 12)
if (!active() || paths.length === 0) return []
}
const matches = Array.from(new Set((await Promise.all(paths.map((path) => match(path, tail, 50)))).flat()))
if (!active()) return []
const base = raw.startsWith("~") ? trimPickerPath(input.directory) : ""
if (raw.endsWith("/") || !tail) return Array.from(new Set([base, ...matches].filter(Boolean))).slice(0, 50)
const target = matches.find((path) => getFilename(path).toLowerCase() === tail.toLowerCase())
if (!target) return matches.slice(0, 50)
const children = await match(target, "", 30)
if (!active()) return []
return Array.from(new Set([base, ...matches, ...children].filter(Boolean))).slice(0, 50)
}
}
@@ -1,7 +0,0 @@
import { ServerConnection } from "@/context/server"
import type { Platform } from "@/context/platform"
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
if (platform === "desktop" && ServerConnection.local(server)) return "native" as const
return "server" as const
}
@@ -1,21 +0,0 @@
import { describe, expect, test } from "bun:test"
import { directoryPickerKind } from "./directory-picker-policy"
const local = {
type: "sidecar",
variant: "base",
http: { url: "http://localhost:4096" },
} as const
const remote = {
type: "ssh",
host: "example.test",
http: { url: "http://localhost:4096" },
} as const
describe("directoryPickerKind", () => {
test("uses the native picker only for local desktop projects", () => {
expect(directoryPickerKind("desktop", local)).toBe("native")
expect(directoryPickerKind("desktop", remote)).toBe("server")
expect(directoryPickerKind("web", local)).toBe("server")
})
})
@@ -1,45 +0,0 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ServerConnection } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { lazy } from "solid-js"
import { DialogSelectDirectory } from "./dialog-select-directory"
import { directoryPickerKind } from "./directory-picker-policy"
const DialogSelectDirectoryV2 = lazy(() =>
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
)
type DirectoryPickerInput = {
server: ServerConnection.Any
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
}
export function useDirectoryPicker() {
const platform = usePlatform()
const settings = useSettings()
const dialog = useDialog()
return (input: DirectoryPickerInput) => {
if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect)
return
}
let selected = false
const onSelect = (result: string | string[] | null) => {
selected = result !== null
input.onSelect(result)
}
const cancel = () => {
if (!selected) input.onSelect(null)
}
if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
return
}
dialog.show(() => <DialogSelectDirectory {...input} onSelect={onSelect} />, cancel)
}
}
@@ -8,8 +8,6 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({ mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined, useNavigate: () => () => undefined,
useParams: () => ({}), useParams: () => ({}),
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
})) }))
mock.module("@/context/file", () => ({ mock.module("@/context/file", () => ({
useFile: () => ({ useFile: () => ({
@@ -1,54 +0,0 @@
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { createSignal, Show } from "solid-js"
import { createStore } from "solid-js/store"
export function HelpButton() {
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */
const [shown, setShown] = createSignal(false)
return (
<Show when={!state.dismissed}>
<div class="fixed bottom-4 right-4 z-50">
<Popover
open={shown()}
onOpenChange={setShown}
triggerAs="button"
triggerProps={{
type: "button",
"aria-label": "Help",
class:
"size-7 rounded-full bg-background-base shadow-[var(--shadow-lg-border-base)] flex items-center justify-center text-text-base hover:text-text-strong transition-colors",
}}
trigger={<span aria-hidden="true">?</span>}
class="[&_[data-slot=popover-body]]:p-0 w-[320px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
gutter={8}
placement="top-end"
>
<Show when={shown()}>
<div class="relative flex flex-col gap-1 w-[320px] p-4 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]">
<button
type="button"
aria-label="Close"
class="absolute top-3.5 right-3.5 size-6 rounded-md flex items-center justify-center text-text-base hover:text-text-strong hover:bg-surface-raised-base-hover transition-colors"
onClick={() => {
setShown(false)
setState("dismissed", true)
}}
>
<Icon name="xmark-small" />
</button>
<span class="text-14-regular text-text-strong">Lorem ipsum dolor sit amet</span>
<p class="text-12-regular text-text-weak">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
</p>
</div>
</Show>
</Popover>
</div>
</Show>
)
}
@@ -1,23 +0,0 @@
import { expect, test } from "bun:test"
import { FileTree, type FileTreeDirectoryHandle } from "@pierre/trees"
test("reports directory expansion changes", () => {
const changes: Array<{ path: string; expanded: boolean }> = []
const tree = new FileTree({
paths: ["src/"],
onExpansionChange: (change) => changes.push(change),
})
const src = tree.getItem("src/")
if (!src || !src.isDirectory()) throw new Error("Expected src to be a directory")
const directory = src as FileTreeDirectoryHandle
directory.expand()
directory.collapse()
expect(changes).toEqual([
{ path: "src/", expanded: true },
{ path: "src/", expanded: false },
])
tree.cleanUp()
})
@@ -1,117 +0,0 @@
// @ts-nocheck
import { createStore } from "solid-js/store"
import { createPromptState } from "@/context/prompt"
import { createPromptInputHistory, PromptInput } from "./prompt-input"
function PromptInputExample() {
const state = createPromptState()
const history = createPromptInputHistory()
const [controls, setControls] = createStore({
agent: "build",
variant: undefined as string | undefined,
comments: 0,
tabs: [] as string[],
activeTab: undefined as string | undefined,
reviewOpen: false,
})
const model = {
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
variant: {
list: () => ["fast", "thinking"],
current: () => controls.variant,
set: (variant?: string) => setControls("variant", variant),
},
}
const submission = {
abort() {},
handleSubmit(event: Event) {
event.preventDefault()
state.reset()
},
}
const inputControls = {
agents: {
available: [{ name: "review", hidden: false, mode: "subagent" }],
options: ["build", "review", "plan"],
get current() {
return controls.agent
},
loading: false,
visible: true,
select: (agent?: string) => setControls("agent", agent ?? "build"),
},
model: {
selection: model,
paid: true,
loading: false,
},
projects: {
available: [{ name: "Story project", worktree: "/tmp/story", sandboxes: [] }],
directory: "/tmp/story",
select() {},
add() {},
},
session: {
id: "story-session",
tabs: {
active: () => controls.activeTab,
all: () => controls.tabs,
open: (tab: string) => setControls("tabs", (tabs) => (tabs.includes(tab) ? tabs : [...tabs, tab])),
setActive: (tab: string) => setControls("activeTab", tab),
},
reviewPanel: {
opened: () => controls.reviewOpen,
open: () => setControls("reviewOpen", true),
},
},
newLayoutDesigns: true,
}
const addReviewComment = () => {
const comment = controls.comments + 1
setControls("comments", comment)
state.context.add({
type: "file",
path: "src/components/prompt-input.tsx",
selection: {
startLine: 84 + comment,
startChar: 0,
endLine: 84 + comment,
endChar: 0,
},
comment: `Review comment ${comment}`,
commentID: `review-comment-${comment}`,
commentOrigin: "review",
preview: "export const PromptInput = ...",
})
}
return (
<div class="flex flex-col gap-3">
<PromptInput controls={inputControls} state={state} history={history} submission={submission} />
<div>
<button
type="button"
class="rounded-md border border-border-weak-base bg-background-base px-2.5 py-1.5 text-12-medium text-text-base hover:bg-background-stronger"
onClick={addReviewComment}
>
Add review comment
</button>
</div>
</div>
)
}
export default {
title: "App/PromptInput",
id: "app-prompt-input",
component: PromptInput,
}
export const Basic = {
render: () => (
<div class="pt-10">
<h1 class="mb-4">Prompt Input</h1>
<PromptInputExample />
</div>
),
}
+140 -234
View File
@@ -17,8 +17,8 @@ import {
type JSX, type JSX,
} from "solid-js" } from "solid-js"
import { Popover as KobaltePopover } from "@kobalte/core/popover" import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { createStore } from "solid-js/store"
import type { 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"
import { import {
ContentPart, ContentPart,
@@ -31,7 +31,9 @@ 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"
@@ -43,15 +45,18 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover } from "@/components/dialog-select-model" import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist" 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 { 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"
import { createPromptAttachments } from "./prompt-input/attachments" import { createPromptAttachments } from "./prompt-input/attachments"
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files" import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
import { import {
canNavigateHistoryAtCursor, canNavigateHistoryAtCursor,
navigatePromptHistory, navigatePromptHistory,
@@ -67,104 +72,16 @@ import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder" import { promptPlaceholder } from "./prompt-input/placeholder"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useQueries } from "@tanstack/solid-query"
import { useQueryOptions } from "@/context/server-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" import { displayName } from "@/pages/layout/helpers"
export type PromptInputState = ReturnType<typeof usePrompt> interface PromptInputProps {
export type PromptInputHistory = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
}
export type PromptInputSubmission = {
abort: () => Promise<void> | void
handleSubmit: (event: Event) => Promise<void> | void
}
export type PromptInputControls = {
agents: {
available: { name: string; hidden?: boolean; mode: string }[]
options: string[]
current: string
loading: boolean
visible: boolean
select: (name: string | undefined) => void
}
model: {
selection: ReturnType<typeof useLocal>["model"]
paid: boolean
loading: boolean
}
projects: {
available: { name?: string; worktree: string; sandboxes?: string[] }[]
directory: string
select: (worktree: string) => void
add: (title: string) => void
}
session: {
id?: string
tabs: {
active: () => string | undefined
all: () => string[]
open: (tab: string) => void | Promise<void>
setActive: (tab: string) => void
}
reviewPanel: {
opened: () => boolean
open: () => void
}
}
newLayoutDesigns: boolean
}
export function createPromptInputHistory(): PromptInputHistory {
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
function createPromptInputHistoryStore(
normal: Store<PromptHistoryState>,
setNormal: SetStoreFunction<PromptHistoryState>,
shell: Store<PromptHistoryState>,
setShell: SetStoreFunction<PromptHistoryState>,
): PromptInputHistory {
return {
entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
add(prompt, mode, comments) {
const current = mode === "shell" ? shell : normal
const setCurrent = mode === "shell" ? setShell : setNormal
const next = prependHistoryEntry(current.entries, prompt, comments)
if (next === current.entries) return
setCurrent("entries", next)
},
}
}
function createPersistedPromptInputHistory() {
const [normal, setNormal] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<PromptHistoryState>({ entries: [] }),
)
const [shell, setShell] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<PromptHistoryState>({ entries: [] }),
)
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}
export interface PromptInputProps {
class?: string class?: string
variant?: "dock" | "new-session" variant?: "dock" | "new-session"
state?: PromptInputState
history?: PromptInputHistory
submission?: PromptInputSubmission
controls: PromptInputControls
ref?: (el: HTMLDivElement) => void ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void onNewSessionWorktreeReset?: () => void
@@ -206,18 +123,24 @@ 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 sync = useSync() const sync = useSync()
const local = useLocal()
const files = useFile() const files = useFile()
const prompt = props.state ?? 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 command = useCommand() const command = useCommand()
const permission = usePermission() const permission = usePermission()
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const tabs = () => props.controls.session.tabs const settings = useSettings()
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
@@ -275,10 +198,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}).activeFileTab }).activeFileTab
const commentInReview = (path: string) => { const commentInReview = (path: string) => {
const sessionID = props.controls.session.id const sessionID = params.id
if (!sessionID) return false if (!sessionID) return false
const diffs = sync().data.session_diff[sessionID] const diffs = sync.data.session_diff[sessionID]
if (!diffs) return false if (!diffs) return false
return diffs.some((diff) => diff.file === path) return diffs.some((diff) => diff.file === path)
} }
@@ -308,14 +231,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
if (wantsReview) { if (wantsReview) {
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("changes") layout.fileTree.setTab("changes")
tabs().setActive("review") tabs().setActive("review")
queueCommentFocus() queueCommentFocus()
return return
} }
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("all") layout.fileTree.setTab("all")
const tab = files.tab(item.path) const tab = files.tab(item.path)
void tabs().open(tab) void tabs().open(tab)
@@ -340,8 +263,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return paths return paths
}) })
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) const working = createMemo(() => sync.data.session_working(params.id ?? ""))
const imageAttachments = createMemo(() => const imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
) )
@@ -354,7 +277,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,
@@ -363,7 +285,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({ const [picker, setPicker] = createStore({
projectOpen: false, projectOpen: false,
@@ -418,14 +339,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
const hasUserPrompt = createMemo(() => { const hasUserPrompt = createMemo(() => {
const sessionID = props.controls.session.id const sessionID = params.id
if (!sessionID) return false if (!sessionID) return false
const messages = sync().data.message[sessionID] const messages = sync.data.message[sessionID]
if (!messages) return false if (!messages) return false
return messages.some((m) => m.role === "user") return messages.some((m) => m.role === "user")
}) })
const history = props.history ?? createPersistedPromptInputHistory() const [history, setHistory] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<{
entries: PromptHistoryStoredEntry[]
}>({
entries: [],
}),
)
const [shellHistory, setShellHistory] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<{
entries: PromptHistoryStoredEntry[]
}>({
entries: [],
}),
)
const suggest = createMemo(() => !hasUserPrompt()) const suggest = createMemo(() => !hasUserPrompt())
@@ -527,20 +463,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()
pickAttachmentFiles({
picker: platform.openAttachmentPickerDialog,
directory: () => sdk().directory,
fallback: () => fileInputRef?.click(),
onFile: addAttachment,
onError: (error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
})
}
const setMode = (mode: "normal" | "shell") => { const setMode = (mode: "normal" | "shell") => {
setStore("mode", mode) setStore("mode", mode)
@@ -629,8 +552,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
createEffect(() => { createEffect(() => {
props.controls.session.id params.id
if (props.controls.session.id) return if (params.id) return
if (!suggest()) return if (!suggest()) return
const interval = setInterval(() => { const interval = setInterval(() => {
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
@@ -659,10 +582,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const agentList = createMemo(() => const agentList = createMemo(() =>
props.controls.agents.available sync.data.agent
.filter((agent) => !agent.hidden && agent.mode !== "primary") .filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
) )
const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
const handleAtSelect = (option: AtOption | undefined) => { const handleAtSelect = (option: AtOption | undefined) => {
if (!option) return if (!option) return
@@ -699,7 +623,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}, },
key: atKey, key: atKey,
filterKeys: ["display"], filterKeys: ["display"],
skipFilter: (item) => item.type === "file" && !item.recent,
groupBy: (item) => { groupBy: (item) => {
if (item.type === "agent") return "agent" if (item.type === "agent") return "agent"
if (item.recent) return "recent" if (item.recent) return "recent"
@@ -728,7 +651,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type: "builtin" as const, type: "builtin" as const,
})) }))
const custom = sync().data.command.map((cmd) => ({ const custom = sync.data.command.map((cmd) => ({
id: `custom.${cmd.name}`, id: `custom.${cmd.name}`,
trigger: cmd.name, trigger: cmd.name,
title: cmd.name, title: cmd.name,
@@ -1088,7 +1011,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
history.add(prompt, mode, mode === "shell" ? [] : historyComments()) const currentHistory = mode === "shell" ? shellHistory : history
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments())
if (next === currentHistory.entries) return
setCurrentHistory("entries", next)
} }
createEffect( createEffect(
@@ -1133,7 +1060,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const navigateHistory = (direction: "up" | "down") => { const navigateHistory = (direction: "up" | "down") => {
const result = navigatePromptHistory({ const result = navigatePromptHistory({
direction, direction,
entries: history.entries(store.mode), entries: store.mode === "shell" ? shellHistory.entries : history.entries,
historyIndex: store.historyIndex, historyIndex: store.historyIndex,
currentPrompt: prompt.current(), currentPrompt: prompt.current(),
currentComments: historyComments(), currentComments: historyComments(),
@@ -1146,8 +1073,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true return true
} }
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({ const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
prompt,
editor: () => editorRef, editor: () => editorRef,
isDialogActive: () => !!dialog.active, isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type), setDraggingType: (type) => setStore("draggingType", type),
@@ -1157,7 +1083,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}, },
addPart, addPart,
readClipboardImage: platform.readClipboardImage, readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile,
}) })
const fileAttachmentInput = () => ( const fileAttachmentInput = () => (
@@ -1175,41 +1100,36 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/> />
) )
const variants = createMemo(() => ["default", ...props.controls.model.selection.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(() => props.controls.model.selection.variant.list().length > 0)
const accepting = createMemo(() => { const accepting = createMemo(() => {
const id = props.controls.session.id const id = params.id
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
return permission.isAutoAccepting(id, sdk().directory) return permission.isAutoAccepting(id, sdk.directory)
}) })
const { abort, handleSubmit } = const { abort, handleSubmit } = createPromptSubmit({
props.submission ?? info,
createPromptSubmit({ imageAttachments,
prompt, commentCount,
info, autoAccept: () => accepting(),
imageAttachments, mode: () => store.mode,
commentCount, working,
autoAccept: () => accepting(), editor: () => editorRef,
mode: () => store.mode, queueScroll,
working, promptLength,
editor: () => editorRef, addToHistory,
queueScroll, resetHistoryNavigation: () => {
promptLength, resetHistoryNavigation(true)
addToHistory, },
resetHistoryNavigation: () => { setMode: (mode) => setStore("mode", mode),
resetHistoryNavigation(true) setPopover: (popover) => setStore("popover", popover),
}, newSessionWorktree: () => props.newSessionWorktree,
setMode: (mode) => setStore("mode", mode), onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
setPopover: (popover) => setStore("popover", popover), shouldQueue: props.shouldQueue,
newSessionWorktree: () => props.newSessionWorktree, onQueue: props.onQueue,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, onAbort: props.onAbort,
shouldQueue: props.shouldQueue, onSubmit: props.onSubmit,
onQueue: props.onQueue, })
onAbort: props.onAbort,
onSubmit: props.onSubmit,
})
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
@@ -1373,9 +1293,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
const agentsLoading = () => props.controls.agents.loading const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({
queries: [
queryOptions.agents(pathKey(sdk.directory)),
queryOptions.providers(null),
queryOptions.providers(pathKey(sdk.directory)),
],
}))
const agentsLoading = () => agentsQuery.isLoading
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
const providersLoading = () => props.controls.model.loading const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
const [promptReady] = createResource( const [promptReady] = createResource(
@@ -1390,23 +1318,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const modelControlState = createMemo<ComposerModelControlState>(() => ({ const modelControlState = createMemo<ComposerModelControlState>(() => ({
loading: providersLoading(), loading: providersLoading(),
paid: props.controls.model.paid, paid: providers.paid().length > 0,
title: language.t("command.model.choose"), title: language.t("command.model.choose"),
keybind: command.keybind("model.choose"), keybind: command.keybind("model.choose"),
model: props.controls.model.selection, model: local.model,
providerID: props.controls.model.selection.current()?.provider?.id, providerID: local.model.current()?.provider?.id,
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"), modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"),
style: control(), style: control(),
onClose: restoreFocus, onClose: restoreFocus,
onUnpaidClick: () => { onUnpaidClick: () => {
void import("@/components/dialog-select-model-unpaid").then((x) => { void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={props.controls.model.selection} />) dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
}) })
}, },
})) }))
const newSession = () => props.variant === "new-session" const newSession = () => props.variant === "new-session"
const projects = createMemo(() => props.controls.projects.available) const projects = createMemo(() => layout.projects.list())
const projectForDirectory = (directory: string | undefined) => { const projectForDirectory = (directory: string | undefined) => {
if (!directory) return if (!directory) return
const key = pathKey(directory) const key = pathKey(directory)
@@ -1414,13 +1342,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
(project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key), (project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
) )
} }
const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory)) const selectedProject = createMemo(() => projectForDirectory(sdk.directory))
const projectResults = createMemo(() => { const projectResults = createMemo(() => {
const search = picker.projectSearch.trim().toLowerCase() const search = picker.projectSearch.trim().toLowerCase()
if (!search) return projects() if (!search) return projects()
return projects().filter((project) => displayName(project).toLowerCase().includes(search)) return projects().filter((project) => displayName(project).toLowerCase().includes(search))
}) })
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0) const showAgentControl = createMemo(() => settings.general.showCustomAgents() && agentNames().length > 0)
const selectProject = (worktree: string) => { const selectProject = (worktree: string) => {
setPicker({ setPicker({
projectOpen: false, projectOpen: false,
@@ -1430,11 +1358,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
restoreFocus() restoreFocus()
return return
} }
props.controls.projects.select(worktree) layout.projects.open(worktree)
restoreFocus() server.projects.touch(worktree)
navigate(`/${base64Encode(worktree)}/session`)
} }
const addProject = () => { const addProject = async () => {
props.controls.projects.add(language.t("command.project.open")) 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} />,
() => select(null),
)
})
} }
const projectPickerState = createMemo<ComposerPickerState>(() => ({ const projectPickerState = createMemo<ComposerPickerState>(() => ({
@@ -1475,11 +1418,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const agentControlState = createMemo<ComposerAgentControlState>(() => ({ const agentControlState = createMemo<ComposerAgentControlState>(() => ({
title: language.t("command.agent.cycle"), title: language.t("command.agent.cycle"),
keybind: command.keybind("agent.cycle"), keybind: command.keybind("agent.cycle"),
options: props.controls.agents.options, options: agentNames(),
current: props.controls.agents.current, current: local.agent.current()?.name ?? "",
style: control(), style: control(),
onSelect: (value) => { onSelect: (value) => {
props.controls.agents.select(value) local.agent.set(value)
restoreFocus() restoreFocus()
}, },
})) }))
@@ -1511,7 +1454,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
t={(key) => language.t(key as Parameters<typeof language.t>[0])} t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/> />
<Switch> <Switch>
<Match when={props.controls.newLayoutDesigns}> <Match when={settings.general.newLayoutDesigns()}>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<DockShellForm <DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"} data-component={newSession() ? "session-new-composer" : "session-composer"}
@@ -1626,39 +1569,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<ComposerPickerTrigger state={newProjectTriggerState()} /> <ComposerPickerTrigger state={newProjectTriggerState()} />
</Show> </Show>
<ComposerModelControl state={modelControlState()} /> <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":
!props.controls.model.selection.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={props.controls.model.selection.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onOpenChange={(open) => setStore("variantOpen", open)}
onSelect={(value) => {
props.controls.model.selection.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> </div>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}> <Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton <IconButton
@@ -1887,10 +1797,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
> >
<Select <Select
size="normal" size="normal"
options={props.controls.agents.options} options={agentNames()}
current={props.controls.agents.current} current={local.agent.current()?.name ?? ""}
onSelect={(value) => { onSelect={(value) => {
props.controls.agents.select(value) local.agent.set(value)
restoreFocus() restoreFocus()
}} }}
class="capitalize max-w-[160px] text-text-base" class="capitalize max-w-[160px] text-text-base"
@@ -1909,7 +1819,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined} style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
> >
<Show <Show
when={props.controls.model.paid} when={providers.paid().length > 0}
fallback={ fallback={
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
@@ -1926,22 +1836,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
style={control()} style={control()}
onClick={() => { onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => { void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => ( dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
<x.DialogSelectModelUnpaid model={props.controls.model.selection} />
))
}) })
}} }}
> >
<Show when={props.controls.model.selection.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon <ProviderIcon
id={props.controls.model.selection.current()?.provider?.id ?? ""} id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150" class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }} style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/> />
</Show> </Show>
<span class="truncate"> <span class="truncate">
{props.controls.model.selection.current()?.name ?? {local.model.current()?.name ?? language.t("dialog.model.select.title")}
language.t("dialog.model.select.title")}
</span> </span>
<Icon name="chevron-down" size="small" class="shrink-0" /> <Icon name="chevron-down" size="small" class="shrink-0" />
</Button> </Button>
@@ -1955,7 +1862,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
keybind={command.keybind("model.choose")} keybind={command.keybind("model.choose")}
> >
<ModelSelectorPopover <ModelSelectorPopover
model={props.controls.model.selection} model={local.model}
triggerAs={Button} triggerAs={Button}
triggerProps={{ triggerProps={{
variant: "ghost", variant: "ghost",
@@ -1966,23 +1873,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}} }}
onClose={restoreFocus} onClose={restoreFocus}
> >
<Show when={props.controls.model.selection.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon <ProviderIcon
id={props.controls.model.selection.current()?.provider?.id ?? ""} id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150" class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }} style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/> />
</Show> </Show>
<span class="truncate"> <span class="truncate">
{props.controls.model.selection.current()?.name ?? {local.model.current()?.name ?? language.t("dialog.model.select.title")}
language.t("dialog.model.select.title")}
</span> </span>
<Icon name="chevron-down" size="small" class="shrink-0" /> <Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover> </ModelSelectorPopover>
</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}
@@ -1996,10 +1902,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Select <Select
size="normal" size="normal"
options={variants()} options={variants()}
current={props.controls.model.selection.variant.current() ?? "default"} current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)} label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(value) => { onSelect={(value) => {
props.controls.model.selection.variant.set(value === "default" ? undefined : value) local.model.variant.set(value === "default" ? undefined : value)
restoreFocus() restoreFocus()
}} }}
class="capitalize max-w-[160px] text-text-base" class="capitalize max-w-[160px] text-text-base"
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { attachmentMime, pickAttachmentFiles } from "./files" import { attachmentMime } from "./files"
import { pasteMode } from "./paste" import { pasteMode } from "./paste"
describe("attachmentMime", () => { describe("attachmentMime", () => {
@@ -24,70 +24,6 @@ describe("attachmentMime", () => {
}) })
}) })
describe("pickAttachmentFiles", () => {
test("reads the current project directory for every native picker invocation", async () => {
const paths: string[] = []
const files: File[] = []
const file = new File(["hello"], "hello.txt", { type: "text/plain" })
let directory = "C:\\Projects\\LoremIpsum"
const picker = async (options?: { defaultPath?: string }, onFile?: (file: File) => Promise<unknown>) => {
paths.push(options?.defaultPath ?? "")
await onFile?.(file)
}
pickAttachmentFiles({
picker,
directory: () => directory,
fallback: () => undefined,
onFile: async (selected) => files.push(selected),
onError: () => undefined,
})
await Promise.resolve()
directory = "C:\\Projects\\DolorSit"
pickAttachmentFiles({
picker,
directory: () => directory,
fallback: () => undefined,
onFile: async (selected) => files.push(selected),
onError: () => undefined,
})
await Promise.resolve()
expect(files).toEqual([file, file])
expect(paths).toEqual(["C:\\Projects\\LoremIpsum", "C:\\Projects\\DolorSit"])
})
test("uses the browser file input when no native picker exists", async () => {
let fallback = 0
pickAttachmentFiles({
directory: () => "/projects/consectetur-adipiscing",
fallback: () => {
fallback += 1
},
onFile: async () => undefined,
onError: () => undefined,
})
expect(fallback).toBe(1)
})
test("reports native picker failures without rejecting", async () => {
const error = new Error("picker unavailable")
const errors: unknown[] = []
const handled = Promise.withResolvers<void>()
pickAttachmentFiles({
picker: async () => Promise.reject(error),
directory: () => "C:\\Projects\\LoremIpsum",
fallback: () => undefined,
onFile: async () => undefined,
onError: (cause) => {
errors.push(cause)
handled.resolve()
},
})
await handled.promise
expect(errors).toEqual([error])
})
})
describe("pasteMode", () => { describe("pasteMode", () => {
test("uses native paste for short single-line text", () => { test("uses native paste for short single-line text", () => {
expect(pasteMode("hello world")).toBe("native") expect(pasteMode("hello world")).toBe("native")
@@ -1,7 +1,7 @@
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 { type ContentPart, type ImageAttachmentPart, type usePrompt } 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"
import { getCursorPosition } from "./editor-dom" import { getCursorPosition } from "./editor-dom"
@@ -26,18 +26,16 @@ function dataUrl(file: File, mime: string) {
} }
type PromptAttachmentsInput = { type PromptAttachmentsInput = {
prompt: ReturnType<typeof usePrompt>
editor: () => HTMLDivElement | undefined editor: () => HTMLDivElement | undefined
isDialogActive: () => boolean isDialogActive: () => boolean
setDraggingType: (type: "image" | "@mention" | null) => void setDraggingType: (type: "image" | "@mention" | null) => void
focusEditor: () => void focusEditor: () => void
addPart: (part: ContentPart) => boolean addPart: (part: ContentPart) => boolean
readClipboardImage?: () => Promise<File | null> readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
} }
export function createPromptAttachments(input: PromptAttachmentsInput) { export function createPromptAttachments(input: PromptAttachmentsInput) {
const prompt = input.prompt const prompt = usePrompt()
const language = useLanguage() const language = useLanguage()
const warn = () => { const warn = () => {
@@ -64,7 +62,6 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
type: "image", type: "image",
id: uuid(), id: uuid(),
filename: file.name, filename: file.name,
sourcePath: input.getPathForFile?.(file) || undefined,
mime, mime,
dataUrl: url, dataUrl: url,
} }
@@ -75,31 +75,6 @@ describe("buildRequestParts", () => {
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"]) expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
}) })
test("preserves an external attachment source path for the model", () => {
const result = buildRequestParts({
prompt: [],
context: [],
images: [
{
type: "image",
id: "img_external",
filename: "opencode.global.dat",
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
mime: "text/plain",
dataUrl: "data:text/plain;base64,AAA",
},
],
text: "inspect this",
messageID: "msg_external",
sessionID: "ses_external",
sessionDirectory: "C:\\Repos\\sst\\opencode",
})
expect(result.requestParts.find((part) => part.type === "file")?.filename).toBe(
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
)
})
test("deduplicates context files when prompt already includes same path", () => { test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }] const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
@@ -188,7 +188,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
type: "file", type: "file",
mime: attachment.mime, mime: attachment.mime,
url: attachment.dataUrl, url: attachment.dataUrl,
filename: attachment.sourcePath ?? attachment.filename, filename: attachment.filename,
} satisfies PromptRequestPart } satisfies PromptRequestPart
}) })
@@ -50,7 +50,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
> >
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" /> <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-[12px] min-w-0 font-medium leading-5"> <div class="flex items-center text-11-regular min-w-0 font-medium">
<span class="text-text-strong whitespace-nowrap">{label}</span> <span class="text-text-strong whitespace-nowrap">{label}</span>
<Show when={item.selection}> <Show when={item.selection}>
{(sel) => ( {(sel) => (
@@ -2,38 +2,6 @@ import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-pick
export { ACCEPTED_FILE_TYPES } export { ACCEPTED_FILE_TYPES }
type AttachmentPicker = (
options: {
defaultPath?: string
multiple?: boolean
accept?: string[]
},
onFile: (file: File) => Promise<unknown>,
) => Promise<void>
export function pickAttachmentFiles(input: {
picker?: AttachmentPicker
directory: () => string
fallback: () => void
onFile: (file: File) => Promise<unknown>
onError: (error: unknown) => void
}) {
if (!input.picker) {
input.fallback()
return
}
void input
.picker(
{
defaultPath: input.directory(),
multiple: true,
accept: ACCEPTED_FILE_TYPES,
},
input.onFile,
)
.catch(input.onError)
}
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES) const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
const IMAGE_EXTS = new Map([ const IMAGE_EXTS = new Map([
["gif", "image/gif"], ["gif", "image/gif"],
@@ -26,22 +26,6 @@ let selected = "/repo/worktree-a"
let variant: string | undefined let variant: string | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const prompt = {
ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }),
current: () => promptValue,
cursor: () => 0,
dirty: () => true,
reset: () => undefined,
set: () => undefined,
context: {
add: () => undefined,
remove: () => undefined,
removeComment: () => undefined,
updateComment: () => undefined,
replaceComments: () => undefined,
items: () => [],
},
}
const clientFor = (directory: string) => { const clientFor = (directory: string) => {
createdClients.push(directory) createdClients.push(directory)
@@ -77,8 +61,6 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({ mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined, useNavigate: () => () => undefined,
useParams: () => params, useParams: () => params,
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
})) }))
mock.module("@opencode-ai/sdk/v2/client", () => ({ mock.module("@opencode-ai/sdk/v2/client", () => ({
@@ -89,7 +71,6 @@ beforeAll(async () => {
})) }))
mock.module("@opencode-ai/ui/toast", () => ({ mock.module("@opencode-ai/ui/toast", () => ({
Toast: { Region: () => null },
showToast: () => 0, showToast: () => 0,
})) }))
@@ -122,18 +103,17 @@ beforeAll(async () => {
}), }),
})) }))
mock.module("@/context/server", () => ({
useServer: () => ({ key: "server-key" }),
}))
mock.module("@/context/tabs", () => ({
useTabs: () => ({
promoteDraft: () => undefined,
}),
}))
mock.module("@/context/prompt", () => ({ mock.module("@/context/prompt", () => ({
usePrompt: () => prompt, usePrompt: () => ({
current: () => promptValue,
reset: () => undefined,
set: () => undefined,
context: {
add: () => undefined,
remove: () => undefined,
items: () => [],
},
}),
})) }))
mock.module("@/context/layout", () => ({ mock.module("@/context/layout", () => ({
@@ -147,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",
@@ -155,12 +134,12 @@ beforeAll(async () => {
return clientFor(opts.directory) return clientFor(opts.directory)
}, },
} }
return () => sdk return sdk
}, },
})) }))
mock.module("@/context/sync", () => ({ mock.module("@/context/sync", () => ({
useSync: () => () => ({ useSync: () => ({
data: { command: [] }, data: { command: [] },
session: { session: {
optimistic: { optimistic: {
@@ -184,7 +163,7 @@ beforeAll(async () => {
})) }))
mock.module("@/context/server-sync", () => ({ mock.module("@/context/server-sync", () => ({
useServerSync: () => () => ({ useServerSync: () => ({
child: (directory: string) => { child: (directory: string) => {
syncedDirectories.push(directory) syncedDirectories.push(directory)
storedSessions[directory] ??= [] storedSessions[directory] ??= []
@@ -240,7 +219,6 @@ beforeEach(() => {
describe("prompt submit worktree selection", () => { describe("prompt submit worktree selection", () => {
test("reads the latest worktree accessor value per submit", async () => { test("reads the latest worktree accessor value per submit", async () => {
const submit = createPromptSubmit({ const submit = createPromptSubmit({
prompt,
info: () => undefined, info: () => undefined,
imageAttachments: () => [], imageAttachments: () => [],
commentCount: () => 0, commentCount: () => 0,
@@ -278,7 +256,6 @@ describe("prompt submit worktree selection", () => {
test("applies auto-accept to newly created sessions", async () => { test("applies auto-accept to newly created sessions", async () => {
const submit = createPromptSubmit({ const submit = createPromptSubmit({
prompt,
info: () => undefined, info: () => undefined,
imageAttachments: () => [], imageAttachments: () => [],
commentCount: () => 0, commentCount: () => 0,
@@ -309,7 +286,6 @@ describe("prompt submit worktree selection", () => {
variant = "high" variant = "high"
const submit = createPromptSubmit({ const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }), info: () => ({ id: "session-1" }),
imageAttachments: () => [], imageAttachments: () => [],
commentCount: () => 0, commentCount: () => 0,
@@ -341,7 +317,6 @@ describe("prompt submit worktree selection", () => {
test("seeds new sessions before optimistic prompts are added", async () => { test("seeds new sessions before optimistic prompts are added", async () => {
const submit = createPromptSubmit({ const submit = createPromptSubmit({
prompt,
info: () => undefined, info: () => undefined,
imageAttachments: () => [], imageAttachments: () => [],
commentCount: () => 0, commentCount: () => 0,
@@ -1,26 +1,23 @@
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, useSearchParams } 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 { useServer } from "@/context/server" import { useServerSync } from "@/context/server-sync"
import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-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"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt" import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK, type DirectorySDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync, type DirectorySync } from "@/context/sync" import { useSync } from "@/context/sync"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree" 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
@@ -40,9 +37,9 @@ export type FollowupDraft = {
} }
type FollowupSendInput = { type FollowupSendInput = {
client: DirectorySDK["client"] client: ReturnType<typeof useSDK>["client"]
serverSync: ServerSync serverSync: ReturnType<typeof useServerSync>
sync: DirectorySync sync: ReturnType<typeof useSync>
draft: FollowupDraft draft: FollowupDraft
messageID?: string messageID?: string
optimisticBusy?: boolean optimisticBusy?: boolean
@@ -174,7 +171,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
} }
type PromptSubmitInput = { type PromptSubmitInput = {
prompt: ReturnType<typeof usePrompt>
info: Accessor<{ id: string } | undefined> info: Accessor<{ id: string } | undefined>
imageAttachments: Accessor<ImageAttachmentPart[]> imageAttachments: Accessor<ImageAttachmentPart[]>
commentCount: Accessor<number> commentCount: Accessor<number>
@@ -212,14 +208,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const serverSync = useServerSync() const serverSync = useServerSync()
const local = useLocal() const local = useLocal()
const permission = usePermission() const permission = usePermission()
const prompt = input.prompt const prompt = usePrompt()
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const params = useParams() const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>()
const server = useServer()
const tabs = useTabs()
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
const errorMessage = (err: unknown) => { const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) { if (err && typeof err === "object" && "data" in err) {
@@ -234,22 +226,21 @@ 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, []) serverSync.todo.set(sessionID, [])
const [, setStore] = serverSync().child(sdk().directory) const [, setStore] = serverSync.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() return sdk.client.session
.client.session.abort({ .abort({
sessionID, sessionID,
}) })
.catch(() => {}) .catch(() => {})
@@ -282,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] = serverSync.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]
@@ -322,13 +313,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.addToHistory(currentPrompt, mode) input.addToHistory(currentPrompt, mode)
input.resetHistoryNavigation() input.resetHistoryNavigation()
const projectDirectory = sdk().directory const projectDirectory = sdk.directory
const isNewSession = !params.id const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept() const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main" const worktreeSelection = input.newSessionWorktree?.() || "main"
let sessionDirectory = projectDirectory let sessionDirectory = projectDirectory
let client = sdk().client let client = sdk.client
if (isNewSession) { if (isNewSession) {
if (worktreeSelection === "create") { if (worktreeSelection === "create") {
@@ -350,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
} }
@@ -359,11 +350,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
if (sessionDirectory !== projectDirectory) { if (sessionDirectory !== projectDirectory) {
client = sdk().createClient({ client = sdk.createClient({
directory: sessionDirectory, directory: sessionDirectory,
throwOnError: true, throwOnError: true,
}) })
serverSync().child(sessionDirectory) serverSync.child(sessionDirectory)
} }
input.onNewSessionWorktreeReset?.() input.onNewSessionWorktreeReset?.()
@@ -387,14 +378,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id) local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
if (draftID)
tabs.promoteDraft(draftID, {
server: server.key,
dirBase64: base64Encode(sessionDirectory),
sessionId: session.id,
})
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
} }
} }
if (!session) { if (!session) {
@@ -471,7 +455,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (text.startsWith("/")) { if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ") const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1) const commandName = cmdName.slice(1)
const customCommand = sync().data.command.find((c) => c.name === commandName) const customCommand = sync.data.command.find((c) => c.name === commandName)
if (customCommand) { if (customCommand) {
clearInput() clearInput()
client.session client.session
@@ -505,7 +489,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const messageID = Identifier.ascending("message") const messageID = Identifier.ascending("message")
const removeOptimisticMessage = () => { const removeOptimisticMessage = () => {
sync().session.optimistic.remove({ sync.session.optimistic.remove({
directory: sessionDirectory, directory: sessionDirectory,
sessionID: session.id, sessionID: session.id,
messageID, messageID,
@@ -516,24 +500,24 @@ 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) {
sync().set("session_status", session.id, { type: "busy" }) sync.set("session_status", session.id, { type: "busy" })
} }
const controller = new AbortController() const controller = new AbortController()
const cleanup = () => { const cleanup = () => {
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
sync().set("session_status", session.id, { type: "idle" }) sync.set("session_status", session.id, { type: "idle" })
} }
removeOptimisticMessage() removeOptimisticMessage()
restoreCommentItems(commentItems) restoreCommentItems(commentItems)
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) {
@@ -560,15 +544,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}, timeoutMs) }, timeoutMs)
}) })
const result = await Promise.race([ const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => {
WorktreeState.wait(sdk().scope, sessionDirectory),
abortWait,
timeout,
]).finally(() => {
if (timer.id === undefined) return if (timer.id === undefined) return
clearTimeout(timer.id) clearTimeout(timer.id)
}) })
pending.delete(pendingKey(session.id)) pending.delete(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
@@ -576,16 +556,16 @@ export function createPromptSubmit(input: PromptSubmitInput) {
void sendFollowupDraft({ void sendFollowupDraft({
client, client,
sync: sync(), sync,
serverSync: serverSync(), serverSync,
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" })
} }
showToast({ showToast({
title: language.t("prompt.toast.promptSendFailed.title"), title: language.t("prompt.toast.promptSendFailed.title"),
@@ -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>
)
}
@@ -42,7 +42,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
pathFromTab: file.pathFromTab, pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}) })
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : [])) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const usd = createMemo( const usd = createMemo(
() => () =>
@@ -96,13 +96,13 @@ export function SessionContextTab() {
const providers = useProviders() const providers = useProviders()
const { params, view } = useSessionLayout() const { params, view } = useSessionLayout()
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const messages = createMemo( const messages = createMemo(
() => { () => {
const id = params.id const id = params.id
if (!id) return emptyMessages if (!id) return emptyMessages
return (sync().data.message[id] ?? []) as Message[] return (sync.data.message[id] ?? []) as Message[]
}, },
emptyMessages, emptyMessages,
{ equals: same }, { equals: same },
@@ -180,7 +180,7 @@ export function SessionContextTab() {
if (!c?.input) return [] if (!c?.input) return []
return estimateSessionContextBreakdown({ return estimateSessionContextBreakdown({
messages: messages(), messages: messages(),
parts: sync().data.part as Record<string, Part[] | undefined>, parts: sync.data.part as Record<string, Part[] | undefined>,
input: c.input, input: c.input,
systemPrompt: systemPrompt(), systemPrompt: systemPrompt(),
}) })
@@ -221,7 +221,7 @@ export function SessionContextTab() {
let scroll: HTMLDivElement | undefined let scroll: HTMLDivElement | undefined
let frame: number | undefined let frame: number | undefined
let pending: { x: number; y: number } | undefined let pending: { x: number; y: number } | undefined
const getParts = (id: string) => (sync().data.part[id] ?? []) as Part[] const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[]
const restoreScroll = () => { const restoreScroll = () => {
const el = scroll const el = scroll
@@ -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"
@@ -25,8 +25,8 @@ 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, StatusPopoverV2 } from "../status-popover"
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 { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
const OPEN_APPS = [ const OPEN_APPS = [
"vscode", "vscode",
@@ -155,9 +155,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 isV2 = settings.general.newLayoutDesigns const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns())
const search = settings.visibility.search const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true))
const status = settings.visibility.status const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true))
const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true))
const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true, finder: true,
@@ -229,7 +231,7 @@ export function SessionHeader() {
) )
const opening = createMemo(() => openRequest.app !== undefined) const opening = createMemo(() => openRequest.app !== undefined)
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>(() => ({ const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
statusVisible: status(), statusVisible: status(),
@@ -320,7 +322,7 @@ export function SessionHeader() {
{(mount) => ( {(mount) => (
<Portal mount={mount()}> <Portal mount={mount()}>
<Show <Show
when={isV2} when={isDesktopV2}
fallback={ fallback={
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Show when={projectDirectory()}> <Show when={projectDirectory()}>
@@ -442,21 +444,23 @@ export function SessionHeader() {
<StatusPopover /> <StatusPopover />
</Tooltip> </Tooltip>
</Show> </Show>
<TooltipKeybind <Show when={term()}>
title={language.t("command.terminal.toggle")} <TooltipKeybind
keybind={command.keybind("terminal.toggle")} title={language.t("command.terminal.toggle")}
> keybind={command.keybind("terminal.toggle")}
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
> >
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} /> <Button
</Button> variant="ghost"
</TooltipKeybind> class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
</Show>
<div class="hidden md:flex items-center gap-1 shrink-0"> <div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind <TooltipKeybind
@@ -475,30 +479,32 @@ export function SessionHeader() {
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
<TooltipKeybind <Show when={tree()}>
title={language.t("command.fileTree.toggle")} <TooltipKeybind
keybind={command.keybind("fileTree.toggle")} title={language.t("command.fileTree.toggle")}
> keybind={command.keybind("fileTree.toggle")}
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
> >
<div class="relative flex items-center justify-center size-4"> <Button
<Icon variant="ghost"
size="small" class="titlebar-icon w-8 h-6 p-0 box-border"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"} onClick={() => layout.fileTree.toggle()}
classList={{ aria-label={language.t("command.fileTree.toggle")}
"text-icon-strong": layout.fileTree.opened(), aria-expanded={layout.fileTree.opened()}
"text-icon-weak": !layout.fileTree.opened(), aria-controls="file-tree-panel"
}} >
/> <div class="relative flex items-center justify-center size-4">
</div> <Icon
</Button> size="small"
</TooltipKeybind> name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</Show>
</div> </div>
</div> </div>
</div> </div>
@@ -524,7 +530,7 @@ type SessionHeaderV2ActionsState = {
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) { function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
return ( return (
<div class="flex items-center gap-2"> <div class="flex items-center gap-0">
<Show when={props.state.statusVisible}> <Show when={props.state.statusVisible}>
<Tooltip placement="bottom" value={props.state.statusLabel}> <Tooltip placement="bottom" value={props.state.statusLabel}>
<StatusPopoverV2 /> <StatusPopoverV2 />
@@ -1,12 +1,11 @@
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2" import { WordmarkV2 } from "@opencode-ai/ui/v2/components/wordmark-v2.jsx"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
export function NewSessionDesignView(props: { children: JSX.Element }) { export function NewSessionDesignView(props: { children: JSX.Element }) {
return ( return (
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep "> <div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6"> <div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}> <div class="w-full max-w-[720px]">
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" /> <WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
<div class="mt-8">{props.children}</div> <div class="mt-8">{props.children}</div>
</div> </div>
@@ -20,24 +20,24 @@ export function NewSessionView(props: NewSessionViewProps) {
const sdk = useSDK() const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const sandboxes = createMemo(() => sync().project?.sandboxes ?? []) const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE]) const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
const current = createMemo(() => { const current = createMemo(() => {
const selection = props.worktree const selection = props.worktree
if (options().includes(selection)) return selection if (options().includes(selection)) return selection
return MAIN_WORKTREE return MAIN_WORKTREE
}) })
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
const isWorktree = createMemo(() => { const isWorktree = createMemo(() => {
const project = sync().project const project = sync.project
if (!project) return false if (!project) return false
return sdk().directory !== project.worktree return sdk.directory !== project.worktree
}) })
const label = (value: string) => { const label = (value: string) => {
if (value === MAIN_WORKTREE) { if (value === MAIN_WORKTREE) {
if (isWorktree()) return language.t("session.new.worktree.main") if (isWorktree()) return language.t("session.new.worktree.main")
const branch = sync().data.vcs?.branch const branch = sync.data.vcs?.branch
if (branch) return language.t("session.new.worktree.mainWithBranch", { branch }) if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
return language.t("session.new.worktree.main") return language.t("session.new.worktree.main")
} }
@@ -69,7 +69,7 @@ export function NewSessionView(props: NewSessionViewProps) {
{label(current())} {label(current())}
</div> </div>
</div> </div>
<Show when={sync().project}> <Show when={sync.project}>
{(project) => ( {(project) => (
<div class="flex items-start justify-center gap-3 min-h-5"> <div class="flex items-start justify-center gap-3 min-h-5">
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center"> <div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
@@ -1,4 +1,5 @@
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js" import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
@@ -6,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 { 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 { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "./updater-action"
import { import {
monoDefault, monoDefault,
monoFontFamily, monoFontFamily,
@@ -86,11 +86,12 @@ 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()
const updater = useUpdaterAction() const [store, setStore] = createStore({
checking: false,
})
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir)) const dir = createMemo(() => decode64(params.dir))
@@ -120,15 +121,67 @@ export const SettingsGeneral: Component = () => {
} }
const desktop = createMemo(() => platform.platform === "desktop") 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 themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync() const serverSync = useServerSync()
const serverSdk = useServerSDK() const globalSdk = useServerSDK()
const [shells] = createResource( const [shells] = createResource(
() => () =>
serverSdk() globalSdk.client.pty
.client.pty.shells() .shells()
.then((res) => res.data ?? []) .then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]), .catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] }, { initialValue: [] as ShellOption[] },
@@ -151,11 +204,11 @@ export const SettingsGeneral: Component = () => {
}) })
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(() => serverSync.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 = serverSync.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) {
@@ -290,7 +343,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 }) serverSync.updateConfig({ shell: option.value })
}} }}
variant="secondary" variant="secondary"
size="small" size="small"
@@ -354,13 +407,7 @@ export const SettingsGeneral: Component = () => {
<div data-action="settings-new-layout-designs"> <div data-action="settings-new-layout-designs">
<Switch <Switch
checked={settings.general.newLayoutDesigns()} checked={settings.general.newLayoutDesigns()}
onChange={(checked) => { onChange={(checked) => settings.general.setNewLayoutDesigns(checked)}
settings.general.setNewLayoutDesigns(checked)
if (!checked) return
void import("@/components/settings-v2").then((module) => {
dialog.show(() => <module.DialogSettings />)
})
}}
/> />
</div> </div>
</SettingsRow> </SettingsRow>
@@ -668,6 +715,19 @@ export const SettingsGeneral: Component = () => {
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3> <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
<SettingsList> <SettingsList>
<SettingsRow
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
>
<div data-action="settings-updates-startup">
<Switch
checked={settings.updates.startup()}
disabled={!platform.checkUpdate}
onChange={(checked) => settings.updates.setStartup(checked)}
/>
</div>
</SettingsRow>
<SettingsRow <SettingsRow
title={language.t("settings.general.row.releaseNotes.title")} title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")} description={language.t("settings.general.row.releaseNotes.description")}
@@ -684,8 +744,10 @@ export const SettingsGeneral: Component = () => {
title={language.t("settings.updates.row.check.title")} title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")} description={language.t("settings.updates.row.check.description")}
> >
<Button size="small" variant="secondary" disabled={!updater.action().run} onClick={updater.run}> <Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}>
{language.t(updater.action().label)} {store.checking
? language.t("settings.updates.action.checking")
: language.t("settings.updates.action.checkNow")}
</Button> </Button>
</SettingsRow> </SettingsRow>
</SettingsList> </SettingsList>
+69 -174
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,178 +371,85 @@ 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 <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
when={props.v2} <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
fallback={ <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<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 items-center justify-between gap-4">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> <h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]"> <Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</Button>
</div>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
variant="ghost"
type="text"
value={store.filter}
onChange={(v) => setStore("filter", v)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="flex-1"
/>
<Show when={store.filter}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
</Show>
</div>
</div>
</div>
{groups}
</div>
}
>
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<ButtonV2 variant="ghost" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")} {language.t("settings.shortcuts.reset.button")}
</ButtonV2> </Button>
</div> </div>
<div class="settings-v2-tab-search">
<TextInputV2 <div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
type="search" <Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
appearance="base" <TextField
variant="ghost"
type="text"
value={store.filter} value={store.filter}
onInput={(event) => setStore("filter", event.currentTarget.value)} onChange={(v) => setStore("filter", v)}
placeholder={language.t("settings.shortcuts.search.placeholder")} placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false} spellcheck={false}
autocorrect="off" autocorrect="off"
autocomplete="off" autocomplete="off"
autocapitalize="off" autocapitalize="off"
aria-label={language.t("settings.shortcuts.search.placeholder")} class="flex-1"
/> />
<Show when={store.filter}> <Show when={store.filter}>
<IconButtonV2 <IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
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> </Show>
</div> </div>
</div> </div>
<div class="settings-v2-tab-body">{groups}</div> </div>
</>
</Show> <div class="flex flex-col gap-8 max-w-[720px]">
<For each={GROUPS}>
{(group) => (
<Show when={(filtered().get(group) ?? []).length > 0}>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t(groupKey[group])}</h3>
<SettingsList>
<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 class="text-14-regular text-text-strong">{title(id)}</span>
<button
type="button"
data-keybind-id={id}
classList={{
"h-8 px-3 rounded-md text-12-regular": true,
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
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>
</div>
</div>
) )
} }
@@ -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,7 +2,7 @@ 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"
@@ -12,7 +12,6 @@ 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,14 +28,6 @@ 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 serverSDK = useServerSDK()
@@ -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 = serverSync.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 = serverSync.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) serverSync.set("config", "disabled_providers", next)
await serverSync() await serverSync
.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) serverSync.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,16 +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() await serverSDK.client.auth.remove({ providerID }).catch(() => undefined)
.client.auth.remove({ providerID })
.catch(() => undefined)
await disableProvider(providerID, name) await disableProvider(providerID, name)
return return
} }
await serverSDK() await serverSDK.client.auth
.client.auth.remove({ providerID }) .remove({ providerID })
.then(async () => { .then(async () => {
await serverSDK().client.global.dispose() await serverSDK.client.global.dispose()
showToast({ showToast({
variant: "success", variant: "success",
icon: "circle-check", icon: "circle-check",
@@ -140,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>

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