Compare commits

..

34 Commits

Author SHA1 Message Date
Kit Langton 729eafc2e7 test: isolate flaky service and tab checks 2026-08-18 13:17:57 +00:00
Shoubhit Dash 47af7462ea feat(cli): improve session export flow (#43229) 2026-08-18 18:00:12 +05:30
Simon Klee a76ab93673 tui: update OpenTUI to 0.5.4 (#43220) 2026-08-18 13:56:34 +02:00
Shoubhit Dash dfb60e37a3 feat(cli): add interactive auth commands (#43127) 2026-08-18 16:37:56 +05:30
Brendan Allan 5105913568 perf(app): keep home session index query-local (#43191) 2026-08-18 16:16:28 +08:00
Brendan Allan 28b4cade9e fix(client): remove order from message list query (#43204) 2026-08-18 16:15:53 +08:00
Luke Parker 1f61eb5ca9 refactor(frontend): enforce package boundaries (#43177) 2026-08-18 15:49:23 +10:00
opencode-agent[bot] ea1ff90e42 perf(fs): use augmented rope for text pagination (#42972)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-18 15:38:49 +10:00
Aiden Cline eb3c4c82fe fix(ai): support newer Anthropic system updates (#43180) 2026-08-18 00:22:19 -05:00
Aiden Cline 7188d42b99 feat(core): resume subagent sessions (#43172) 2026-08-17 23:43:13 -05:00
Aiden Cline 9391987443 fix(ai): settle pending Anthropic tool calls (#43136) 2026-08-17 22:58:01 -05:00
opencode-agent[bot] 4d79337d42 chore: remove root vitest dependency (#43167)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-18 13:47:24 +10:00
opencode-agent[bot] 401a154ec3 fix(app): keep server details editable (#43170)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-18 13:32:43 +10:00
Luke Parker 18c25ae406 refactor(app): use one server event model (#43168) 2026-08-18 13:28:47 +10:00
opencode-agent[bot] cd156f9f1c test(tui): update web search label expectation (#43166)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-18 03:22:35 +00:00
Dax Raad b968a314d3 fix(tui): hide empty tab bar 2026-08-17 23:15:09 -04:00
Luke Parker 93cdfafa78 refactor(desktop): extract renderer platform (#43164) 2026-08-18 13:12:39 +10:00
Dax Raad b6a8d99da5 fix(cli): align automatic update methods 2026-08-17 23:09:49 -04:00
Dax Raad 8cacf150db chore: skip node cli publish build 2026-08-17 22:49:48 -04:00
Luke Parker 76e7b556b6 refactor(desktop): modularize main process (#43159) 2026-08-18 12:23:05 +10:00
Luke Parker a888ba4da7 refactor(app): use current session messages (#42766) 2026-08-18 12:17:39 +10:00
opencode-agent[bot] a1eca087d4 fix(tui): streamline generic tool rendering (#43158)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-17 21:04:07 -05:00
Aiden Cline 5252cefab2 fix(core): exclude native code mode MCP servers (#43157) 2026-08-17 20:54:25 -05:00
opencode-agent[bot] 839343e5de fix(cli): skip missing node distribution (#43156)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-08-17 21:42:34 -04:00
opencode-agent[bot] 30d778cf5f fix(tui): clarify web search provider labels (#43155)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-17 20:41:14 -05:00
Luke Parker 616aba7bbd refactor(desktop): establish typed ipc contract (#43150) 2026-08-18 11:31:05 +10:00
Aiden Cline 006a0a4b34 feat(plugin): expose MCP server transforms (#43125) 2026-08-17 20:17:48 -05:00
Kit Langton 3facbe1dd3 fix(core): support older previous-channel databases (#43142) 2026-08-18 00:45:36 +00:00
opencode-agent[bot] 1430a460f1 fix(ui): smooth shimmer loop (#43144)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-18 10:40:50 +10:00
Dax Raad 1f96af5ea7 docs: document heap snapshot debugging 2026-08-17 20:37:53 -04:00
Dax Raad 15f0fc9e00 chore: disable generate workflow 2026-08-17 20:26:57 -04:00
opencode-agent[bot] cd3133038b chore: generate 2026-08-18 00:19:50 +00:00
Brendan Allan e55a91eadd refactor(app): use shared server data (#43017) 2026-08-18 08:18:33 +08:00
Dax 2f8fa6afef fix(core): serialize MCP token refresh (#43074) 2026-08-17 19:57:27 -04:00
382 changed files with 9008 additions and 15436 deletions
-52
View File
@@ -1,52 +0,0 @@
name: generate
on:
push:
branches:
- dev
- v2
jobs:
generate:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Generate
run: ./script/generate.ts
- name: Commit and push
run: |
if [ -z "$(git status --porcelain)" ]; then
echo "No changes to commit"
exit 0
fi
git add -A
git commit -m "chore: generate" --allow-empty
git push origin HEAD:${{ github.ref_name }} --no-verify
# if ! git push origin HEAD:${{ github.event.pull_request.head.ref || github.ref_name }} --no-verify; then
# echo ""
# echo "============================================"
# echo "Failed to push generated code."
# echo "Please run locally and push:"
# echo ""
# echo " ./script/generate.ts"
# echo " git add -A && git commit -m \"chore: generate\" && git push"
# echo ""
# echo "============================================"
# exit 1
# fi
+2 -1
View File
@@ -196,7 +196,7 @@ jobs:
build-node-cli: build-node-cli:
needs: version needs: version
if: github.repository == 'anomalyco/opencode' if: github.repository == 'anomalyco/opencode' && false # Temporarily disabled
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -594,6 +594,7 @@ jobs:
path: packages/cli/dist path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: needs.build-node-cli.result == 'success'
with: with:
pattern: opencode-node-cli-* pattern: opencode-node-cli-*
path: packages/cli/dist/node path: packages/cli/dist/node
+4
View File
@@ -80,6 +80,8 @@ jobs:
if: always() if: always()
timeout-minutes: 10 timeout-minutes: 10
working-directory: packages/cli working-directory: packages/cli
env:
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
run: | run: |
bun run script/build.ts --single --skip-install bun run script/build.ts --single --skip-install
bun run script/service-smoke.ts bun run script/service-smoke.ts
@@ -94,6 +96,8 @@ jobs:
if: always() if: always()
timeout-minutes: 15 timeout-minutes: 15
working-directory: packages/cli working-directory: packages/cli
env:
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
run: | run: |
bun run script/build-node.ts --single --skip-install --outdir=dist/node bun run script/build-node.ts --single --skip-install --outdir=dist/node
bun run script/service-smoke.ts --node bun run script/service-smoke.ts --node
+204
View File
@@ -0,0 +1,204 @@
---
name: opencode-dev
description: Use when interactively running, debugging, or verifying opencode's own V2 CLI/TUI or server during development in this repo — starting the dev TUI, driving it with termctrl, comparing V2 against the legacy TUI, hitting the V2 server/API directly, reading log files, or attaching Bun's inspector.
---
# Debugging opencode itself
Workflow for interactively exercising the V2 CLI/TUI and server while developing in this repo. All commands below run from `packages/cli` unless noted otherwise.
## Server/client model
opencode V2 is a client/server system, not a single monolithic process:
- **Server process** runs the Effect HTTP API (`packages/server`) and owns all domain state: sessions, database, plugins, permissions, Location services. It's started by the `serve` command (`packages/cli/src/commands/handlers/serve.ts`).
- **TUI process** is a separate process that runs no application logic itself — it's an HTTP/SSE client of the server via the generated SDK (`createOpencodeClient` / `sdk.client.v2`).
- **Discovery**: CLI processes find the shared server through a JSON registration file at `~/.local/state/opencode/service.json` (or `service-local.json` for the local/dev channel) containing `{id, version, url, pid}`. A separate password file under `~/.config/opencode/service.json` provides HTTP Basic auth. Before reusing a registration, the client calls `GET /health` to confirm the server is alive, authenticated, and version-compatible.
- **Sharing**: because of this registration/health-check dance, many concurrent `opencode`/TUI invocations converge on one shared background daemon rather than each spawning their own. If no compatible healthy daemon is found, a new one is spawned detached (`serve --service`) and registers itself.
- **`bun dev service start|status|stop|restart`** manages this shared background daemon's lifecycle directly — useful when you need to force a fresh server, confirm one is running, or kill a stuck one.
- **Standalone mode** (`--standalone`) opts a single invocation out of the shared daemon: it spawns a private one-off `serve --stdio --port 0` child tied to that invocation's lifetime, with its own random password. Use this to isolate a debugging session from your other running opencode sessions.
- Every log line is tagged `role=server` or `role=cli` and a per-process `run=<id>`, so you can distinguish server-side and client-side activity in one shared log file (see "Logs" below) even when both roles are interleaved from concurrent processes.
## Starting the dev TUI
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server.
## Interactive debugging with termctrl
- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots.
- Use a dedicated session name and do not reuse or kill an unrelated session.
```bash
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
termctrl wait opencode-v2-dev "Ask anything" --timeout 20000
termctrl show opencode-v2-dev
```
- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`.
- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input.
```bash
termctrl send opencode-v2-dev 'text:example prompt' enter
termctrl send opencode-v2-dev ctrl-c
```
- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits.
- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed.
```bash
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png
```
- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again:
```bash
termctrl resize opencode-v2-dev --cols 100 --rows 30
termctrl show opencode-v2-dev
```
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
- Always clean up the Terminal Control session when the check is complete:
```bash
termctrl stop opencode-v2-dev
```
## Comparing V2 against the legacy TUI
Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states:
```bash
# From packages/cli: local V2 TUI
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
# Released legacy TUI behavior reference
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png
termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
```
- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints.
- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`.
## Server/API debugging
- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI.
- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering.
- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path:
```bash
bun dev api get /health
bun dev api get /openapi.json
bun dev api <operationId> --param key=value
```
- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`.
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
## Auditing installed `opencode2` sessions
Installed next-channel sessions normally use `~/.local/share/opencode/opencode-next.db` and `~/.local/share/opencode/log/opencode.log`; `OPENCODE_DB` can override the database. Before calling `opencode2 api`, inspect `~/.local/state/opencode/service.json` because the command may start a daemon when none is healthy.
For a supplied `ses_...` ID, compare three sources:
- `opencode2 api get /api/session/active` and the Session/message endpoints for live server state.
- The database's ordered `event` rows for durable history.
- `packages/tui/src/context/data.tsx` and the relevant route for client projection and rendering.
Locate an uncertain database without modifying it:
```bash
SESSION=ses_...
for db in ~/.local/share/opencode/*.db; do
sqlite3 "file:$db?mode=ro" "select 1 from session where id='$SESSION' limit 1" 2>/dev/null | grep -q 1 && printf '%s\n' "$db"
done
```
## Logs
- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine.
- Each line is structured `key=value` text: `timestamp`, `level`, `run=<id>` (per-process run ID), `message`, and a `role=cli` or `role=server` tag. Use `run=` to isolate one process's activity and `role=` to separate client-side from server-side log lines, since a shared daemon interleaves many processes' output in one file.
- Tail the live file while reproducing an issue instead of guessing from stale output:
```bash
tail -f ~/.local/share/opencode/log/opencode-local.log
```
- Filter to one run or role when the file is noisy:
```bash
grep 'run=8fc3b1d5' ~/.local/share/opencode/log/opencode-local.log
grep 'role=server' ~/.local/share/opencode/log/opencode-local.log
```
- `OPENCODE_LOG_LEVEL` controls verbosity (default `INFO`); set it before starting `bun dev` or `serve` to get `DEBUG` output for a specific repro.
- `OPENCODE_PRINT_LOGS=1` additionally tees log output to stderr of the process that emitted it, which is useful when a process fails before you'd think to check the shared log file.
- `termctrl logs <session>` surfaces stdout/stderr for a Terminal Control session specifically (e.g. inspector output or startup failures before the TUI renderer starts) — use the log file above for anything emitted by a separate server/daemon process instead.
## Heap snapshots
The CLI installs a `SIGUSR1` listener on non-Windows processes in `packages/cli/src/heap.ts`. Use it to capture the installed `opencode2` server without restarting it or attaching an inspector.
1. Find the processes and inspect their roles and memory:
```bash
pgrep -a -f 'opencode2\.exe|opencode2'
ps -o pid,ppid,rss,vsz,lstart,etime,cmd -p <pid>,<pid>
```
2. Signal the process whose heap needs investigation. For shared-service memory, target the `opencode2.exe serve --service` child, not the short wrapper/TUI process:
```bash
kill -USR1 <server-pid>
```
3. Wait for `heap snapshot written` in the channel's log before opening the file. Snapshots are written to the same log directory as `heap-<pid>-<timestamp>.heapsnapshot`; writing a large heap can take several seconds and the file is incomplete until the completion message appears:
```bash
grep 'heap snapshot' ~/.local/share/opencode/log/opencode.log | tail
find ~/.local/share/opencode/log -maxdepth 1 -name 'heap-<server-pid>-*.heapsnapshot' -printf '%T@ %s %p\n' | sort -nr | head
```
Use `opencode-local.log` instead for a local/dev channel process. The log's `path=` field is authoritative.
4. Analyze the snapshot with Chrome DevTools, a V8 heap snapshot parser, or a temporary tool installed outside the repository. Start with the largest retained objects, dominators, object counts grouped by constructor/name, and retainer paths back to GC roots. Relate suspicious names and paths back to the source tree rather than treating large shallow allocations as leaks.
For command-line analysis, install tooling under `/tmp/opencode`, not in the repository. For example, MemLab can rank dominators and trace a reported heap object ID back to a GC root:
```bash
npm install --prefix /tmp/opencode/heap-analysis @memlab/cli
/tmp/opencode/heap-analysis/node_modules/.bin/memlab analyze object-size --snapshot <snapshot>
/tmp/opencode/heap-analysis/node_modules/.bin/memlab analyze shape --snapshot <snapshot>
/tmp/opencode/heap-analysis/node_modules/.bin/memlab trace --snapshot <snapshot> --node-id=<id>
```
A single snapshot explains what retains memory at one point in time, but does not by itself prove a leak. For leak confirmation, capture a baseline, perform a controlled repeated workload, allow idle cleanup/GC when possible, capture another snapshot, and compare growth and retainer paths. Also compare snapshot heap size with process RSS: a large difference can indicate native allocations, database mappings, allocator fragmentation, or other memory outside the JavaScript heap.
```bash
cat /proc/<pid>/smaps_rollup
pmap -x <pid> | sort -k3 -nr | head -25
```
Heap serialization itself can temporarily increase RSS and allocator high-water marks, so record `ps`/`smaps_rollup` both before and after capture. Large anonymous mappings with a comparatively small live heap require native-allocation or allocator investigation; they cannot be explained from JavaScript retainer paths alone.
## Debugger
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
```bash
termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \
bun run --inspect=ws://localhost:6499/ src/index.ts
```
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until a debugger attaches.
- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI.
## Verification
- Run `bun typecheck` from `packages/cli` after CLI adapter changes.
- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root.
- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state.
+61 -51
View File
@@ -28,7 +28,6 @@
"semver": "^7.6.0", "semver": "^7.6.0",
"sst": "catalog:", "sst": "catalog:",
"turbo": "2.10.2", "turbo": "2.10.2",
"vitest": "4.1.10",
}, },
}, },
"packages/ai": { "packages/ai": {
@@ -63,7 +62,6 @@
"@dnd-kit/solid": "0.5.0", "@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/session-ui": "workspace:*", "@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -121,6 +119,7 @@
}, },
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "1.2.1", "@agentclientprotocol/sdk": "1.2.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
@@ -591,9 +590,9 @@
}, },
"peerDependencies": { "peerDependencies": {
"@opencode-ai/theme": "workspace:*", "@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.3", "@opentui/core": ">=0.5.4",
"@opentui/keymap": ">=0.5.3", "@opentui/keymap": ">=0.5.4",
"@opentui/solid": ">=0.5.3", "@opentui/solid": ">=0.5.4",
"solid-js": ">=1.9.0", "solid-js": ">=1.9.0",
}, },
"optionalPeers": [ "optionalPeers": [
@@ -686,9 +685,8 @@
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "file:../app/vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:", "@shikijs/stream": "catalog:",
"@solid-primitives/event-listener": "2.4.5", "@solid-primitives/event-listener": "2.4.5",
@@ -1090,9 +1088,9 @@
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.3", "@opentui/core": "0.5.4",
"@opentui/keymap": "0.5.3", "@opentui/keymap": "0.5.4",
"@opentui/solid": "0.5.3", "@opentui/solid": "0.5.4",
"@pierre/diffs": "1.2.10", "@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0", "@sentry/solid": "10.36.0",
@@ -2114,27 +2112,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.3", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.3", "@opentui/core-darwin-x64": "0.5.3", "@opentui/core-linux-arm64": "0.5.3", "@opentui/core-linux-arm64-musl": "0.5.3", "@opentui/core-linux-x64": "0.5.3", "@opentui/core-linux-x64-musl": "0.5.3", "@opentui/core-win32-arm64": "0.5.3", "@opentui/core-win32-x64": "0.5.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-K8EQu44cx0rhnn3v3baCQW18Bpci3GltZayOwVpGGsbiAGL1WUYqwQjuaWsmS0c4dCa9rQ5xCEoHB1C4936nDg=="], "@opentui/core": ["@opentui/core@0.5.4", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.4", "@opentui/core-darwin-x64": "0.5.4", "@opentui/core-linux-arm64": "0.5.4", "@opentui/core-linux-arm64-musl": "0.5.4", "@opentui/core-linux-x64": "0.5.4", "@opentui/core-linux-x64-musl": "0.5.4", "@opentui/core-win32-arm64": "0.5.4", "@opentui/core-win32-x64": "0.5.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-czcJKQ72QhTWvu1eWfKg4EPN1GLLND6cP9MOhDyqYzCdIZgxQSJVWYzz6c4/CQGOu/qQLRyce2y1efVu4lgQ0w=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R39YeUqaMb/rH1h6G4MkB4MLVKIrRaUaXLfVqorZM4xgU5BxnfPetRk1vWR9vuLCvDwskg+kQ589kULw0o6AWA=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oETbn6tg/0g+mOvGXy3iot+1Zv7CGr65U1lRaPJ7kpsKGnOxftCpDD1qA0I6eQwfPJa9k7h9D/9yGB3IbweGcg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-1pmUas/chTVFGeiN19kaOx+5Xbte/DLhcgKyACwWO0M3+xE3z1v/6QGSyX6CoP5HBpmDroiX+JHv1ic/JlGd/g=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-TIeqCNfAV8xvNAv6oVBYsoGBz/p8CxcYm668OQIeBPUO+irqbQ72vJJa/SwFZrUEAHFmsDELaB6y80oHU5Jm6g=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nMo9Q9VIaQVdw2SNKlwIEWMmf3z+cI4jRdCkh36e2RU1FO7LrIBAEmV1ZuRp1CIFVGPkqCXIizCeckZHTr4yQ=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-UrOsX3D5BOO9TI30WvRwEK/lyPPhY75+LwSqeRRe8SV2ON+Ez5QqY4oryTyYC6+yNahTJufz34n0YgwnQaxTNA=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-QOYAxbbWrhYo27Cd6m0ATzpEx9YCAKAq82LgfUn0xu+VKXLNu+Q3hMNSVbG0SepUQQZil5rz228R9o9Cs8995w=="], "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-8vFWd1dsPZj9fHQKlsGHue3ukp7MRjTSpmIrdneIruOcoK2etccHfd6bqIo4FcNr+HA0eI2FP4ZIL9xhE3r9/w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-hdAYLriLpTj3lvpMyL25GPBzvM2w/n2KCSbIwTmgS2F/dPZYCKJxHEETj2lCvtStSp7KuY8tkg3Xl5RAq1v7gA=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-1RXzSl6d347O7mUXviXFWlFFyILr7qsVt4jwD3k0UiKtENeEDNi+glM1bKHbXwCdQjfA835QgFA2mbILybK+aQ=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-BkVIiPQ1TOf5/FfmIpf7DQU5rT/FO6ASW5R/o/wonI5Pdul7XiDCu86gzGyk1x5k9Sbh6GLeq1fe8/tPmI7IaA=="], "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-YYREqUB3v5K0qWij3A5YWzJkkVXp/EqIiuHZKsAjrUAY/0+Bp8Hn0baLvvvkuklpG9whW+GfwIeUsmp4fxqmCA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-AjObTyZPU0xsK3Yk8GmhkboK6OcMoHBbydqYAybeHD4+v6axScSuZ3OEI9J05JJ9T7H2nZNky75tsdnjsvZJmg=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-F9sB6suPJmwkLF2MwKEC+OtwP6cn5QspIm4MCh2hmu3mVqSz9GDpYID1X3sAh9/V79EVocqiOSqI3KeCTRouKQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-e3nRlF2nSkLKCUPBF32OL9EDgtQDIh2pBo7tjhumpTyJ3qoNOa3us7DsM290Vw4xnakM6jpk9r9NzRf72CuMVg=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-2/6dPPPJ9xL/bWz9jh+lZeV2g/tbxZ9N4FBIqwhnqSfIYy5jOnscsPZpcN4X6PstfJAo2yf0Ebk/tLTOzOS6TQ=="],
"@opentui/keymap": ["@opentui/keymap@0.5.3", "", { "dependencies": { "@opentui/core": "0.5.3" }, "peerDependencies": { "@opentui/react": "0.5.3", "@opentui/solid": "0.5.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-VdvUabUDWnmgolzp1qiO55wz1zv0mMu5Z6tv+UQk9JqZRfKt5Q8XTDi0uQzhBb6qeLsHvXtRFOsnn6EFAcLixA=="], "@opentui/keymap": ["@opentui/keymap@0.5.4", "", { "dependencies": { "@opentui/core": "0.5.4" }, "peerDependencies": { "@opentui/react": "0.5.4", "@opentui/solid": "0.5.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-WAB6EH6C7SicmK/ycvx+mUu00sQ755gToZqBx8vgLhS7qZ07s/WLi88Ys0D6AA0i8lOiSIPA4uqei1jkV8UnBQ=="],
"@opentui/solid": ["@opentui/solid@0.5.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-IB6YC3ajucQeo06BMSNYRD7LHf8ndm6G5ETcYsS+51Mli5yW0ioyUr+6+qgqVtaQyOcJvaD1KWmK23pXLhsiwg=="], "@opentui/solid": ["@opentui/solid@0.5.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-1injDvieTyYXD3RLC2EclaJK8XZ2+qyJ7wGeQPSzpcYEEcanb7G79KVWFlN7YUzqVAhTIictZDoQ6SRvIwL4Ww=="],
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="], "@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
@@ -3232,7 +3230,7 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="],
@@ -3242,7 +3240,7 @@
"@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="],
"@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
@@ -3544,7 +3542,7 @@
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="], "chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="],
@@ -3948,7 +3946,7 @@
"es-get-iterator": ["es-get-iterator@1.1.3", "", { "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", "has-symbols": "^1.0.3", "is-arguments": "^1.1.1", "is-map": "^2.0.2", "is-set": "^2.0.2", "is-string": "^1.0.7", "isarray": "^2.0.5", "stop-iteration-iterator": "^1.0.0" } }, "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw=="], "es-get-iterator": ["es-get-iterator@1.1.3", "", { "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", "has-symbols": "^1.0.3", "is-arguments": "^1.1.1", "is-map": "^2.0.2", "is-set": "^2.0.2", "is-string": "^1.0.7", "isarray": "^2.0.5", "stop-iteration-iterator": "^1.0.0" } }, "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw=="],
"es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="], "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
@@ -5620,7 +5618,7 @@
"tinyclip": ["tinyclip@0.1.15", "", {}, "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A=="], "tinyclip": ["tinyclip@0.1.15", "", {}, "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A=="],
"tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
@@ -6054,6 +6052,8 @@
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.42", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Q07lZsq4ir+xwAGVfSbY2WNGLrbfWINFaL2I/vTBFrkuXeP7VZh+UtQgLOKZS6Z/6flNFW22jDYdbINvwTV/PA=="], "@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.42", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Q07lZsq4ir+xwAGVfSbY2WNGLrbfWINFaL2I/vTBFrkuXeP7VZh+UtQgLOKZS6Z/6flNFW22jDYdbINvwTV/PA=="],
"@antfu/install-pkg/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"@astrojs/cloudflare/vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], "@astrojs/cloudflare/vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"@astrojs/cloudflare/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="], "@astrojs/cloudflare/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
@@ -6070,8 +6070,6 @@
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="], "@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="],
"@astrojs/mdx/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"@astrojs/node/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.10.2", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.3.0", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-yt7fMgPYqSM4Tmr+taTW6Per+hjJ8Pk6lA1PAcDyqzOt8HzJ6Kje5WzCxA2Sd+9wsUW7uhkLeoTMK0cXPwH9rQ=="], "@astrojs/node/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.10.2", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.3.0", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-yt7fMgPYqSM4Tmr+taTW6Per+hjJ8Pk6lA1PAcDyqzOt8HzJ6Kje5WzCxA2Sd+9wsUW7uhkLeoTMK0cXPwH9rQ=="],
@@ -6370,8 +6368,6 @@
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"@opencode-ai/session-ui/@opencode-ai/sdk": ["@opencode-ai/sdk@../app/vendor/opencode-ai-sdk-1.18.8-dev.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-C2nfk4x0sPINwE5V6DPkFSuH3PkUmKPWHPzxpXC1j+3Ui5hslLCWJbkk8WcOG1Lyt3C0+yp4ea64v/kmtYCO4w=="],
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="], "@opencode-ai/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
@@ -6470,8 +6466,6 @@
"@slack/web-api/p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], "@slack/web-api/p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
"@solidjs/start/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"@solidjs/start/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "@solidjs/start/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"@solidjs/start/shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="], "@solidjs/start/shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="],
@@ -6542,6 +6536,12 @@
"@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], "@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"@vitest/mocker/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
"@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], "@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.127", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.31", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SiD2kyR2J4fEY0FTXYpNznwWN90ohrwzAMi2laZCGkI/lL8DmyJ31zFzMYJvzRusp2IQYmvkYQWOpZpBl01xYw=="], "ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.127", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.31", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SiD2kyR2J4fEY0FTXYpNznwWN90ohrwzAMi2laZCGkI/lL8DmyJ31zFzMYJvzRusp2IQYmvkYQWOpZpBl01xYw=="],
@@ -6580,14 +6580,10 @@
"astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], "astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="],
"astro/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"astro/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], "astro/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
"astro/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], "astro/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
"astro/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
"astro/vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], "astro/vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
@@ -6878,10 +6874,6 @@
"sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="], "sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
"storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"storybook/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "storybook/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
@@ -6932,11 +6924,15 @@
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], "venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
"vite-plugin-dynamic-import/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"vite-plugin-icons-spritesheet/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], "vitest/@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
"vitest/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
"vitest/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"vitest/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"vitest/vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="], "vitest/vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="],
@@ -7028,6 +7024,8 @@
"@astrojs/node/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "@astrojs/node/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@astrojs/node/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"@astrojs/node/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "@astrojs/node/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"@astrojs/node/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], "@astrojs/node/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
@@ -7046,6 +7044,8 @@
"@astrojs/node/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], "@astrojs/node/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"@astrojs/node/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"@astrojs/node/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="], "@astrojs/node/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
"@astrojs/node/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "@astrojs/node/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
@@ -7082,6 +7082,8 @@
"@astrojs/vercel/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "@astrojs/vercel/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@astrojs/vercel/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"@astrojs/vercel/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], "@astrojs/vercel/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
"@astrojs/vercel/astro/get-tsconfig": ["get-tsconfig@5.0.0-beta.4", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ=="], "@astrojs/vercel/astro/get-tsconfig": ["get-tsconfig@5.0.0-beta.4", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ=="],
@@ -7098,6 +7100,8 @@
"@astrojs/vercel/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], "@astrojs/vercel/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"@astrojs/vercel/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"@astrojs/vercel/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="], "@astrojs/vercel/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
"@astrojs/vercel/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "@astrojs/vercel/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
@@ -7456,6 +7460,8 @@
"@opencode-ai/www/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "@opencode-ai/www/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@opencode-ai/www/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"@opencode-ai/www/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "@opencode-ai/www/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"@opencode-ai/www/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], "@opencode-ai/www/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
@@ -7474,6 +7480,8 @@
"@opencode-ai/www/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], "@opencode-ai/www/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"@opencode-ai/www/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"@opencode-ai/www/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="], "@opencode-ai/www/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
"@opencode-ai/www/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "@opencode-ai/www/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
@@ -7582,6 +7590,8 @@
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
@@ -7646,6 +7656,8 @@
"blume/@astrojs/mdx/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "blume/@astrojs/mdx/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"blume/@astrojs/mdx/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"blume/@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "blume/@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"blume/@clack/prompts/@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="], "blume/@clack/prompts/@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="],
@@ -7676,6 +7688,8 @@
"blume/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "blume/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"blume/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"blume/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "blume/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"blume/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], "blume/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
@@ -7688,6 +7702,8 @@
"blume/astro/p-queue": ["p-queue@9.3.3", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA=="], "blume/astro/p-queue": ["p-queue@9.3.3", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA=="],
"blume/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"blume/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="], "blume/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
"blume/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "blume/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
@@ -7824,12 +7840,6 @@
"rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"storybook/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], "storybook/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"storybook/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], "storybook/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
@@ -7902,6 +7912,8 @@
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.42", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Q07lZsq4ir+xwAGVfSbY2WNGLrbfWINFaL2I/vTBFrkuXeP7VZh+UtQgLOKZS6Z/6flNFW22jDYdbINvwTV/PA=="], "venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.42", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Q07lZsq4ir+xwAGVfSbY2WNGLrbfWINFaL2I/vTBFrkuXeP7VZh+UtQgLOKZS6Z/6flNFW22jDYdbINvwTV/PA=="],
"vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"vitest/vite/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "vitest/vite/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"vitest/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "vitest/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
@@ -8784,8 +8796,6 @@
"rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"tw-to-css/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "tw-to-css/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+4 -5
View File
@@ -47,9 +47,9 @@
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0", "@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.3", "@opentui/core": "0.5.4",
"@opentui/keymap": "0.5.3", "@opentui/keymap": "0.5.4",
"@opentui/solid": "0.5.3", "@opentui/solid": "0.5.4",
"@tanstack/solid-virtual": "3.13.32", "@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0", "@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0", "@standard-schema/spec": "1.1.0",
@@ -121,8 +121,7 @@
"prettier": "3.6.2", "prettier": "3.6.2",
"semver": "^7.6.0", "semver": "^7.6.0",
"sst": "catalog:", "sst": "catalog:",
"turbo": "2.10.2", "turbo": "2.10.2"
"vitest": "4.1.10"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.933.0", "@aws-sdk/client-s3": "3.933.0",
@@ -437,10 +437,19 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart
return yield* Effect.forEach(content, lowerToolResultContentItem) return yield* Effect.forEach(content, lowerToolResultContentItem)
}) })
// Mid-conversation system messages are a native Claude API feature only for // Mid-conversation system messages became available with Opus 4.8 and version
// Opus 4.8. Other Anthropic models intentionally use the same visible wrapped- // 5 of the other supported Claude families. Treat later family versions as
// user fallback as non-Anthropic routes rather than sending a role they reject. // compatible without assuming that every Anthropic Messages model is Claude.
const supportsNativeSystemUpdates = (request: LLMRequest) => String(request.model.id) === "claude-opus-4-8" const supportsNativeSystemUpdates = (request: LLMRequest) => {
const match = /(?:^|[./])claude-(fable|haiku|mythos|opus|sonnet)-(\d+)(?:[.-](\d+))?/.exec(
String(request.model.id).toLowerCase(),
)
if (!match) return false
const major = Number(match[2])
if (match[1] !== "opus") return major >= 5
if (major !== 4) return major >= 5
return match[3] !== undefined && match[3].length <= 2 && Number(match[3]) >= 8
}
const endsInServerToolUse = (message: LLMRequest["messages"][number]) => { const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
const last = message.content.at(-1) const last = message.content.at(-1)
@@ -957,9 +966,12 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
] ]
} }
const onMessageStop = (state: ParserState): StepResult => { const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (state: ParserState) {
const result = yield* ToolStream.finishAll(ADAPTER, state.tools)
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, { const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...result.events)
const finished = Lifecycle.finish(lifecycle, events, {
reason: state.pendingFinish?.reason ?? { reason: state.pendingFinish?.reason ?? {
normalized: "unknown", normalized: "unknown",
raw: undefined, raw: undefined,
@@ -967,8 +979,8 @@ const onMessageStop = (state: ParserState): StepResult => {
usage: state.usage, usage: state.usage,
providerMetadata: state.pendingFinish?.providerMetadata, providerMetadata: state.pendingFinish?.providerMetadata,
}) })
return [{ ...state, lifecycle }, events] return [{ ...state, lifecycle: finished, tools: result.tools }, events] satisfies StepResult
} })
// Prefix `error.type` so overloads, rate limits, and quota errors are visible // Prefix `error.type` so overloads, rate limits, and quota errors are visible
// even when the provider message is generic or empty. // even when the provider message is generic or empty.
@@ -992,7 +1004,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "content_block_delta") return onContentBlockDelta(state, event) if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
if (event.type === "content_block_stop") return onContentBlockStop(state, event) if (event.type === "content_block_stop") return onContentBlockStop(state, event)
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event)) if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
if (event.type === "message_stop") return Effect.succeed(onMessageStop(state)) if (event.type === "message_stop") return onMessageStop(state)
if (event.type === "error") return onError(event) if (event.type === "error") return onError(event)
return Effect.succeed<StepResult>([state, NO_EVENTS]) return Effect.succeed<StepResult>([state, NO_EVENTS])
} }
@@ -136,6 +136,33 @@ describe("Anthropic Messages route", () => {
}), }),
) )
it.effect("supports native chronological system updates on documented and later Claude family versions", () =>
Effect.gen(function* () {
const ids = [
"claude-opus-4-8",
"claude-opus-5-1",
"claude-sonnet-5",
"claude-haiku-5-1",
"claude-fable-6",
"anthropic/claude-mythos-7.2",
]
const prepared = yield* Effect.forEach(ids, (id) =>
compileRequest(
LLM.request({
model: AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
.model({ id }),
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
cache: "none",
}),
),
)
expect(prepared.map((item) => item.body.messages[1]?.role)).toEqual(ids.map(() => "system"))
}),
)
it.effect("lowers chronological system updates to wrapped user text for unsupported Anthropic models", () => it.effect("lowers chronological system updates to wrapped user text for unsupported Anthropic models", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
@@ -163,6 +190,34 @@ describe("Anthropic Messages route", () => {
}), }),
) )
it.effect("does not infer native system update support for older or undocumented Claude families", () =>
Effect.gen(function* () {
const ids = [
"claude-opus-4-7",
"claude-opus-4-20250514",
"claude-sonnet-4-9",
"claude-haiku-4-9",
"custom-model-7",
]
const prepared = yield* Effect.forEach(ids, (id) =>
compileRequest(
LLM.request({
model: AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
.model({ id }),
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
cache: "none",
}),
),
)
expect(prepared.map((item) => item.body.messages.some((message) => message.role === "system"))).toEqual(
ids.map(() => false),
)
}),
)
it.effect("rejects non-text chronological system update content before send", () => it.effect("rejects non-text chronological system update content before send", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* compileRequest(
@@ -955,6 +1010,37 @@ describe("Anthropic Messages route", () => {
}), }),
) )
it.effect("settles pending tool calls at message_stop", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "call_1", name: "lookup" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' },
},
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.toolCalls).toMatchObject([
{ id: "call_1", name: "lookup", input: { query: "weather" } },
])
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_use" })
}),
)
it.effect("assembles and persists multiple tool calls from one Anthropic response", () => it.effect("assembles and persists multiple tool calls from one Anthropic response", () =>
Effect.gen(function* () { Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe( const response = yield* LLMClient.generate(request).pipe(
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import type { import type {
JsonValue, JsonValue,
OpenCodeEvent, OpenCodeEvent,
@@ -114,6 +114,7 @@ const nextOrdinals = new Map<string, { text: number; reasoning: number }>()
const startedParts = new Set<string>() const startedParts = new Set<string>()
const toolStates = new Map<string, ToolStatus>() const toolStates = new Map<string, ToolStatus>()
let eventSequence = 0 let eventSequence = 0
let durableSequence = -1
export async function setupTimeline( export async function setupTimeline(
page: Page, page: Page,
@@ -132,6 +133,8 @@ export async function setupTimeline(
seedHistory?: boolean seedHistory?: boolean
} = {}, } = {},
) { ) {
eventSequence = 0
durableSequence = -1
const sessions = input.sessions ?? [session()] const sessions = input.sessions ?? [session()]
const messages = const messages =
input.sessionMessages ?? input.sessionMessages ??
@@ -427,9 +430,23 @@ export function messageUpdated(info: SessionMessageAssistant) {
} }
export function status(type: SessionStatus["type"], attempt = 1) { export function status(type: SessionStatus["type"], attempt = 1) {
return event("session.status", { if (type === "busy") return makeEvent("session.execution.started", { sessionID })
if (type === "idle") return makeEvent("session.execution.succeeded", { sessionID })
return makeEvent("session.retry.scheduled", {
sessionID, sessionID,
status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type }, assistantMessageID: assistantID,
attempt,
at: 1700000010000,
error: { type: "provider.error", message: "Rate limited" },
})
}
export function stepStarted(message: SessionMessageAssistant) {
return makeEvent("session.step.started", {
sessionID,
assistantMessageID: message.id,
agent: message.agent,
model: message.model,
}) })
} }
@@ -803,7 +820,7 @@ function makeEvent<Type extends OpenCodeEvent["type"]>(
definition.durability === "durable" definition.durability === "durable"
? { ? {
...base, ...base,
durable: { aggregateID: sessionID, seq: eventSequence, version: definition.durable.version }, durable: { aggregateID: sessionID, seq: ++durableSequence, version: definition.durable.version },
} }
: base : base
return Schema.decodeUnknownSync(definition)(input) as unknown as OpenCodeEvent return Schema.decodeUnknownSync(definition)(input) as unknown as OpenCodeEvent
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise" import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server" import { mockOpenCodeServer } from "../../utils/mock-server"
@@ -1,5 +1,5 @@
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../../utils/mock-server" import { mockOpenCodeServer } from "../../utils/mock-server"
import { fixture, pageMessages } from "./session-timeline-stress.fixture" import { fixture, pageMessages } from "./session-timeline-stress.fixture"
@@ -5,11 +5,9 @@ import { mockOpenCodeServer } from "../../utils/mock-server"
test("applies message latency after a list response gate is released", async () => { test("applies message latency after a list response gate is released", async () => {
const events: string[] = [] const events: string[] = []
const gate = Promise.withResolvers<void>() const gate = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
let handler: ((route: Route) => Promise<void>) | undefined let handler: ((route: Route) => Promise<void>) | undefined
const page = { const page = {
addInitScript: () => Promise.resolve(), addInitScript: () => Promise.resolve(),
on: () => page,
route: (_url: string, callback: (route: Route) => Promise<void>) => { route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback handler = callback
return Promise.resolve() return Promise.resolve()
@@ -23,7 +21,6 @@ test("applies message latency after a list response gate is released", async ()
messageDelay: 25, messageDelay: 25,
beforeMessagesResponse: () => { beforeMessagesResponse: () => {
events.push("before") events.push("before")
started.resolve()
return gate.promise return gate.promise
}, },
onMessages: (request) => events.push(request.phase), onMessages: (request) => events.push(request.phase),
@@ -34,18 +31,12 @@ test("applies message latency after a list response gate is released", async ()
}) })
const response = handler!({ const response = handler!({
request: () => ({ request: () => ({ url: () => "http://127.0.0.1:4096/api/session/session/message" }),
url: () => "http://127.0.0.1:4096/api/session/session/message",
method: () => "GET",
headers: () => ({}),
postDataBuffer: () => null,
}),
fulfill: () => { fulfill: () => {
events.push("fulfill") events.push("fulfill")
return Promise.resolve() return Promise.resolve()
}, },
} as unknown as Route) } as unknown as Route)
await started.promise
expect(events).toEqual(["start", "before"]) expect(events).toEqual(["start", "before"])
const released = performance.now() const released = performance.now()
@@ -54,42 +45,3 @@ test("applies message latency after a list response gate is released", async ()
expect(performance.now() - released).toBeGreaterThanOrEqual(20) expect(performance.now() - released).toBeGreaterThanOrEqual(20)
expect(events).toEqual(["start", "before", "page", "end", "fulfill"]) expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
}) })
test("routes requests through the HttpApi contract", async () => {
const connected = Promise.withResolvers<{ integrationID: string; body: unknown }>()
let handler: ((route: Route) => Promise<void>) | undefined
const page = {
addInitScript: () => Promise.resolve(),
on: () => page,
route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback
return Promise.resolve()
},
} as unknown as Page
await mockOpenCodeServer(page, {
provider: {},
directory: "C:/OpenCode",
project: {},
sessions: [],
pageMessages: () => ({ items: [] }),
onConnectKey: connected.resolve,
})
const body = Buffer.from(JSON.stringify({ key: "secret" }))
let status: number | undefined
await handler!({
request: () => ({
url: () => "http://127.0.0.1:4096/api/integration/anthropic/connect/key",
method: () => "POST",
headers: () => ({ "content-type": "application/json" }),
postDataBuffer: () => body,
}),
fulfill: (response: Parameters<Route["fulfill"]>[0]) => {
status = response?.status
return Promise.resolve()
},
} as unknown as Route)
expect(status).toBe(204)
expect(await connected.promise).toEqual({ integrationID: "anthropic", body: { key: "secret" } })
})
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { fixture } from "../timeline/session-timeline-stress.fixture" import { fixture } from "../timeline/session-timeline-stress.fixture"
import { stressSessionHref } from "../timeline/timeline-test-helpers" import { stressSessionHref } from "../timeline/timeline-test-helpers"
@@ -1,5 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server" import { currentSession } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport" import { installSseTransport } from "../utils/sse-transport"
@@ -38,12 +38,6 @@ test("closing the active server's last tab opens the remaining server tab", asyn
await expect(page.getByText(sessionB.title).first()).toBeVisible() await expect(page.getByText(sessionB.title).first()).toBeVisible()
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`)) const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true) expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
expect(
requests.some((request) => {
const url = new URL(request)
return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory
}),
).toBe(true)
}) })
function session(id: string, directory: string, title: string) { function session(id: string, directory: string, title: string) {
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
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 { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits" import { expectAppVisible } from "../utils/waits"
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits" import { expectAppVisible } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport" import { installSseTransport } from "../utils/sse-transport"
import { currentSession } from "../utils/mock-server" import { currentSession } from "../utils/mock-server"
@@ -1,5 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server" import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096" const serverA = "http://127.0.0.1:4096"
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits" import { expectAppVisible, expectSessionTitle } from "../utils/waits"
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits" import { expectAppVisible, expectSessionTitle } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
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 { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
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" import { expectAppVisible, expectSessionTitle } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
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 { installSseTransport } from "../utils/sse-transport" import { installSseTransport } from "../utils/sse-transport"
@@ -93,7 +93,9 @@ test.describe("regression: session timeline local row state", () => {
events.push(...textEvents()) events.push(...textEvents())
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 }) await expect(page.locator(`[data-timeline-part-id="${assistantMessageID}:text:0"]`).first()).toBeVisible({
timeout: 10_000,
})
expect(await readToolState(page)).toEqual({ expect(await readToolState(page)).toEqual({
expanded: false, expanded: false,
@@ -119,7 +121,9 @@ test.describe("regression: session timeline local row state", () => {
events.push(...textEvents()) events.push(...textEvents())
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 }) await expect(page.locator(`[data-timeline-part-id="${assistantMessageID}:text:0"]`).first()).toBeVisible({
timeout: 10_000,
})
const siblingProbe = await readDiffProbe(page) const siblingProbe = await readDiffProbe(page)
expect(siblingProbe).toEqual({ expect(siblingProbe).toEqual({
fileMarker: "before", fileMarker: "before",
@@ -232,7 +236,7 @@ async function readToolState(page: Page) {
row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"), row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`), streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
}), }),
textPartID, `${assistantMessageID}:text:0`,
) )
} }
@@ -309,7 +313,7 @@ function toolContent(part: typeof editPart): SessionMessageAssistant["content"][
} }
} }
let eventSequence = 0 let eventSequence = -1
function textEvents(): OpenCodeEvent[] { function textEvents(): OpenCodeEvent[] {
return [ return [
@@ -410,6 +414,7 @@ async function mockServer(
events: EventPayload[], events: EventPayload[],
messages: SessionMessageInfo[] = [userMessage, assistantMessage], messages: SessionMessageInfo[] = [userMessage, assistantMessage],
) { ) {
eventSequence = -1
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
directory, directory,
project: project(), project: project(),
@@ -310,45 +310,10 @@ function toolContent(part: ContextTool): SessionMessageAssistant["content"][numb
} }
} }
let eventSequence = 0 let eventSequence = -1
function toolEvents(part: ContextTool): OpenCodeEvent[] { function toolEvents(part: ContextTool): OpenCodeEvent[] {
const events = [
eventValue(
"session.tool.input.started",
{
sessionID,
assistantMessageID: part.messageID,
id: part.callID,
name: part.tool,
},
1,
),
eventValue(
"session.tool.input.ended",
{
sessionID,
assistantMessageID: part.messageID,
id: part.callID,
text: JSON.stringify(part.state.input),
},
1,
),
eventValue(
"session.tool.called",
{
sessionID,
assistantMessageID: part.messageID,
id: part.callID,
input: part.state.input,
executed: true,
},
1,
),
] satisfies OpenCodeEvent[]
if (part.state.status === "running") return events
return [ return [
...events,
eventValue( eventValue(
"session.tool.success", "session.tool.success",
{ {
@@ -381,6 +346,7 @@ function eventValue<Type extends OpenCodeEvent["type"]>(
} }
async function mockServer(page: Page, events: OpenCodeEvent[] = [], fixtureMessages = messages) { async function mockServer(page: Page, events: OpenCodeEvent[] = [], fixtureMessages = messages) {
eventSequence = -1
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
directory, directory,
project: project(), project: project(),
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import type { SessionMessageAssistant } from "@opencode-ai/client/promise" import type { SessionMessageAssistant } from "@opencode-ai/client/promise"
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { import {
@@ -9,6 +9,7 @@ import {
setupTimeline, setupTimeline,
shell, shell,
status, status,
stepStarted,
textPart, textPart,
userMessage, userMessage,
} from "../performance/timeline-stability/fixture" } from "../performance/timeline-stability/fixture"
@@ -98,12 +99,12 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
await timeline.send(status("retry"), 180) await timeline.send(status("retry"), 180)
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible() await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await timeline.send(status("busy", 2), 180) await timeline.send(stepStarted(assistant), 180)
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140) await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100) await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
await timeline.send(status("idle"), 350) await timeline.send(status("idle"), 350)
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_recovered")}"]`)).toContainText( await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_recovered")}"]`)).toContainText(
"Recovered response", "Recovered response",
@@ -203,7 +203,12 @@ test("separates blocking and already-backgrounded work into two rows", async ({
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'), page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
).toHaveAttribute("data-active", "true") ).toHaveAttribute("data-active", "true")
await timeline.send(event("session.status", { sessionID: backgroundID, status: { type: "idle" } })) await timeline.transport.send({
id: "evt_background_succeeded",
created: Date.now(),
type: "session.execution.succeeded",
data: { sessionID: backgroundID },
} as never)
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0) await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
await expect(backgroundCard).toContainText("Background task (background)") await expect(backgroundCard).toContainText("Background task (background)")
}) })
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise" import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { currentSession, mockOpenCodeServer } from "../utils/mock-server" import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
@@ -26,7 +26,7 @@ test("navigates to a subagent child session missing from the session list", asyn
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1) await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
}) })
test("keeps the parent visible while child lineage resolves", async ({ page }) => { test("keeps the parent visible while the child session resolves", async ({ page }) => {
await setup(page) await setup(page)
const requested = Promise.withResolvers<void>() const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>() const release = Promise.withResolvers<void>()
@@ -1,5 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server" import { currentSession } from "../utils/mock-server"
const server = "http://127.0.0.1:4096" const server = "http://127.0.0.1:4096"
@@ -1,4 +1,4 @@
import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { base64Encode, checksum } from "@opencode-ai/util/encode"
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 { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
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 { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/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"
-218
View File
@@ -1,218 +0,0 @@
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
const Json = Schema.Unknown.pipe(HttpApiSchema.asJson())
const JsonPayload = Schema.Unknown.pipe(HttpApiSchema.asJson())
const Query = Schema.Struct({
directory: Schema.optional(Schema.String),
parentID: Schema.optional(Schema.String),
search: Schema.optional(Schema.String),
order: Schema.optional(Schema.String),
cursor: Schema.optional(Schema.String),
limit: Schema.optional(Schema.NumberFromString),
path: Schema.optional(Schema.String),
query: Schema.optional(Schema.String),
type: Schema.optional(Schema.String),
})
const SessionParams = { sessionID: Schema.String }
const NoContent = HttpApiSchema.NoContent
export class MockNotFound extends Schema.TaggedError<MockNotFound>()("MockNotFound", {
message: Schema.String,
}) {}
export class MockBadRequest extends Schema.TaggedError<MockBadRequest>()("MockBadRequest", {
message: Schema.String,
}) {}
const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("health", "/api/health", { success: Json }))
.add(
HttpApiEndpoint.get("event", "/api/event", {
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
}),
)
.add(HttpApiEndpoint.get("reference", "/api/reference", { success: Json }))
.add(HttpApiEndpoint.get("agent", "/api/agent", { success: Json }))
.add(HttpApiEndpoint.get("provider", "/api/provider", { success: Json }))
.add(HttpApiEndpoint.get("model", "/api/model", { success: Json }))
.add(HttpApiEndpoint.get("modelDefault", "/api/model/default", { success: Json }))
.add(HttpApiEndpoint.get("integrationList", "/api/integration", { success: Json }))
.add(
HttpApiEndpoint.get("integrationGet", "/api/integration/:integrationID", {
params: { integrationID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.post("integrationConnect", "/api/integration/:integrationID/connect/key", {
params: { integrationID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.delete("credentialRemove", "/api/credential/:credentialID", {
params: { credentialID: Schema.String },
success: NoContent,
}),
)
.add(HttpApiEndpoint.get("command", "/api/command", { success: Json }))
.add(HttpApiEndpoint.get("plugin", "/api/plugin", { success: Json }))
.add(HttpApiEndpoint.get("mcp", "/api/mcp", { success: Json }))
.add(HttpApiEndpoint.get("mcpResource", "/api/mcp/resource", { success: Json }))
.add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json }))
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
.add(
HttpApiEndpoint.get("worktreeList", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.post("worktreeCreate", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
payload: JsonPayload,
success: Json,
}),
)
.add(
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/:projectID/refresh", {
params: { projectID: Schema.String },
success: NoContent,
}),
)
.add(HttpApiEndpoint.get("location", "/api/location", { success: Json }))
.add(HttpApiEndpoint.get("permissionRequests", "/api/permission/request", { success: Json }))
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json }))
.add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json }))
.add(
HttpApiEndpoint.get("fsRead", "/api/fs/read/*", {
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
}),
)
.add(HttpApiEndpoint.get("fsFind", "/api/fs/find", { query: Query, success: Json }))
.add(HttpApiEndpoint.get("shell", "/api/shell", { success: Json }))
.add(
HttpApiEndpoint.get("ptyConnectToken", "/api/pty/:ptyID/connect-token", {
params: { ptyID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.get("sessionList", "/api/session", {
query: Query,
success: Json,
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
}),
)
.add(HttpApiEndpoint.post("sessionCreate", "/api/session", { payload: JsonPayload, success: Json }))
.add(HttpApiEndpoint.get("sessionActive", "/api/session/active", { success: Json }))
.add(
HttpApiEndpoint.get("sessionGet", "/api/session/:sessionID", {
params: SessionParams,
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
}),
)
.add(
HttpApiEndpoint.delete("sessionRemove", "/api/session/:sessionID", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionShell", "/api/session/:sessionID/shell", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("sessionForm", "/api/session/:sessionID/form", {
params: SessionParams,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionFormReply", "/api/session/:sessionID/form/:formID/reply", {
params: { ...SessionParams, formID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionFormCancel", "/api/session/:sessionID/form/:formID/cancel", {
params: { ...SessionParams, formID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionBackground", "/api/session/:sessionID/background", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("sessionInbox", "/api/session/:sessionID/inbox", {
params: SessionParams,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionPermissionReply", "/api/session/:sessionID/permission/:permissionID/reply", {
params: { ...SessionParams, permissionID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionRename", "/api/session/:sessionID/rename", {
params: SessionParams,
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionInterrupt", "/api/session/:sessionID/interrupt", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionRevertClear", "/api/session/:sessionID/revert/clear", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionRevertCommit", "/api/session/:sessionID/revert/commit", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("messageGet", "/api/session/:sessionID/message/:messageID", {
params: { ...SessionParams, messageID: Schema.String },
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
}),
)
.add(
HttpApiEndpoint.get("messageList", "/api/session/:sessionID/message", {
params: SessionParams,
query: Query,
success: Json,
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
}),
)
export const MockApi = HttpApi.make("mock").add(Group)
+311 -297
View File
@@ -1,9 +1,5 @@
import type { Page } from "@playwright/test" import type { Page, Route } from "@playwright/test"
import type { JsonValue, OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise" import type { JsonValue, OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
import { Duration, Effect, Layer } from "effect"
import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { MockApi, MockBadRequest, MockNotFound } from "./mock-api"
export interface MockServerConfig { export interface MockServerConfig {
provider: unknown | (() => unknown) provider: unknown | (() => unknown)
@@ -42,8 +38,9 @@ type MockStreamWindow = Window & {
} }
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const state = { cursors: new Map<string, string>(), nextCursor: 0 } const cursors = new Map<string, string>()
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
let nextCursor = 0
await page.addInitScript( await page.addInitScript(
({ server, retry }) => { ({ server, retry }) => {
@@ -130,311 +127,307 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}, 50) }, 50)
page.on("close", () => clearInterval(timer)) page.on("close", () => clearInterval(timer))
} }
const transport = HttpRouter.toWebHandler(
HttpApiBuilder.layer(MockApi).pipe(
Layer.provide(mockHandlers(config, state)),
Layer.provide(HttpServer.layerServices),
),
{ disableLogger: true },
)
page.on("close", () => void transport.dispose())
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 appPort = new URL( const appPort = new URL(
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`, process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
).port ).port
if (url.origin !== server && url.port !== appPort) return route.fallback() if (url.origin !== server && url.port !== appPort) return route.fallback()
if (route.request().method() === "OPTIONS") {
return route.fulfill({ status: 204, headers: corsHeaders })
}
const body = route.request().postDataBuffer() const path = url.pathname
const response = await transport.handler( if (path === "/api/event") {
new Request(url, { const events = config.events?.()
method: route.request().method(), return sse(
headers: route.request().headers(), route,
body: body ? Uint8Array.from(body) : undefined, [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])],
}), config.eventRetry,
)
if (response.status === 404 && url.origin !== server) return route.fallback()
return route.fulfill({
status: response.status,
headers: { ...Object.fromEntries(response.headers), ...corsHeaders },
body: Buffer.from(await response.arrayBuffer()),
})
})
}
const corsHeaders = {
"access-control-allow-origin": "*",
"access-control-allow-headers": "*",
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
"access-control-expose-headers": "x-next-cursor",
}
function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, string>; nextCursor: number }) {
const noContent = Effect.succeed(HttpApiSchema.NoContent.make())
const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay))
return HttpApiBuilder.group(MockApi, "mock", (handlers) =>
handlers
.handleRaw("event", () => {
const events = config.events?.()
const retry = config.eventRetry === undefined ? "" : `retry: ${config.eventRetry}\n\n`
const body = [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])]
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
.join("")
return Effect.succeed(HttpServerResponse.text(retry + body, { contentType: "text/event-stream" }))
})
.handleRaw("fsRead", (ctx) =>
Effect.gen(function* () {
const path = decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13))
const value = yield* Effect.promise(() => Promise.resolve(config.fileContent?.(path)))
const content =
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
return HttpServerResponse.uint8Array(new TextEncoder().encode(content))
}),
) )
.handleAll({ }
health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }), if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
reference: () => if (path === "/api/reference")
Effect.succeed({ return json(route, {
location: { location: {
directory: config.directory, directory: config.directory,
project: { project: {
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
},
},
data: [],
}),
agent: () =>
Effect.succeed({
location: location(config),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
}),
provider: () => Effect.succeed({ location: location(config), data: currentProviders(providerConfig(config)) }),
model: () => Effect.succeed({ location: location(config), data: currentModels(providerConfig(config)) }),
modelDefault: () =>
Effect.succeed({ location: location(config), data: currentDefaultModel(providerConfig(config)) }),
integrationList: () => Effect.succeed({ location: location(config), data: [] }),
integrationGet: (ctx) =>
Effect.succeed({
location: location(config),
data: {
id: ctx.params.integrationID,
name: ctx.params.integrationID,
methods: config.integrationMethods?.[ctx.params.integrationID] ?? [{ type: "key", label: "API key" }],
connections: [],
},
}),
integrationConnect: (ctx) =>
Effect.sync(() => config.onConnectKey?.({ integrationID: ctx.params.integrationID, body: ctx.payload })).pipe(
Effect.andThen(noContent),
),
credentialRemove: () => noContent,
command: () => Effect.succeed({ location: location(config), data: [] }),
plugin: () => Effect.succeed({ location: location(config), data: [] }),
mcp: () => Effect.succeed({ location: location(config), data: [] }),
mcpResource: () => Effect.succeed({ location: location(config), data: { resources: [], templates: [] } }),
projectList: () => {
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return Effect.succeed([{ ...project, canonical: project.canonical ?? project.worktree ?? config.directory }])
},
projectCurrent: () =>
Effect.succeed({
id: (config.project as { id?: string }).id, id: (config.project as { id?: string }).id,
directory: config.directory, directory: config.directory,
canonical: config.directory, canonical: config.directory,
}), },
worktreeList: () =>
Effect.succeed([
{ directory: config.directory },
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
directory,
strategy: "git",
})),
]),
worktreeCreate: (ctx) => {
const input = record(ctx.payload) ? ctx.payload : {}
return Effect.succeed({
directory: `${typeof input.directory === "string" ? input.directory : config.directory}/${
typeof input.name === "string" ? input.name : "copy"
}`,
})
}, },
worktreeRemove: () => noContent, data: [],
worktreeRefresh: () => noContent, })
location: () => Effect.succeed(location(config)), if (path === "/api/agent")
permissionRequests: () => return json(route, {
Effect.succeed({ location: location(config),
location: location(config), data: [
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map( {
currentPermission, id: "build",
), name: "Build",
}), mode: "primary",
formRequests: () => hidden: false,
Effect.succeed({ request: { settings: {}, headers: {}, body: {} },
location: location(config), permissions: [],
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []), },
}), ],
vcs: () => })
Effect.succeed({ location: location(config), data: { branch: { current: "main", default: "main" } } }), if (path === "/api/provider")
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }), return json(route, {
vcsDiff: () => Effect.succeed({ location: location(config), data: config.vcsDiff ?? [] }), location: location(config),
fsList: (ctx) => data: currentProviders(providerConfig(config)),
Effect.promise(() => Promise.resolve(config.fileList?.(ctx.query.path ?? ""))).pipe( })
Effect.map((data) => ({ location: location(config), data })), if (path === "/api/model")
), return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
fsFind: (ctx) => if (path === "/api/model/default")
Effect.promise(() => return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
Promise.resolve( if (path === "/api/integration") return json(route, { location: location(config), data: [] })
config.findFiles?.({ query: ctx.query.query ?? "", dirs: ctx.query.type, limit: ctx.query.limit }), if (path === "/api/command") return json(route, { location: location(config), data: [] })
), if (path === "/api/skill") return json(route, { location: location(config), data: [] })
).pipe( if (path === "/api/plugin") return json(route, { location: location(config), data: [] })
Effect.map((entries) => ({ if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
location: location(config), if (path === "/api/mcp/resource")
data: Array.isArray(entries) return json(route, { location: location(config), data: { resources: [], templates: [] } })
? entries.map((entry) => const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
typeof entry === "string" if (integration && route.request().method() === "GET")
? { return json(route, {
name: entry.split(/[\\/]/).at(-1) ?? entry, location: location(config),
path: entry, data: {
absolute: `${config.directory}/${entry}`, id: integration,
type: "directory", name: integration,
ignored: false, methods: config.integrationMethods?.[integration] ?? [{ type: "key", label: "API key" }],
} connections: [],
: entry, },
) })
: entries, const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
})), if (integrationConnect && route.request().method() === "POST") {
), config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
shell: () => Effect.succeed({ location: location(config), data: [] }), return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
ptyConnectToken: () => }
Effect.succeed({ location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }), if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
sessionList: (ctx) => { return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
const sessions = config.sessions if (path === "/api/project") {
.filter((session) => { const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
const location = session.location as { directory?: string } | undefined return json(route, [
return ( {
!ctx.query.directory || ...project,
location?.directory === ctx.query.directory || canonical: project.canonical ?? project.worktree ?? config.directory,
session.directory === ctx.query.directory },
) ])
}) }
.filter((session) => { if (path === "/api/project/current")
if (ctx.query.parentID === undefined) return true return json(route, {
if (ctx.query.parentID === "null") return session.parentID === undefined id: (config.project as { id?: string }).id,
return session.parentID === ctx.query.parentID directory: config.directory,
}) canonical: config.directory,
.filter((session) => })
ctx.query.search === undefined const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1]
? true if (worktree && route.request().method() === "GET")
: String(session.title ?? "") return json(route, [
.toLowerCase() { directory: config.directory },
.includes(ctx.query.search.toLowerCase()), ...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
directory,
strategy: "git",
})),
])
if (path === "/api/location") return json(route, location(config))
if (worktree && route.request().method() === "POST") {
const input = route.request().postDataJSON() as { directory: string; name?: string }
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
}
if (worktree && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/worktree\/[^/]+\/refresh$/.test(path))
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/permission/request")
return json(route, {
location: location(config),
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
currentPermission,
),
})
if (path === "/api/form/request")
return json(route, {
location: location(config),
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
})
if (path === "/api/vcs")
return json(route, { location: location(config), data: { branch: { current: "main", default: "main" } } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
if (path === "/api/fs/list" && config.fileList)
return json(route, {
location: location(config),
data: await config.fileList(url.searchParams.get("path") ?? ""),
})
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
if (fileRead && config.fileContent) {
const value = await config.fileContent(decodeURIComponent(fileRead))
const content =
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
}
if (path === "/api/fs/find" && config.findFiles) {
const entries = await config.findFiles({
query: url.searchParams.get("query") ?? "",
dirs: url.searchParams.get("type") ?? undefined,
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
})
return json(route, {
location: location(config),
data: Array.isArray(entries)
? entries.map((entry) =>
typeof entry === "string"
? {
name: entry.split(/[\\/]/).at(-1) ?? entry,
path: entry,
absolute: `${config.directory}/${entry}`,
type: "directory",
ignored: false,
}
: entry,
) )
const ordered = ctx.query.order === "asc" ? sessions : sessions.toReversed() : entries,
const offset = Number(ctx.query.cursor ?? 0) })
const limit = ctx.query.limit ?? 50 }
const data = ordered.slice(offset, offset + limit) if (path === "/api/shell" && route.request().method() === "GET")
return Effect.succeed({ return json(route, { location: location(config), data: [] })
data: data.map((session) => currentSession(session, config.directory)), if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
cursor: { next: offset + limit < ordered.length ? String(offset + limit) : undefined }, return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
}) if (path === "/api/session") {
}, if (route.request().method() === "POST") {
sessionCreate: (ctx) => { const payload = route.request().postDataJSON() as Record<string, unknown>
const payload = record(ctx.payload) ? ctx.payload : {} const created = currentSession(
const created = currentSession( {
{ id: "ses_mock_created",
id: "ses_mock_created", projectID: (config.project as { id?: string }).id,
projectID: (config.project as { id?: string }).id, title: typeof payload.title === "string" ? payload.title : "New session",
title: typeof payload.title === "string" ? payload.title : "New session", parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
parentID: typeof payload.parentID === "string" ? payload.parentID : undefined, },
}, config.directory,
config.directory, )
config.sessions.push(created)
return json(route, { data: created })
}
if (route.request().method() !== "GET") return route.fallback()
const directory = url.searchParams.get("directory")
const parentID = url.searchParams.get("parentID")
const limit = Number(url.searchParams.get("limit") ?? 50)
const offset = Number(url.searchParams.get("cursor") ?? 0)
const sessions = config.sessions
.filter((session) => {
const location = session.location as { directory?: string } | undefined
return !directory || location?.directory === directory || session.directory === directory
})
.filter((session) => {
if (parentID === null) return true
if (parentID === "null") return session.parentID === undefined
return session.parentID === parentID
})
.filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase()
return (
!search ||
String(session.title ?? "")
.toLowerCase()
.includes(search)
) )
return Effect.sync(() => config.sessions.push(created)).pipe(Effect.as({ data: created })) })
}, const ordered = url.searchParams.get("order") === "asc" ? sessions : sessions.toReversed()
sessionActive: () => { const data = ordered.slice(offset, offset + limit)
const statuses = ( const next = offset + limit < ordered.length ? String(offset + limit) : undefined
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}) return json(route, {
) as Record<string, { type?: string }> data: data.map((session) => currentSession(session, config.directory)),
return Effect.succeed({ cursor: { next },
data: Object.fromEntries( })
Object.entries(statuses).flatMap(([id, status]) => }
status.type === "idle" ? [] : [[id, { type: "running" }]], if (path === "/api/session/active") {
), const statuses = (
), typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
}) ) as Record<string, { type?: string }>
}, return json(route, {
sessionGet: (ctx) => { data: Object.fromEntries(
const session = config.sessions.find((item) => item.id === ctx.params.sessionID) Object.entries(statuses).flatMap(([id, status]) =>
return session status.type === "idle" ? [] : [[id, { type: "running" }]],
? Effect.succeed({ data: currentSession(session, config.directory) }) ),
: Effect.fail(new MockNotFound({ message: "Session not found" })) ),
}, })
sessionRemove: () => noContent, }
sessionShell: () => noContent, if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
sessionForm: (ctx) => { return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? []) }
return Effect.succeed({ const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1]
data: forms.filter((form) => (form as { sessionID?: string }).sessionID === ctx.params.sessionID), if (sessionForm && route.request().method() === "GET") {
}) const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
}, return json(route, { data: forms.filter((form) => (form as { sessionID?: string }).sessionID === sessionForm) })
sessionFormReply: () => noContent, }
sessionFormCancel: () => noContent, if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path) && route.request().method() === "POST") {
sessionBackground: () => noContent, return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
sessionInbox: () => Effect.succeed({ data: [] }), }
sessionPermissionReply: () => noContent, if (/^\/api\/session\/[^/]+\/background$/.test(path) && route.request().method() === "POST")
sessionRename: () => noContent, return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
sessionInterrupt: () => noContent, if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET")
sessionRevertClear: () => noContent, return json(route, { data: [] })
sessionRevertCommit: () => noContent, const sessionPermission = path.match(/^\/api\/session\/([^/]+)\/permission$/)?.[1]
messageGet: (ctx) => if (sessionPermission && route.request().method() === "GET") {
Effect.gen(function* () { const permissions = typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
config.onMessage?.({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }) return json(route, {
yield* delay data: permissions.map(currentPermission).filter((permission) => permission.sessionID === sessionPermission),
const message = })
config.message?.(ctx.params.sessionID, ctx.params.messageID) ?? }
config if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
.pageMessages(ctx.params.sessionID, Number.MAX_SAFE_INTEGER) return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
.items.find((item) => item.id === ctx.params.messageID) }
if (!message) return yield* new MockNotFound({ message: "Message not found" }) if (
return { data: message } /^\/api\/session\/[^/]+\/(rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
}), route.request().method() === "POST"
messageList: (ctx) => { ) {
const token = ctx.query.cursor return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
const before = token ? state.cursors.get(token) : undefined }
if (token && !before) return Effect.fail(new MockBadRequest({ message: "Invalid cursor" })) if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
return Effect.gen(function* () { return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "start" }) }
if (config.beforeMessagesResponse) { const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
yield* Effect.promise(() => config.beforeMessagesResponse!({ sessionID: ctx.params.sessionID, before })) if (currentSessionMatch) {
} const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
yield* delay if (!session) return json(route, { error: "Session not found" }, undefined, 404)
const pageData = config.pageMessages(ctx.params.sessionID, ctx.query.limit ?? 50, before) return json(route, {
config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "end" }) data: currentSession(session, config.directory),
const cursor = pageData.cursor ? `cursor_${++state.nextCursor}` : undefined })
if (cursor) state.cursors.set(cursor, pageData.cursor!) }
return {
data: ctx.query.order === "asc" ? pageData.items : pageData.items.toReversed(), const messageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
cursor: { next: cursor }, if (messageMatch) {
} config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
}) if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
}, const message =
}), config.message?.(messageMatch[1]!, messageMatch[2]!) ??
) config.pageMessages(messageMatch[1]!, Number.MAX_SAFE_INTEGER).items.find((item) => item.id === messageMatch[2])
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
return json(route, { data: message })
}
const messagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
if (messagesMatch) {
const token = url.searchParams.get("cursor") ?? undefined
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
if (cursor) cursors.set(cursor, pageData.cursor!)
return json(route, {
data: url.searchParams.get("order") === "asc" ? pageData.items : pageData.items.toReversed(),
cursor: { next: cursor },
})
}
if (url.port === targetPort && targetPort !== appPort)
return json(route, { error: `Unhandled mock route: ${path}` }, undefined, 404)
return route.fallback()
})
} }
function location(config: MockServerConfig) { function location(config: MockServerConfig) {
@@ -592,3 +585,24 @@ function jsonValue(value: unknown): JsonValue | undefined {
function record(value: unknown): value is Record<string, unknown> { function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value) return !!value && typeof value === "object" && !Array.isArray(value)
} }
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-expose-headers": "x-next-cursor",
...headers,
},
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route, events?: unknown[], retry?: number) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
})
}
+1 -1
View File
@@ -148,7 +148,7 @@ export async function installSseTransport<T extends OpenCodeEvent = OpenCodeEven
})) }))
encoded.forEach((item) => marker(item.delivery.options?.marker)) encoded.forEach((item) => marker(item.delivery.options?.marker))
if (input.burst) { if (input.burst) {
const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join("")) const bytes = encoder.encode(encoded.map((item) => new TextDecoder().decode(item.bytes)).join(""))
connection.controller.enqueue(bytes) connection.controller.enqueue(bytes)
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id)) return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import { expect, type Locator, type Page } from "@playwright/test" import { expect, type Locator, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
export const APP_READY_TIMEOUT = 30_000 export const APP_READY_TIMEOUT = 30_000
-1
View File
@@ -56,7 +56,6 @@
"@dnd-kit/solid": "0.5.0", "@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/session-ui": "workspace:*", "@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
+18 -23
View File
@@ -34,9 +34,9 @@ import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServersProvider } from "@/context/servers" import { ServerConnection, ServersProvider } from "@/context/servers"
import { SettingsProvider } from "@/context/settings" import { SettingsProvider } from "@/context/settings"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider } from "@/context/sdk" import { LocationProvider } from "@/context/location"
import { WslServersProvider } from "@/wsl/context" import { WslServersProvider } from "@/wsl/context"
import { DirectoryDataProvider } from "@/pages/directory-layout" import { SessionUIProvider } 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 { requireServerKey } from "./utils/session-route" import { requireServerKey } from "./utils/session-route"
@@ -48,19 +48,16 @@ import { ServerProvider } from "./context/server"
const NewSession = lazy(() => import("@/pages/new-session")) const NewSession = lazy(() => import("@/pages/new-session"))
function TargetServerRoute(props: ParentProps) { function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string; id: string }>() const params = useParams<{ serverKey: string }>()
const global = useGlobal() const global = useGlobal()
const conn = createMemo(() => { const conn = createMemo(() =>
const key = requireServerKey(params.serverKey) global.servers.list().find((item) => ServerConnection.key(item) === requireServerKey(params.serverKey)),
return global.servers.list().find((item) => ServerConnection.key(item) === key) )
})
return ( return (
// Owns the server-identity remount. Session changes must not remount this subtree. // Owns the server-identity remount. Session changes must not remount this subtree.
<Show when={requireServerKey(params.serverKey)} keyed> <Show when={conn()} keyed>
<Show when={conn()} keyed> {(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
</Show>
</Show> </Show>
) )
} }
@@ -69,14 +66,12 @@ function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs() const tabs = useTabs()
return ( return (
<Show when={tabs.ready()}> <Show
<Show when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)} keyed
keyed fallback={tabs.ready() && <Navigate href="/" />}
fallback={<Navigate href="/" />} >
> {(draft) => <ResolvedDraftRoute draft={draft} />}
{(draft) => <ResolvedDraftRoute draft={draft} />}
</Show>
</Show> </Show>
) )
} }
@@ -91,13 +86,13 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
{(conn) => ( {(conn) => (
<ServerProvider conn={conn}> <ServerProvider conn={conn}>
<ModelsProvider directory={props.draft.directory}> <ModelsProvider directory={props.draft.directory}>
<SDKProvider directory={props.draft.directory}> <LocationProvider directory={props.draft.directory}>
<DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}> <SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
<DraftProviders> <DraftProviders>
<NewSession draftId={props.draft.draftID} /> <NewSession draftId={props.draft.draftID} />
</DraftProviders> </DraftProviders>
</DirectoryDataProvider> </SessionUIProvider>
</SDKProvider> </LocationProvider>
</ModelsProvider> </ModelsProvider>
</ServerProvider> </ServerProvider>
)} )}
@@ -1,4 +1,4 @@
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/util/path"
import type { Project } from "@/types" import type { Project } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise" import type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -1,4 +1,4 @@
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2" import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
@@ -13,7 +13,6 @@ import { type Component, createMemo, createUniqueId, For, Match, onMount, Show,
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/components/external-link" import { ExternalLink } from "@/components/external-link"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useIntegrations } from "@/hooks/use-integrations" import { useIntegrations } from "@/hooks/use-integrations"
@@ -258,7 +257,6 @@ function ProviderConnection(props: {
setBack: (handler: () => void) => void setBack: (handler: () => void) => void
}) { }) {
const dialog = useDialog() const dialog = useDialog()
const serverSync = useServerSync()
const params = useParams() const params = useParams()
const language = useLanguage() const language = useLanguage()
const providers = useProviders(() => props.directory) const providers = useProviders(() => props.directory)
@@ -279,11 +277,7 @@ function ProviderConnection(props: {
}) })
const provider = createMemo(() => ({ const provider = createMemo(() => ({
id: props.provider, id: props.provider,
name: name: providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
providers.all().get(props.provider)?.name ??
serverSync.data.provider.all.get(props.provider)?.name ??
controller.integration()?.name ??
props.provider,
})) }))
const methodLabel = (value?: { type?: string; label?: string }) => { const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return "" if (!value) return ""
@@ -9,7 +9,7 @@ import { showToast } from "@/utils/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 { ExternalLink } from "@/components/external-link" import { ExternalLink } from "@/components/external-link"
import { useServerSync } from "@/context/server-sync" import { useData } from "@/context/server"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form" import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
@@ -41,7 +41,7 @@ export function DialogCustomProvider(props: Props) {
export function CustomProviderForm(props: { autofocus?: boolean } = {}) { export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const dialog = useDialog() const dialog = useDialog()
const serverSync = useServerSync() const data = useData()
const language = useLanguage() const language = useLanguage()
const [form, setForm] = createStore<FormState>({ const [form, setForm] = createStore<FormState>({
@@ -116,8 +116,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const output = validateCustomProvider({ const output = validateCustomProvider({
form, form,
t: language.t, t: language.t,
disabledProviders: serverSync.data.config.disabled_providers ?? [], // TODO: Restore disabled-provider validation when V2 exposes config reads.
existingProviderIDs: new Set(serverSync.data.provider.all.keys()), disabledProviders: [],
existingProviderIDs: new Set((data.location.provider.list() ?? []).map((provider) => provider.id)),
}) })
batch(() => { batch(() => {
setForm("err", output.err) setForm("err", output.err)
@@ -11,7 +11,7 @@ import { For, Show, createSignal, startTransition } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout" import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/servers" import { ServerConnection } from "@/context/servers"
import { SDKProvider } from "@/context/sdk" import { LocationProvider } from "@/context/location"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { createEditProjectModel } from "./edit-project" import { createEditProjectModel } from "./edit-project"
import { ProjectSettingsExtensions } from "./project-settings-extensions" import { ProjectSettingsExtensions } from "./project-settings-extensions"
@@ -22,9 +22,9 @@ import "./dialog-edit-project-v2.css"
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) { export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
return ( return (
<SettingsServerDataScope server={props.server}> <SettingsServerDataScope server={props.server}>
<SDKProvider directory={props.project.worktree}> <LocationProvider directory={props.project.worktree}>
<ProjectSettingsDialog project={props.project} server={props.server} /> <ProjectSettingsDialog project={props.project} server={props.server} />
</SDKProvider> </LocationProvider>
</SettingsServerDataScope> </SettingsServerDataScope>
) )
} }
+31 -22
View File
@@ -1,16 +1,16 @@
import { Component, createMemo } from "solid-js" import { Component, createMemo } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { useSync } from "@/context/sync" import { useData } from "@/context/server"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt" 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 "@/utils/toast"
import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@/types"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { base64Encode } from "@opencode-ai/util/encode"
import { extractPromptComments, extractPromptFromMessage } from "@/utils/prompt"
import { useWorkspaceLocation } from "@/context/location"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { sessionHref } from "@/utils/session-route" import { sessionHref } from "@/utils/session-route"
@@ -27,8 +27,9 @@ function formatTime(date: Date): string {
export const DialogFork: Component = () => { export const DialogFork: Component = () => {
const params = useParams() const params = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const sync = useSync() const data = useData()
const sdk = useSDK() const serverSDK = useServerSDK()
const location = useWorkspaceLocation()
const prompt = usePrompt() const prompt = usePrompt()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
@@ -38,19 +39,15 @@ 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 = data.session.message.list(sessionID)
const result: ForkableMessage[] = [] const result: ForkableMessage[] = []
for (const message of msgs) { for (const message of msgs) {
if (message.role !== "user") continue if (message.type !== "user" || !message.text) continue
const parts = sync().data.part[message.id] ?? []
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
if (!textPart) continue
result.push({ result.push({
id: message.id, id: message.id,
text: textPart.text.replace(/\n/g, " ").slice(0, 200), text: message.text.replace(/\n/g, " ").slice(0, 200),
time: formatTime(new Date(message.time.created)), time: formatTime(new Date(message.time.created)),
}) })
} }
@@ -63,19 +60,31 @@ export const DialogFork: Component = () => {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return if (!sessionID) return
const message = data.session.message.get(sessionID, item.id)
const parts = sync().data.part[item.id] ?? [] if (message?.type !== "user") return
const restored = extractPromptFromParts(parts, { const restored = extractPromptFromMessage(message, {
directory: sdk().directory, directory: location().directory,
attachmentName: language.t("common.attachment"), attachmentName: language.t("common.attachment"),
}) })
const dir = base64Encode(sdk().directory) const dir = base64Encode(location().directory)
sdk() serverSDK.api.session
.api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } }) .fork({ sessionID, boundary: { type: "before", messageID: item.id } })
.then((forked) => { .then((forked) => {
data.session.remember(forked)
dialog.close() dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id }) const target = prompt.capture({ dir, id: forked.id })
target.set(restored)
target.context.replaceComments(
extractPromptComments(message).map((comment) => ({
type: "file",
path: comment.path,
selection: comment.selection,
comment: comment.comment,
preview: comment.preview,
commentOrigin: comment.origin,
})),
)
navigate(sessionHref(server.key, forked.id)) navigate(sessionHref(server.key, forked.id))
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
@@ -29,7 +29,7 @@ import {
} from "./directory-picker-domain" } from "./directory-picker-domain"
import "./dialog-select-directory-v2.css" import "./dialog-select-directory-v2.css"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/util/path"
interface DialogSelectDirectoryV2Props { interface DialogSelectDirectoryV2Props {
title?: string title?: string
@@ -3,7 +3,7 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind" import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js" import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
import { formatKeybind } from "@/context/command" import { formatKeybind } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
@@ -1,5 +1,6 @@
import { Component, createMemo, Show } from "solid-js" import { Component, createMemo, Show } from "solid-js"
import { useSync } from "@/context/sync" import { useData } from "@/context/server"
import { useWorkspaceLocation } from "@/context/location"
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"
@@ -14,12 +15,13 @@ const statusLabels = {
} as const } as const
export const DialogSelectMcp: Component = () => { export const DialogSelectMcp: Component = () => {
const sync = useSync() const data = useData()
const sdk = useWorkspaceLocation()
const language = useLanguage() const language = useLanguage()
const items = createMemo(() => const items = createMemo(() =>
Object.entries(sync().data.mcp ?? {}) (data.location.mcp.server.list({ directory: sdk().directory }) ?? [])
.map(([name, status]) => ({ name, status: status.status })) .map((server) => ({ name: server.name, status: server.status.status }))
.sort((a, b) => a.name.localeCompare(b.name)), .sort((a, b) => a.name.localeCompare(b.name)),
) )
@@ -47,7 +49,9 @@ export const DialogSelectMcp: Component = () => {
}} }}
> >
{(i) => { {(i) => {
const mcpStatus = () => sync().data.mcp[i.name] const mcpStatus = () =>
data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name)
?.status
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
@@ -82,7 +82,7 @@ function ServerForm(props: ServerFormProps) {
type="text" type="text"
label={language.t("dialog.server.add.name")} label={language.t("dialog.server.add.name")}
placeholder={language.t("dialog.server.add.namePlaceholder")} placeholder={language.t("dialog.server.add.namePlaceholder")}
value={props.name} defaultValue={props.name}
disabled={props.busy} disabled={props.busy}
onChange={props.onNameChange} onChange={props.onNameChange}
onKeyDown={keyDown} onKeyDown={keyDown}
@@ -92,7 +92,7 @@ function ServerForm(props: ServerFormProps) {
type="text" type="text"
label={language.t("dialog.server.add.username")} label={language.t("dialog.server.add.username")}
placeholder={language.t("dialog.server.add.usernamePlaceholder")} placeholder={language.t("dialog.server.add.usernamePlaceholder")}
value={props.username} defaultValue={props.username}
disabled={props.busy} disabled={props.busy}
onChange={props.onUsernameChange} onChange={props.onUsernameChange}
onKeyDown={keyDown} onKeyDown={keyDown}
@@ -101,7 +101,7 @@ function ServerForm(props: ServerFormProps) {
type="password" type="password"
label={language.t("dialog.server.add.password")} label={language.t("dialog.server.add.password")}
placeholder={language.t("dialog.server.add.passwordPlaceholder")} placeholder={language.t("dialog.server.add.passwordPlaceholder")}
value={props.password} defaultValue={props.password}
disabled={props.busy} disabled={props.busy}
onChange={props.onPasswordChange} onChange={props.onPasswordChange}
onKeyDown={keyDown} onKeyDown={keyDown}
@@ -245,7 +245,7 @@ export function nativePickerPath(path: string) {
if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\") if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\")
return value return value
} }
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/util/path"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { ServerSDK } from "@/context/server-sdk" import { ServerSDK } from "@/context/server-sdk"
+1 -1
View File
@@ -1,4 +1,4 @@
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import { normalizeProjectInfo } from "@/context/global-sync/utils" import { normalizeProjectInfo } from "@/context/global-sync/utils"
@@ -1,13 +1,12 @@
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js" import { type Component, For, Show, createEffect, createMemo, createResource, createSignal } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useMcpToggle } from "@/context/mcp" import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk" import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useData } from "@/context/server"
import { useSync } from "@/context/sync"
import { ExternalLink } from "./external-link" import { ExternalLink } from "./external-link"
type SkillItem = { type SkillItem = {
@@ -15,7 +14,6 @@ type SkillItem = {
location: string location: string
} }
const pluginName = (item: string | [string, Record<string, unknown>]) => (typeof item === "string" ? item : item[0])
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}` const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
const ExtensionCard: Component<{ children: unknown }> = (props) => ( const ExtensionCard: Component<{ children: unknown }> = (props) => (
@@ -23,9 +21,8 @@ const ExtensionCard: Component<{ children: unknown }> = (props) => (
) )
const ExtensionRow: Component<{ const ExtensionRow: Component<{
icon: "mcp" | "cube" | "post-skill" | "code" icon: "mcp" | "cube" | "post-skill"
name: string name: string
status?: string
children?: unknown children?: unknown
}> = (props) => ( }> = (props) => (
<div class="project-settings-extension-row"> <div class="project-settings-extension-row">
@@ -33,14 +30,6 @@ const ExtensionRow: Component<{
<Icon name={props.icon} class="project-settings-extension-row-icon" /> <Icon name={props.icon} class="project-settings-extension-row-icon" />
<span class="project-settings-extension-row-name">{props.name}</span> <span class="project-settings-extension-row-name">{props.name}</span>
</div> </div>
<Show when={props.status}>
{(status) => (
<span class="project-settings-extension-row-status">
<span class="project-settings-extension-row-status-dot" />
{status()}
</span>
)}
</Show>
{props.children as any} {props.children as any}
</div> </div>
) )
@@ -75,56 +64,55 @@ const SharedSection: Component<{
export const ProjectSettingsExtensions: Component = () => { export const ProjectSettingsExtensions: Component = () => {
const language = useLanguage() const language = useLanguage()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const directorySDK = useSDK() const directorySDK = useWorkspaceLocation()
const serverSync = useServerSync() const data = useData()
const sync = useSync() const toggleMcp = useMcpToggle(() => directorySDK().directory)
const toggleMcp = useMcpToggle()
createEffect(() => {
if (serverSDK.connection.status() !== "connected") return
const ref = { directory: directorySDK().directory }
void Promise.all([
data.location.mcp.server.sync(),
data.location.skill.sync(),
data.location.mcp.server.sync(ref),
data.location.skill.sync(ref),
]).catch(() => undefined)
})
const [serverMcp] = createResource(
serverSDK,
(sdk) =>
sdk.api.mcp
.list()
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
.catch(() => ({})),
{ initialValue: {} },
)
const globalMcpNames = createMemo(() => const globalMcpNames = createMemo(() =>
[...new Set([...Object.keys(serverSync.data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(), [...new Set((data.location.mcp.server.list() ?? []).map((server) => server.name))].sort(),
) )
const projectMcpNames = createMemo(() => { const projectMcpNames = createMemo(() => {
const shared = new Set(globalMcpNames()) const shared = new Set(globalMcpNames())
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name)) return (data.location.mcp.server.list({ directory: directorySDK().directory }) ?? [])
if (configured.length > 0) return configured.sort() .map((server) => server.name)
return Object.keys(sync().data.mcp ?? {})
.filter((name) => !shared.has(name)) .filter((name) => !shared.has(name))
.sort() .sort()
}) })
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected" const mcpEnabled = (name: string) =>
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)
?.status.status === "connected"
const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName)) const [globalPluginList] = createResource(
() => serverSDK.connection.status() === "connected",
() => serverSDK.api.plugin.list().then((result) => result.data),
)
const [projectPluginList] = createResource(
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id))
const projectPlugins = createMemo(() => { const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins()) const shared = new Set(globalPlugins())
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name)) return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name))
}) })
const [serverSkills] = createResource( const serverSkills = createMemo(() => data.location.skill.list() ?? [])
serverSDK,
(sdk): Promise<SkillItem[]> =>
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
{ initialValue: [] },
)
const [directorySkills] = createResource(
directorySDK,
(sdk): Promise<SkillItem[]> =>
sdk.api.skill
.list({ location: { directory: sdk.directory } })
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
{ initialValue: [] },
)
const projectSkills = createMemo(() => { const projectSkills = createMemo(() => {
const shared = new Set(serverSkills.latest.map(skillKey)) const shared = new Set(serverSkills().map(skillKey))
return directorySkills.latest.filter((item) => !shared.has(skillKey(item))) return (data.location.skill.list({ directory: directorySDK().directory }) ?? []).filter(
(item) => !shared.has(skillKey(item)),
)
}) })
const mcpRows = (items: string[]) => ( const mcpRows = (items: string[]) => (
@@ -165,7 +153,7 @@ export const ProjectSettingsExtensions: Component = () => {
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger> <TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger> <TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger> <TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
<TabsV2.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</TabsV2.Trigger> {/* TODO: Restore LSP status when V2 exposes it. */}
</TabsV2.List> </TabsV2.List>
<TabsV2.Content value="mcps"> <TabsV2.Content value="mcps">
@@ -205,31 +193,7 @@ export const ProjectSettingsExtensions: Component = () => {
<Show when={projectSkills().length > 0}> <Show when={projectSkills().length > 0}>
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard> <ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
</Show> </Show>
<SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection> <SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
</div>
</TabsV2.Content>
<TabsV2.Content value="lsps">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.lsp.detected")}</span>
<span>{language.t("project.settings.extensions.lsp.description")}</span>
</div>
<Show when={sync().data.lsp.length > 0}>
<ExtensionCard>
<For each={sync().data.lsp}>
{(item) => (
<ExtensionRow
icon="code"
name={item.name || item.id}
status={
item.status === "error" ? language.t("project.settings.extensions.setupRequired") : undefined
}
/>
)}
</For>
</ExtensionCard>
</Show>
</div> </div>
</TabsV2.Content> </TabsV2.Content>
</TabsV2> </TabsV2>
+12 -15
View File
@@ -22,8 +22,8 @@ import { useLayout } from "@/context/layout"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt" import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk" import { useWorkspaceLocation } from "@/context/location"
import { useSync } from "@/context/sync" import { useData } from "@/context/server"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input" import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
@@ -81,8 +81,8 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
} }
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController { export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
const sdk = useSDK() const sdk = useWorkspaceLocation()
const sync = useSync() const data = useData()
const files = useFile() const files = useFile()
const layout = useLayout() const layout = useLayout()
const comments = useComments() const comments = useComments()
@@ -113,8 +113,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
return [...result, path] return [...result, path]
}, []) }, [])
}) })
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined))
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running")
const attachments = createMemo(() => const attachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
) )
@@ -228,8 +228,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
const referenceDescription = (reference: ReferenceInfo) => const referenceDescription = (reference: ReferenceInfo) =>
reference.source.type === "git" ? reference.source.repository : reference.source.path reference.source.type === "git" ? reference.source.repository : reference.source.path
const references = createMemo(() => const references = createMemo(() =>
sync() (data.location.reference.list({ directory: sdk().directory }) ?? [])
.data.reference.filter((reference) => !reference.hidden) .filter((reference) => !reference.hidden)
.map((reference) => ({ .map((reference) => ({
id: `reference:${reference.name}`, id: `reference:${reference.name}`,
kind: "reference" as const, kind: "reference" as const,
@@ -248,7 +248,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
})), })),
) )
const resources = createMemo(() => const resources = createMemo(() =>
Object.values(sync().data.mcp_resource).map((resource) => ({ (data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).map((resource) => ({
id: `resource:${resource.server}:${resource.uri}`, id: `resource:${resource.server}:${resource.uri}`,
kind: "resource" as const, kind: "resource" as const,
label: `@${resource.name}`, label: `@${resource.name}`,
@@ -294,7 +294,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
})), })),
]) ])
const slashCommands = createMemo(() => [ const slashCommands = createMemo(() => [
...sync().data.command.map((item) => ({ ...(data.location.command.list({ directory: sdk().directory }) ?? []).map((item) => ({
id: `custom.${item.name}`, id: `custom.${item.name}`,
trigger: item.name, trigger: item.name,
title: item.name, title: item.name,
@@ -354,7 +354,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />), dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
openContext(key) { openContext(key) {
const item = controller.contextItem(key) const item = controller.contextItem(key)
if (item) openComment(item, props, sync, layout, files, comments) if (item) openComment(item, props, layout, files, comments)
}, },
onEditor(element) { onEditor(element) {
editor = element as HTMLDivElement editor = element as HTMLDivElement
@@ -557,7 +557,6 @@ function PromptInputV2ModelControl(props: {
function openComment( function openComment(
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }, item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
props: PromptInputV2ControllerProps, props: PromptInputV2ControllerProps,
sync: ReturnType<typeof useSync>,
layout: ReturnType<typeof useLayout>, layout: ReturnType<typeof useLayout>,
files: ReturnType<typeof useFile>, files: ReturnType<typeof useFile>,
comments: ReturnType<typeof useComments>, comments: ReturnType<typeof useComments>,
@@ -575,9 +574,7 @@ function openComment(
}) })
}) })
} }
const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined const review = item.commentOrigin === "review"
const review =
item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
if (review) { if (review) {
layout.fileTree.setTab("changes") layout.fileTree.setTab("changes")
@@ -1,8 +1,6 @@
// @ts-nocheck // @ts-nocheck
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { Todo } from "@/types"
import { createPromptState } from "@/context/prompt" import { createPromptState } from "@/context/prompt"
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
import { createPromptInputHistory, PromptInput } from "./prompt-input" import { createPromptInputHistory, PromptInput } from "./prompt-input"
function createPromptInputStoryRuntime() { function createPromptInputStoryRuntime() {
@@ -111,93 +109,6 @@ function PromptInputExample() {
) )
} }
const todos: Todo[] = [
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
]
function PromptInputWithOpenDock() {
const input = createPromptInputStoryRuntime()
const [controls, setControls] = createStore({
agent: "build",
activeTab: undefined as string | undefined,
todoCollapsed: false,
})
const inputControls = {
agents: {
available: [],
options: ["build"],
get current() {
return controls.agent
},
loading: false,
visible: true,
select: (agent?: string) => setControls("agent", agent ?? "build"),
},
model: {
selection: {
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
variant: { list: () => [], current: () => undefined, set: () => {} },
},
paid: true,
loading: false,
},
session: {
id: "story-session",
tabs: {
active: () => controls.activeTab,
all: () => [],
open: () => {},
setActive: (tab: string) => setControls("activeTab", tab),
},
reviewPanel: { opened: () => false, open: () => {} },
},
}
const state = {
blocked: () => false,
questionRequest: () => undefined,
permissionRequest: () => undefined,
permissionResponding: () => false,
decide: () => {},
todos: () => todos,
dock: () => true,
closing: () => false,
opening: () => false,
}
return (
<SessionComposerRegion
controller={createSessionComposerRegionController({
state,
sessionKey: () => "story-session",
sessionID: () => "story-session",
prompt: input.state,
ready: () => true,
centered: () => false,
todo: {
collapsed: () => controls.todoCollapsed,
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
},
followup: () => undefined,
revert: () => undefined,
onResponseSubmit: () => {},
openParent: () => {},
setPromptRef: () => {},
setDockRef: () => {},
})}
promptInput={
<PromptInput
controls={inputControls}
{...input}
ref={() => {}}
newSessionWorktree=""
onNewSessionWorktreeReset={() => {}}
/>
}
/>
)
}
export default { export default {
title: "App/PromptInput", title: "App/PromptInput",
id: "app-prompt-input", id: "app-prompt-input",
@@ -212,12 +123,3 @@ export const Basic = {
</div> </div>
), ),
} }
export const DockAlreadyOpen = {
render: () => (
<div class="pt-10">
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
<PromptInputWithOpenDock />
</div>
),
}
@@ -1,4 +1,4 @@
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/util/path"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path" import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
@@ -11,18 +11,6 @@ type SessionCreateInput = {
model?: { id: string; providerID: string; variant?: string } model?: { id: string; providerID: string; variant?: string }
location?: { directory: string } location?: { directory: string }
} }
const admitted: Array<{
directory?: string
sessionID: string
messageID: string
text: string
displayText: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
comments: unknown[]
}> = []
const confirmed: unknown[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = [] const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const sentShellDirectories: string[] = [] const sentShellDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = [] const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
@@ -36,13 +24,10 @@ const switchedModels: Array<{
}> = [] }> = []
const sessionRequestOrder: string[] = [] const sessionRequestOrder: string[] = []
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = [] const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
const syncedServers: string[] = []
const admittedServers: string[] = []
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = [] const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
const navigations: string[] = [] const navigations: string[] = []
let serverSessionSyncs = 0 let serverSessionSyncs = 0
let restoredPrompts = 0 let restoredPrompts = 0
let clearEchoCalls = 0
let params: { id?: string } = {} let params: { id?: string } = {}
let search: { draftId?: string } = {} let search: { draftId?: string } = {}
@@ -53,12 +38,9 @@ let createWorktreeGate: Promise<void> | undefined
let worktreeFailure: Error | undefined let worktreeFailure: Error | undefined
let locationFailure: Error | undefined let locationFailure: Error | undefined
let promptFailure: Error | undefined let promptFailure: Error | undefined
let clearEchoResult = true
let worktreeCreates = 0 let worktreeCreates = 0
let activeSDK = "server-a" let activeSDK = "server-a"
let activeServer = "server-a" let activeServer = "server-a"
let activeServerSync = "server-a"
let activeDirectorySync = "server-a"
let commands: Array<{ name: string }> = [] let commands: Array<{ name: string }> = []
let worktreeDirectory = "/repo/new-0" let worktreeDirectory = "/repo/new-0"
let worktreeID = 0 let worktreeID = 0
@@ -191,7 +173,7 @@ beforeAll(async () => {
showToast: () => 0, showToast: () => 0,
})) }))
mock.module("@opencode-ai/core/util/encode", () => ({ mock.module("@opencode-ai/util/encode", () => ({
base64Decode: (value: string) => value, base64Decode: (value: string) => value,
base64Encode: (value: string) => value, base64Encode: (value: string) => value,
checksum: (value: string) => value, checksum: (value: string) => value,
@@ -217,10 +199,6 @@ beforeAll(async () => {
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) } return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
}) })
mock.module("@/context/server", () => ({
useServer: () => ({ key: activeServer }),
}))
mock.module("@/context/tabs", () => ({ mock.module("@/context/tabs", () => ({
useTabs: () => ({ useTabs: () => ({
updateDraft: (draftID: string, draft: { worktree?: string }) => { updateDraft: (draftID: string, draft: { worktree?: string }) => {
@@ -236,83 +214,35 @@ beforeAll(async () => {
usePrompt: () => prompt, usePrompt: () => prompt,
})) }))
mock.module("@/context/sdk", () => ({ mock.module("@/context/location", () => ({
useSDK: () => { useWorkspaceLocation: () => {
return () => ({ return () => ({
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other", directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
api: rootClient.api,
url: "http://localhost:4096",
}) })
}, },
})) }))
mock.module("@/context/sync", () => ({ mock.module("@/context/server-sdk", () => ({
useSync: () => () => { useServerSDK: () => ({
const server = activeDirectorySync scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
return { api: rootClient.api,
data: { command: commands, project: "project" }, }),
session: {
inbox: {
echo: (value: {
directory?: string
sessionID: string
messageID: string
text: string
displayText: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
comments: unknown[]
}) => {
admittedServers.push(server)
admitted.push(value)
},
confirm: (value: unknown) => {
confirmed.push(value)
},
clearEcho: () => {
clearEchoCalls++
return clearEchoResult
},
},
},
set: () => undefined,
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
}
},
})) }))
mock.module("@/context/server-sync", () => ({ mock.module("@/context/server", () => ({
useServerSync: () => { useServer: () => ({ key: activeServer }),
const server = activeServerSync useData: () => ({
return { session: {
session: { remember: () => undefined,
remember: () => undefined, setStatus: () => undefined,
set: () => undefined, },
sync: async () => { location: {
serverSessionSyncs++ info: () => ({ project: { id: "project", directory: "/repo/main" } }),
}, command: {
list: () => commands,
}, },
child: (directory: string) => { },
syncedServers.push(server) }),
storedSessions[directory] ??= []
return [
{ session: storedSessions[directory] },
(...args: unknown[]) => {
if (args[0] !== "session") return
const next = args[1]
if (typeof next === "function") {
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
return
}
if (Array.isArray(next)) {
storedSessions[directory] = next as Array<{ id: string; title?: string }>
}
},
]
},
}
},
})) }))
mock.module("@/context/platform", () => ({ mock.module("@/context/platform", () => ({
@@ -333,8 +263,6 @@ beforeAll(async () => {
beforeEach(() => { beforeEach(() => {
createdSessions.length = 0 createdSessions.length = 0
admitted.length = 0
confirmed.length = 0
promotedDrafts.length = 0 promotedDrafts.length = 0
updatedDrafts.length = 0 updatedDrafts.length = 0
sentCommands.length = 0 sentCommands.length = 0
@@ -343,12 +271,9 @@ beforeEach(() => {
switchedAgents.length = 0 switchedAgents.length = 0
switchedModels.length = 0 switchedModels.length = 0
sessionRequestOrder.length = 0 sessionRequestOrder.length = 0
syncedServers.length = 0
admittedServers.length = 0
promptCaptures.length = 0 promptCaptures.length = 0
navigations.length = 0 navigations.length = 0
restoredPrompts = 0 restoredPrompts = 0
clearEchoCalls = 0
params = {} params = {}
search = {} search = {}
sentShell.length = 0 sentShell.length = 0
@@ -357,8 +282,6 @@ beforeEach(() => {
variant = undefined variant = undefined
activeSDK = "server-a" activeSDK = "server-a"
activeServer = "server-a" activeServer = "server-a"
activeServerSync = "server-a"
activeDirectorySync = "server-a"
commands = [] commands = []
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
worktreeDirectory = `/repo/new-${++worktreeID}` worktreeDirectory = `/repo/new-${++worktreeID}`
@@ -368,10 +291,8 @@ beforeEach(() => {
worktreeFailure = undefined worktreeFailure = undefined
locationFailure = undefined locationFailure = undefined
promptFailure = undefined promptFailure = undefined
clearEchoResult = true
worktreeCreates = 0 worktreeCreates = 0
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key] for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
}) })
const event = { preventDefault: () => undefined } as unknown as Event const event = { preventDefault: () => undefined } as unknown as Event
@@ -446,8 +367,6 @@ describe("prompt submit worktree selection", () => {
const result = submit.handleSubmit(event) const result = submit.handleSubmit(event)
activeSDK = "server-b" activeSDK = "server-b"
activeServer = "server-b" activeServer = "server-b"
activeServerSync = "server-b"
activeDirectorySync = "server-b"
search.draftId = "draft-2" search.draftId = "draft-2"
release() release()
await result await result
@@ -455,8 +374,6 @@ describe("prompt submit worktree selection", () => {
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }]) expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "server-a", sessionId: "session-1" }]) expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "server-a", sessionId: "session-1" }])
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
expect(admittedServers).toEqual(["server-a"])
expect(promptCaptures.at(-1)?.target).toEqual({ server: "server-a", scope: ServerScope.local }) expect(promptCaptures.at(-1)?.target).toEqual({ server: "server-a", scope: ServerScope.local })
expect(submitted).toBe(0) expect(submitted).toBe(0)
}) })
@@ -476,15 +393,6 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event) await submit.handleSubmit(event)
await Bun.sleep(0) await Bun.sleep(0)
expect(admitted).toHaveLength(1)
expect(admitted[0]).toMatchObject({
sessionID: "session-1",
text: "ls",
agent: "agent",
model: { providerID: "provider", modelID: "model", variant: "high" },
})
expect(admitted[0]?.messageID).toStartWith("msg_")
expect(confirmed).toMatchObject([{ id: admitted[0]?.messageID, sessionID: "session-1" }])
expect(sentPrompts).toEqual(["/repo/main"]) expect(sentPrompts).toEqual(["/repo/main"])
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }]) expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
expect(switchedModels).toEqual([ expect(switchedModels).toEqual([
@@ -499,14 +407,19 @@ describe("prompt submit worktree selection", () => {
text: "ls", text: "ls",
files: [], files: [],
agents: [], agents: [],
metadata: {
displayText: "ls",
comments: [],
agent: "agent",
model: { providerID: "provider", modelID: "model", variant: "high" },
},
}) })
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_") expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
}) })
test("keeps a confirmed echo when the prompt response is lost", async () => { test("restores the prompt when sending fails", async () => {
params = { id: "session-1" } params = { id: "session-1" }
promptFailure = new Error("connection lost") promptFailure = new Error("connection lost")
clearEchoResult = false
const submit = makeSubmit({ const submit = makeSubmit({
info: () => ({ id: "session-1", agent: "agent", model: { id: "model", providerID: "provider" } }), info: () => ({ id: "session-1", agent: "agent", model: { id: "model", providerID: "provider" } }),
}) })
@@ -514,9 +427,7 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event) await submit.handleSubmit(event)
await settle() await settle()
expect(admitted).toHaveLength(1) expect(restoredPrompts).toBe(1)
expect(clearEchoCalls).toBe(1)
expect(restoredPrompts).toBe(0)
}) })
test("submits slash commands through the current session API", async () => { test("submits slash commands through the current session API", async () => {
@@ -1,19 +1,18 @@
import type { SessionInfo } from "@opencode-ai/client/promise" import type { Data } from "@opencode-ai/client/solid"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { startTransition, type Accessor } from "solid-js" import { startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync" import { useData } from "@/context/server"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLocal, type ModelSelection } from "@/context/local" import { useLocal, type ModelSelection } 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, type usePrompt } from "@/context/prompt"
import { useSDK, type DirectorySDK } from "@/context/sdk" import { useWorkspaceLocation } from "@/context/location"
import { useSync, type DirectorySync } from "@/context/sync" import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { getDirectory } from "@opencode-ai/core/util/path" import { getDirectory } from "@opencode-ai/util/path"
import { buildPromptRequest } from "./build-prompt-request" import { buildPromptRequest } from "./build-prompt-request"
import { setCursorPosition } from "./editor-dom" import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
@@ -37,9 +36,8 @@ export type FollowupDraft = {
} }
type FollowupSendInput = { type FollowupSendInput = {
api: DirectorySDK["api"]["session"] api: ServerSDK["api"]["session"]
serverSync: ServerSync data: Data
sync: DirectorySync
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined> session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
draft: FollowupDraft draft: FollowupDraft
messageID?: string messageID?: string
@@ -55,17 +53,20 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
const images = draftImages(input.draft.prompt) const images = draftImages(input.draft.prompt)
const setBusy = () => { const setBusy = () => {
if (!input.optimisticBusy) return if (!input.optimisticBusy) return
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" }) input.data.session.setStatus(input.draft.sessionID, "running")
} }
const setIdle = () => { const setIdle = () => {
if (!input.optimisticBusy) return if (!input.optimisticBusy) return
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" }) input.data.session.setStatus(input.draft.sessionID, "idle")
} }
const [head, ...tail] = text.split(" ") const [head, ...tail] = text.split(" ")
const cmd = head?.startsWith("/") ? head.slice(1) : undefined const cmd = head?.startsWith("/") ? head.slice(1) : undefined
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) { if (
cmd &&
input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)
) {
setBusy() setBusy()
try { try {
const messageID = Identifier.ascending("message") const messageID = Identifier.ascending("message")
@@ -110,14 +111,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
}) })
setBusy() setBusy()
input.sync.session.inbox.echo({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
messageID,
agent: input.draft.agent,
model: { ...input.draft.model, variant: input.draft.variant },
...request,
})
try { try {
const session = input.session() const session = input.session()
@@ -139,22 +132,24 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
}) })
} }
const admitted = await input.api.prompt({ await input.api.prompt({
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
id: messageID, id: messageID,
text: request.text, text: request.text,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })), files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents, agents: request.agents,
metadata: {
displayText: request.displayText,
comments: request.comments,
agent: input.draft.agent,
model: {
...input.draft.model,
...(input.draft.variant ? { variant: input.draft.variant } : {}),
},
},
}) })
input.sync.session.inbox.confirm(admitted)
return true return true
} catch (err) { } catch (err) {
const failed = input.sync.session.inbox.clearEcho({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
messageID,
})
if (!failed) return true
setIdle() setIdle()
throw err throw err
} }
@@ -188,9 +183,9 @@ type PromptSubmitInput = {
export function createPromptSubmit(input: PromptSubmitInput) { export function createPromptSubmit(input: PromptSubmitInput) {
const navigate = useNavigate() const navigate = useNavigate()
const sdk = useSDK() const sdk = useWorkspaceLocation()
const sync = useSync() const serverSDK = useServerSDK()
const serverSync = useServerSync() const data = useData()
const server = useServer() const server = useServer()
const local = useLocal() const local = useLocal()
const permission = usePermission() const permission = usePermission()
@@ -212,13 +207,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const abort = async () => { const abort = async () => {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return Promise.resolve() if (!sessionID) return Promise.resolve()
serverSync.session.set("todo", sessionID, [])
input.onAbort?.() input.onAbort?.()
return sdk() return serverSDK.api.session.interrupt({ sessionID }).catch(() => {})
.api.session.interrupt({ sessionID })
.catch(() => {})
} }
const restoreCommentItems = ( const restoreCommentItems = (
@@ -244,21 +235,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
} }
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
target.session.remember(info)
const [, setStore] = target.child(dir)
setStore("session", (list: SessionInfo[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
if (result.found) {
next[result.index] = info
return next
}
next.splice(result.index, 0, info)
return next
})
}
const handleSubmit = async (event: Event) => { const handleSubmit = async (event: Event) => {
event.preventDefault() event.preventDefault()
@@ -291,9 +267,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
const submissionSDK = sdk() const submissionSDK = sdk()
const submissionSync = sync() const submissionServerSDK = serverSDK
const submissionServerSync = serverSync const submissionData = data
const submissionScope = submissionSDK.scope const submissionScope = submissionServerSDK.scope
const submissionServer = server.key const submissionServer = server.key
const projectDirectory = submissionSDK.directory const projectDirectory = submissionSDK.directory
const sessionID = params.id const sessionID = params.id
@@ -321,14 +297,16 @@ export function createPromptSubmit(input: PromptSubmitInput) {
let sessionDirectory = projectDirectory let sessionDirectory = projectDirectory
if (isNewSession) { if (isNewSession) {
if (worktreeSelection === "create") { if (worktreeSelection === "create") {
const createdWorktree = await submissionSDK.api.worktree const createdWorktree = await submissionServerSDK.api.worktree
.create({ .create({
projectID: submissionSync.data.project, projectID: submissionData.location.info({ directory: projectDirectory })?.project.id ?? "",
strategy: "git", strategy: "git",
directory: getDirectory(submissionSync.project?.worktree ?? projectDirectory), directory: getDirectory(
submissionData.location.info({ directory: projectDirectory })?.project.directory ?? projectDirectory,
),
}) })
.then(async (created) => { .then(async (created) => {
await submissionSDK.api.location.get({ location: { directory: created.directory } }) await submissionServerSDK.api.location.get({ location: { directory: created.directory } })
return created return created
}) })
.catch((err) => { .catch((err) => {
@@ -345,15 +323,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (worktreeSelection !== "main" && worktreeSelection !== "create") { if (worktreeSelection !== "main" && worktreeSelection !== "create") {
sessionDirectory = worktreeSelection sessionDirectory = worktreeSelection
} }
if (sessionDirectory !== projectDirectory) {
submissionServerSync.child(sessionDirectory)
}
} }
let session = currentSession let session = currentSession
if (!session && isNewSession) { if (!session && isNewSession) {
const created = await submissionSDK.api.session const created = await submissionServerSDK.api.session
.create({ .create({
agent: currentAgent.name, agent: currentAgent.name,
model: { id: currentModel.id, providerID: currentModel.provider.id, variant }, model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
@@ -367,7 +341,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return undefined return undefined
}) })
if (created) { if (created) {
seed(submissionServerSync, sessionDirectory, created) submissionData.session.remember(created)
session = created session = created
await startTransition(() => { await startTransition(() => {
if (!session) return if (!session) return
@@ -448,7 +422,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (mode === "shell") { if (mode === "shell") {
clearInput() clearInput()
const eventID = Event.ID.create() const eventID = Event.ID.create()
void submissionSDK.api.session void submissionServerSDK.api.session
.shell({ .shell({
sessionID: session.id, sessionID: session.id,
id: eventID, id: eventID,
@@ -467,12 +441,14 @@ 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 = submissionSync.data.command.find((c) => c.name === commandName) const customCommand = submissionData.location.command
.list({ directory: sessionDirectory })
?.find((command) => command.name === commandName)
if (customCommand) { if (customCommand) {
clearInput() clearInput()
const messageID = Identifier.ascending("message") const messageID = Identifier.ascending("message")
submissionServerSync.session.set("session_status", session.id, { type: "busy" }) submissionData.session.setStatus(session.id, "running")
void submissionSDK.api.session void submissionServerSDK.api.session
.command({ .command({
sessionID: session.id, sessionID: session.id,
id: messageID, id: messageID,
@@ -488,7 +464,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
), ),
}) })
.catch((err) => { .catch((err) => {
submissionServerSync.session.set("session_status", session.id, { type: "idle" }) submissionData.session.setStatus(session.id, "idle")
showToast({ showToast({
title: language.t("prompt.toast.commandSendFailed.title"), title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")), description: formatServerError(err, language.t, language.t("common.requestFailed")),
@@ -506,16 +482,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
clearInput() clearInput()
void sendFollowupDraft({ void sendFollowupDraft({
api: submissionSDK.api.session, api: submissionServerSDK.api.session,
sync: submissionSync, data: submissionData,
serverSync: submissionServerSync,
session: () => session, session: () => session,
draft, draft,
messageID, messageID,
optimisticBusy: sessionDirectory === projectDirectory, optimisticBusy: sessionDirectory === projectDirectory,
}).catch((err) => { }).catch((err) => {
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
submissionSync.set("session_status", session.id, { type: "idle" }) submissionData.session.setStatus(session.id, "idle")
} }
showToast({ showToast({
title: language.t("prompt.toast.promptSendFailed.title"), title: language.t("prompt.toast.promptSendFailed.title"),
@@ -2,7 +2,7 @@ import { createMemo, createSignal, For, Show } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Icon } from "@opencode-ai/ui/v2/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/util/path"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { sameDirectory } from "@/utils/workspace" import { sameDirectory } from "@/utils/workspace"
@@ -1,9 +1,7 @@
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise" import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { useQueryClient } from "@tanstack/solid-query"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useData } from "@/context/server"
import { pathKey } from "@/utils/path-key"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js" import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
@@ -18,8 +16,7 @@ export function createProviderConnectionController(options: {
}) { }) {
const language = useLanguage() const language = useLanguage()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const serverSync = useServerSync() const data = useData()
const queryClient = useQueryClient()
const location = () => { const location = () => {
const directory = options.directory() const directory = options.directory()
return directory ? { directory } : undefined return directory ? { directory } : undefined
@@ -118,12 +115,15 @@ export function createProviderConnectionController(options: {
} }
const finish = async () => { const finish = async () => {
cancelPolling() cancelPolling()
const directory = options.directory() const ref = location()
const key = directory ? pathKey(directory) : null data.location.integration.invalidate(ref)
data.location.provider.invalidate(ref)
data.location.model.invalidate(ref)
await Promise.all([ await Promise.all([
queryClient.refetchQueries(serverSync.queryOptions.providers(key)).catch(() => undefined), data.location.integration.sync(ref),
queryClient.refetchQueries(serverSync.queryOptions.integrations(key)).catch(() => undefined), data.location.provider.sync(ref),
]) data.location.model.sync(ref),
]).catch(() => undefined)
if (polling.disposed) return if (polling.disposed) return
options.onComplete() options.onComplete()
} }
@@ -7,11 +7,10 @@ import { createMediaQuery } from "@solid-primitives/media"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync" import { useData } from "@/context/server"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk" import { useWorkspaceLocation } from "@/context/location"
import { getSessionContext } from "@/components/session/session-context-metrics"
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"
@@ -40,11 +39,11 @@ function openSessionContext(args: {
} }
export function SessionContextUsage(props: SessionContextUsageProps) { export function SessionContextUsage(props: SessionContextUsageProps) {
const sync = useSync() const data = useData()
const file = useFile() const file = useFile()
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const sdk = useSDK() const sdk = useWorkspaceLocation()
const providers = useProviders(() => sdk().directory) const providers = useProviders(() => sdk().directory)
const { params, tabs, view } = useSessionLayout() const { params, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)") const isDesktop = createMediaQuery("(min-width: 768px)")
@@ -56,8 +55,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
fileBrowser: () => isDesktop() && !!params.id, fileBrowser: () => isDesktop() && !!params.id,
}) })
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : [])) const messages = createMemo(() => (params.id ? data.session.message.list(params.id) : []))
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
const usd = createMemo( const usd = createMemo(
() => () =>
@@ -67,7 +66,21 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}), }),
) )
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) const context = createMemo(() => {
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
if (message?.type !== "assistant" || !message.tokens) return
const model = providers.all().get(message.model.providerID)?.models[message.model.id]
const total =
message.tokens.input +
message.tokens.output +
message.tokens.reasoning +
message.tokens.cache.read +
message.tokens.cache.write
return {
total,
usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
}
})
const cost = createMemo(() => { const cost = createMemo(() => {
return usd().format(info()?.cost ?? 0) return usd().format(info()?.cost ?? 0)
}) })
@@ -1,12 +1,12 @@
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Icon } from "@opencode-ai/ui/v2/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js" import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
import type { Project } from "@/types" import type { Project } from "@/types"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useData } from "@/context/server"
import { useSettingsDialog } from "@/components/settings-dialog" import { useSettingsDialog } from "@/components/settings-dialog"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
@@ -26,11 +26,11 @@ export function SessionWorkspaceMenu(props: {
}) { }) {
const language = useLanguage() const language = useLanguage()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const serverSync = useServerSync() const data = useData()
const openWorkspaces = useSettingsDialog("workspaces") const openWorkspaces = useSettingsDialog("workspaces")
const [store, setStore] = createStore({ selected: undefined as string | undefined }) const [store, setStore] = createStore({ selected: undefined as string | undefined })
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project)) const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
const blocked = () => props.eligible === false || serverSync.session.data.session_working(props.sessionID) const blocked = () => props.eligible === false || data.session.status(props.sessionID) === "running"
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory)) const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
const workspaces = () => const workspaces = () =>
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory)) directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
@@ -1,61 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@/types"
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
const user = (id: string) => {
return {
id,
role: "user",
time: { created: 1 },
} as unknown as Message
}
const assistant = (id: string) => {
return {
id,
role: "assistant",
time: { created: 1 },
} as unknown as Message
}
describe("estimateSessionContextBreakdown", () => {
test("estimates tokens and keeps remaining tokens as other", () => {
const messages = [user("u1"), assistant("a1")]
const parts = {
u1: [{ type: "text", text: "hello world" }] as unknown as Part[],
a1: [{ type: "text", text: "assistant response" }] as unknown as Part[],
}
const output = estimateSessionContextBreakdown({
messages,
parts,
input: 20,
systemPrompt: "system prompt",
})
const map = Object.fromEntries(output.map((segment) => [segment.key, segment.tokens]))
expect(map.system).toBe(4)
expect(map.user).toBe(3)
expect(map.assistant).toBe(5)
expect(map.other).toBe(8)
})
test("scales segments when estimates exceed input", () => {
const messages = [user("u1"), assistant("a1")]
const parts = {
u1: [{ type: "text", text: "x".repeat(400) }] as unknown as Part[],
a1: [{ type: "text", text: "y".repeat(400) }] as unknown as Part[],
}
const output = estimateSessionContextBreakdown({
messages,
parts,
input: 10,
systemPrompt: "z".repeat(200),
})
const total = output.reduce((sum, segment) => sum + segment.tokens, 0)
expect(total).toBeLessThanOrEqual(10)
expect(output.every((segment) => segment.width <= 100)).toBeTrue()
})
})
@@ -1,132 +0,0 @@
import type { Message, Part } from "@/types"
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
export type SessionContextBreakdownSegment = {
key: SessionContextBreakdownKey
tokens: number
width: number
percent: number
}
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
const toPercent = (tokens: number, input: number) => (tokens / input) * 100
const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
const charsFromUserPart = (part: Part) => {
if (part.type === "text") return part.text.length
if (part.type === "file") return part.source?.text.value.length ?? 0
if (part.type === "agent") return part.source?.value.length ?? 0
return 0
}
const charsFromAssistantPart = (part: Part) => {
if (part.type === "text") return { assistant: part.text.length, tool: 0 }
if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
if (part.type !== "tool") return { assistant: 0, tool: 0 }
const input = Object.keys(part.state.input).length * 16
if (part.state.status === "pending") return { assistant: 0, tool: input + part.state.raw.length }
if (part.state.status === "completed") return { assistant: 0, tool: input + part.state.output.length }
if (part.state.status === "error") return { assistant: 0, tool: input + part.state.error.length }
return { assistant: 0, tool: input }
}
const build = (
tokens: { system: number; user: number; assistant: number; tool: number; other: number },
input: number,
) => {
return [
{
key: "system",
tokens: tokens.system,
},
{
key: "user",
tokens: tokens.user,
},
{
key: "assistant",
tokens: tokens.assistant,
},
{
key: "tool",
tokens: tokens.tool,
},
{
key: "other",
tokens: tokens.other,
},
]
.filter((x) => x.tokens > 0)
.map((x) => ({
key: x.key,
tokens: x.tokens,
width: toPercent(x.tokens, input),
percent: toPercentLabel(x.tokens, input),
})) as SessionContextBreakdownSegment[]
}
export function estimateSessionContextBreakdown(args: {
messages: Message[]
parts: Record<string, Part[] | undefined>
input: number
systemPrompt?: string
}) {
if (!args.input) return []
const counts = args.messages.reduce(
(acc, msg) => {
const parts = args.parts[msg.id] ?? []
if (msg.role === "user") {
const user = parts.reduce((sum, part) => sum + charsFromUserPart(part), 0)
return { ...acc, user: acc.user + user }
}
if (msg.role !== "assistant") return acc
const assistant = parts.reduce(
(sum, part) => {
const next = charsFromAssistantPart(part)
return {
assistant: sum.assistant + next.assistant,
tool: sum.tool + next.tool,
}
},
{ assistant: 0, tool: 0 },
)
return {
...acc,
assistant: acc.assistant + assistant.assistant,
tool: acc.tool + assistant.tool,
}
},
{
system: args.systemPrompt?.length ?? 0,
user: 0,
assistant: 0,
tool: 0,
},
)
const tokens = {
system: estimateTokens(counts.system),
user: estimateTokens(counts.user),
assistant: estimateTokens(counts.assistant),
tool: estimateTokens(counts.tool),
}
const estimated = tokens.system + tokens.user + tokens.assistant + tokens.tool
if (estimated <= args.input) {
return build({ ...tokens, other: args.input - estimated }, args.input)
}
const scale = args.input / estimated
const scaled = {
system: Math.floor(tokens.system * scale),
user: Math.floor(tokens.user * scale),
assistant: Math.floor(tokens.assistant * scale),
tool: Math.floor(tokens.tool * scale),
}
const total = scaled.system + scaled.user + scaled.assistant + scaled.tool
return build({ ...scaled, other: Math.max(0, args.input - total) }, args.input)
}
@@ -1,99 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@/types"
import { getSessionContext } from "./session-context-metrics"
const assistant = (
id: string,
tokens: { input: number; output: number; reasoning: number; read: number; write: number },
cost: number,
providerID = "openai",
modelID = "gpt-4.1",
) => {
return {
id,
role: "assistant",
providerID,
modelID,
cost,
tokens: {
input: tokens.input,
output: tokens.output,
reasoning: tokens.reasoning,
cache: {
read: tokens.read,
write: tokens.write,
},
},
time: { created: 1 },
} as unknown as Message
}
const user = (id: string) => {
return {
id,
role: "user",
cost: 0,
time: { created: 1 },
} as unknown as Message
}
describe("getSessionContext", () => {
test("computes token totals and usage from latest assistant with tokens", () => {
const messages = [
user("u1"),
assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5),
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
]
const providers = [
{
id: "openai",
name: "OpenAI",
models: {
"gpt-4.1": {
name: "GPT-4.1",
limit: { context: 1000 },
},
},
},
]
const ctx = getSessionContext(messages, providers)
expect(ctx?.message.id).toBe("a2")
expect(ctx?.total).toBe(500)
expect(ctx?.input).toBe(300)
expect(ctx?.usage).toBe(50)
expect(ctx?.providerLabel).toBe("OpenAI")
expect(ctx?.modelLabel).toBe("GPT-4.1")
})
test("preserves fallback labels and null usage when model metadata is missing", () => {
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
const providers = [{ id: "p-1", models: {} }]
const ctx = getSessionContext(messages, providers)
expect(ctx?.providerLabel).toBe("p-1")
expect(ctx?.modelLabel).toBe("m-1")
expect(ctx?.limit).toBeUndefined()
expect(ctx?.usage).toBeNull()
})
test("recomputes when message array is mutated in place", () => {
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
const providers = [{ id: "openai", models: {} }]
const one = getSessionContext(messages, providers)
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
const two = getSessionContext(messages, providers)
expect(one?.message.id).toBe("a1")
expect(two?.message.id).toBe("a2")
})
test("returns undefined when inputs are undefined", () => {
const ctx = getSessionContext(undefined, undefined)
expect(ctx).toBeUndefined()
})
})
@@ -1,65 +0,0 @@
import type { AssistantMessage, Message } from "@/types"
type Provider = {
id: string
name?: string
models: Record<string, Model | undefined>
}
type Model = {
name?: string
limit: {
context: number
}
}
type Context = {
message: AssistantMessage
provider?: Provider
model?: Model
providerLabel: string
modelLabel: string
limit: number | undefined
input: number
total: number
usage: number | null
}
const tokenTotal = (msg: AssistantMessage) => {
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
}
const lastAssistantWithTokens = (messages: Message[]) => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role !== "assistant") continue
if (tokenTotal(msg) <= 0) continue
return msg
}
}
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
const message = lastAssistantWithTokens(messages)
if (!message) return undefined
const provider = providers.find((item) => item.id === message.providerID)
const model = provider?.models[message.modelID]
const limit = model?.limit.context
const total = tokenTotal(message)
return {
message,
provider,
model,
providerLabel: provider?.name ?? message.providerID,
modelLabel: model?.name ?? message.modelID,
limit,
input: message.tokens.input,
total,
usage: limit ? Math.round((total / limit) * 100) : null,
}
}
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers)
}
@@ -1,8 +1,7 @@
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
import { useSync } from "@/context/sync" import { useData } from "@/context/server"
import { checksum } from "@opencode-ai/core/util/encode" import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/core/util/array"
import { same } from "@/utils/same" import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
@@ -11,25 +10,16 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { File } from "@opencode-ai/session-ui/file" import { File } from "@opencode-ai/session-ui/file"
import { Markdown } from "@opencode-ai/session-ui/markdown" import { Markdown } from "@opencode-ai/session-ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@/types" import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk" import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContext } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format" import { createSessionContextFormatter } from "./session-context-format"
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
system: "var(--syntax-info)",
user: "var(--syntax-success)",
assistant: "var(--syntax-property)",
tool: "var(--syntax-warning)",
other: "var(--syntax-comment)",
}
function Stat(props: { label: string; value: JSX.Element }) { function Stat(props: { label: string; value: JSX.Element }) {
return ( return (
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
@@ -39,12 +29,11 @@ function Stat(props: { label: string; value: JSX.Element }) {
) )
} }
function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) { function RawMessageContent(props: { message: SessionMessageInfo; onRendered: () => void }) {
const file = createMemo(() => { const file = createMemo(() => {
const parts = props.getParts(props.message.id) const contents = JSON.stringify(props.message, null, 2)
const contents = JSON.stringify({ message: props.message, parts }, null, 2)
return { return {
name: `${props.message.role}-${props.message.id}.json`, name: `${props.message.type}-${props.message.id}.json`,
contents, contents,
cacheKey: checksum(contents), cacheKey: checksum(contents),
} }
@@ -62,8 +51,7 @@ function RawMessageContent(props: { message: Message; getParts: (id: string) =>
} }
function RawMessage(props: { function RawMessage(props: {
message: Message message: SessionMessageInfo
getParts: (id: string) => Part[]
onRendered: () => void onRendered: () => void
time: (value: number | undefined) => string time: (value: number | undefined) => string
}) { }) {
@@ -73,7 +61,7 @@ function RawMessage(props: {
<Accordion.Trigger> <Accordion.Trigger>
<div class="flex items-center justify-between gap-2 w-full"> <div class="flex items-center justify-between gap-2 w-full">
<div class="min-w-0 truncate"> <div class="min-w-0 truncate">
{props.message.role} <span class="text-text-base"> {props.message.id}</span> {props.message.type} <span class="text-text-base"> {props.message.id}</span>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div> <div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div>
@@ -84,52 +72,35 @@ function RawMessage(props: {
</StickyAccordionHeader> </StickyAccordionHeader>
<Accordion.Content class="bg-background-base"> <Accordion.Content class="bg-background-base">
<div class="p-3"> <div class="p-3">
<RawMessageContent message={props.message} getParts={props.getParts} onRendered={props.onRendered} /> <RawMessageContent message={props.message} onRendered={props.onRendered} />
</div> </div>
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>
) )
} }
const emptyMessages: Message[] = [] const emptyMessages: SessionMessageInfo[] = []
const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() { export function SessionContextTab() {
const sync = useSync() const data = useData()
const language = useLanguage() const language = useLanguage()
const sdk = useSDK() const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const providers = useProviders(() => sdk().directory) const providers = useProviders(() => sdk().directory)
const { params, view } = useSessionLayout() const { params, view } = useSessionLayout()
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? data.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 data.session.message.list(id)
}, },
emptyMessages, emptyMessages,
{ equals: same }, { equals: same },
) )
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
const boundary = userMessages().findIndex((message) => message.id === revert)
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
},
emptyUserMessages,
{ equals: same },
)
const usd = createMemo( const usd = createMemo(
() => () =>
new Intl.NumberFormat(language.intl(), { new Intl.NumberFormat(language.intl(), {
@@ -138,7 +109,28 @@ export function SessionContextTab() {
}), }),
) )
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) const ctx = createMemo(() => {
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
if (message?.type !== "assistant" || !message.tokens) return
const provider = providers.all().get(message.model.providerID)
const model = provider?.models[message.model.id]
const total =
message.tokens.input +
message.tokens.output +
message.tokens.reasoning +
message.tokens.cache.read +
message.tokens.cache.write
return {
message,
tokens: message.tokens,
providerLabel: provider?.name ?? message.model.providerID,
modelLabel: model?.name ?? message.model.id,
limit: model?.limit.context,
input: message.tokens.input,
total,
usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
}
})
const formatter = createMemo(() => createSessionContextFormatter(language.intl())) const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
const cost = createMemo(() => { const cost = createMemo(() => {
@@ -147,8 +139,8 @@ export function SessionContextTab() {
const counts = createMemo(() => { const counts = createMemo(() => {
const all = messages() const all = messages()
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0) const user = all.reduce((count, message) => count + (message.type === "user" ? 1 : 0), 0)
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0) const assistant = all.reduce((count, message) => count + (message.type === "assistant" ? 1 : 0), 0)
return { return {
all: all.length, all: all.length,
user, user,
@@ -157,8 +149,7 @@ export function SessionContextTab() {
}) })
const systemPrompt = createMemo(() => { const systemPrompt = createMemo(() => {
const msg = findLast(visibleUserMessages(), (m) => !!m.system) const system = messages().findLast((message) => message.type === "system")?.text
const system = msg?.system
if (!system) return if (!system) return
const trimmed = system.trim() const trimmed = system.trim()
if (!trimmed) return if (!trimmed) return
@@ -177,30 +168,6 @@ export function SessionContextTab() {
return c.modelLabel return c.modelLabel
}) })
const breakdown = createMemo(
on(
() => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
() => {
const c = ctx()
if (!c?.input) return []
return estimateSessionContextBreakdown({
messages: messages(),
parts: sync().data.part as Record<string, Part[] | undefined>,
input: c.input,
systemPrompt: systemPrompt(),
})
},
),
)
const breakdownLabel = (key: SessionContextBreakdownKey) => {
if (key === "system") return language.t("context.breakdown.system")
if (key === "user") return language.t("context.breakdown.user")
if (key === "assistant") return language.t("context.breakdown.assistant")
if (key === "tool") return language.t("context.breakdown.tool")
return language.t("context.breakdown.other")
}
const stats = [ const stats = [
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" }, { label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) }, { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) },
@@ -210,12 +177,11 @@ export function SessionContextTab() {
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) }, { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) }, { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) }, { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.tokens.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) }, { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.tokens.reasoning) },
{ {
label: "context.stats.cacheTokens", label: "context.stats.cacheTokens",
value: () => value: () => `${formatter().number(ctx()?.tokens.cache.read)} / ${formatter().number(ctx()?.tokens.cache.write)}`,
`${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`,
}, },
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
@@ -230,7 +196,7 @@ export function SessionContextTab() {
try { try {
const data = await fetchSessionExport({ const data = await fetchSessionExport({
sessionID, sessionID,
api: sdk().api, api: serverSDK.api,
}) })
const filename = sessionExportFilename(data.info) const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data) downloadSessionExport(filename, data)
@@ -252,8 +218,6 @@ 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 restoreScroll = () => { const restoreScroll = () => {
const el = scroll const el = scroll
if (!el) return if (!el) return
@@ -314,37 +278,6 @@ export function SessionContextTab() {
</For> </For>
</div> </div>
<Show when={breakdown().length > 0}>
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.breakdown.title")}</div>
<div class="h-2 w-full rounded-full bg-surface-base overflow-hidden flex">
<For each={breakdown()}>
{(segment) => (
<div
class="h-full"
style={{
width: `${segment.width}%`,
"background-color": BREAKDOWN_COLOR[segment.key],
}}
/>
)}
</For>
</div>
<div class="flex flex-wrap gap-x-3 gap-y-1">
<For each={breakdown()}>
{(segment) => (
<div class="flex items-center gap-1 text-11-regular text-text-weak">
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
<div>{breakdownLabel(segment.key)}</div>
<div class="text-text-weaker">{segment.percent.toLocaleString(language.intl())}%</div>
</div>
)}
</For>
</div>
<div class="hidden text-11-regular text-text-weaker">{language.t("context.breakdown.note")}</div>
</div>
</Show>
<Show when={systemPrompt()}> <Show when={systemPrompt()}>
{(prompt) => ( {(prompt) => (
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@@ -371,9 +304,7 @@ export function SessionContextTab() {
</div> </div>
<Accordion multiple> <Accordion multiple>
<For each={messages()}> <For each={messages()}>
{(message) => ( {(message) => <RawMessage message={message} onRendered={restoreScroll} time={formatter().time} />}
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
)}
</For> </For>
</Accordion> </Accordion>
</div> </div>
@@ -1,11 +1,11 @@
import { Show, createMemo } from "solid-js" import { Show, createMemo } from "solid-js"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { useSync } from "@/context/sync" import { useWorkspaceLocation } from "@/context/location"
import { useSDK } from "@/context/sdk"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useData } from "@/context/server"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Mark } from "@opencode-ai/ui/logo" import { Mark } from "@opencode-ai/ui/logo"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
const MAIN_WORKTREE = "main" const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create" const CREATE_WORKTREE = "create"
@@ -16,28 +16,32 @@ interface NewSessionViewProps {
} }
export function NewSessionView(props: NewSessionViewProps) { export function NewSessionView(props: NewSessionViewProps) {
const sync = useSync() const sdk = useWorkspaceLocation()
const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const data = useData()
const sandboxes = createMemo(() => sync().project?.sandboxes ?? []) const project = createMemo(() => {
const projectID = data.location.info({ directory: sdk().directory })?.project.id
return projectID ? data.project.get(projectID) : undefined
})
const sandboxes = createMemo(() => 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(() => project()?.canonical ?? sdk().directory)
const isWorktree = createMemo(() => { const isWorktree = createMemo(() => {
const project = sync().project const current = project()
if (!project) return false if (!current) return false
return sdk().directory !== project.worktree return sdk().directory !== current.canonical
}) })
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 = data.location.vcs.info({ directory: sdk().directory })?.branch.current
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 +73,7 @@ export function NewSessionView(props: NewSessionViewProps) {
{label(current())} {label(current())}
</div> </div>
</div> </div>
<Show when={sync().project}> <Show when={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,7 +1,7 @@
import { Show } from "solid-js" import { Show } from "solid-js"
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/util/path"
export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element { export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element {
return ( return (
@@ -63,7 +63,7 @@ export const DialogSettings: Component<{
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined return draft?.type === "draft" ? draft.directory : undefined
} }
if (route.type === "session") return serverCtx()?.sync.session.get(route.sessionId)?.location.directory if (route.type === "session") return serverCtx()?.data.session.get(route.sessionId)?.location.directory
return undefined return undefined
}) })
@@ -1,10 +1,11 @@
import { Component, For, createMemo, createResource } from "solid-js" import { Component, For, createEffect, createMemo, createResource } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useMcpToggle } from "@/context/mcp"
import { ExternalLink } from "../external-link" import { ExternalLink } from "../external-link"
import { InlineServerSelect } from "./parts/server-select" import { InlineServerSelect } from "./parts/server-select"
import "./settings-v2.css" import "./settings-v2.css"
@@ -21,35 +22,35 @@ interface PluginRowItem {
export const SettingsExtensionsV2: Component = () => { export const SettingsExtensionsV2: Component = () => {
const language = useLanguage() const language = useLanguage()
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const serverSync = useServerSync() const data = useData()
const [mcpList, { refetch: refetchMcp }] = createResource(
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.mcp.list().then((result) => result.data),
)
const toggleMcp = useMcpToggle(() => undefined, refetchMcp)
const mcps = createMemo<McpRowItem[]>(() => { const mcps = createMemo<McpRowItem[]>(() => {
const configMcp = serverSync.data.config.mcp ?? {} return (mcpList.latest ?? []).map((server) => ({
return Object.entries(configMcp).map(([name, config]) => ({ name: server.name,
name, enabled: server.status.status === "connected",
enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false,
})) }))
}) })
const handleMcpToggle = (item: McpRowItem, checked: boolean) => { const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
const before = serverSync.data.config.mcp ?? {} if (item.enabled === checked || toggleMcp.isPending) return
const config = before[item.name] toggleMcp.mutate(item.name)
if (typeof config !== "object" || config === null) return
const next = { ...before, [item.name]: { ...config, enabled: checked } }
serverSync.set("config", "mcp", next)
void serverSync.updateConfig({ mcp: next }).catch(() => serverSync.set("config", "mcp", before))
} }
const plugins = createMemo<PluginRowItem[]>(() => { const [pluginList] = createResource(
const raw = serverSync.data.config.plugin ?? [] () => serverSdk.connection.status() === "connected",
return raw.map((item) => { () => serverSdk.api.plugin.list().then((result) => result.data),
const name = typeof item === "string" ? item : item[0] )
return { name } const plugins = createMemo<PluginRowItem[]>(() => (pluginList.latest ?? []).map((item) => ({ name: item.id })))
})
})
const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), { createEffect(() => {
initialValue: [], if (serverSdk.connection.status() !== "connected") return
void data.location.skill.sync().catch(() => undefined)
}) })
const skills = () => data.location.skill.list() ?? []
return ( return (
<> <>
@@ -2,7 +2,6 @@ import { createMemo, createResource, onMount, type Accessor } from "solid-js"
import type { ColorScheme } from "@opencode-ai/ui/theme/context" import type { ColorScheme } from "@opencode-ai/ui/theme/context"
import { useTheme } from "@opencode-ai/ui/theme/context" import { useTheme } from "@opencode-ai/ui/theme/context"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { useServerSync } from "@/context/server-sync"
import { import {
monoDefault, monoDefault,
monoFontFamily, monoFontFamily,
@@ -34,7 +33,7 @@ export function createPermissionScopeController(
const s = server() const s = server()
const id = sessionID() const id = sessionID()
if (!s || !id) return undefined if (!s || !id) return undefined
return serverCtx()?.sync.session.lineage.peek(id)?.session.location.directory return serverCtx()?.data.session.get(id)?.location.directory
}) })
return { return {
@@ -8,7 +8,6 @@ import { useIntegrations } from "@/hooks/use-integrations"
import { createMemo, type Component, For, Show } from "solid-js" import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider" import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
import { DialogCustomProvider } from "../dialog-custom-provider" import { DialogCustomProvider } from "../dialog-custom-provider"
import { SettingsServerScope } from "../settings-server-picker" import { SettingsServerScope } from "../settings-server-picker"
@@ -39,7 +38,6 @@ export const SettingsProvidersV2: Component<{
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const serverSync = useServerSync()
const providers = useProviders(() => props.directory) const providers = useProviders(() => props.directory)
const integrations = useIntegrations(() => props.directory) const integrations = useIntegrations(() => props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack }) const providerConnect = useProviderConnectController({ onBack: props.onBack })
@@ -55,15 +53,12 @@ export const SettingsProvidersV2: Component<{
} }
const connected = createMemo(() => { const connected = createMemo(() => {
return providers.connected().filter( return providers
(provider) => .connected()
provider.id !== "opencode" || .filter(
Object.values(provider.models).some((model) => { (provider) =>
if (typeof model !== "object" || model === null || !("cost" in model)) return false provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost.input > 0),
const cost = model.cost )
return typeof cost === "object" && cost !== null && "input" in cost
}),
)
}) })
const popular = createMemo(() => { const popular = createMemo(() => {
@@ -94,10 +89,7 @@ export const SettingsProvidersV2: Component<{
const current = source(item) const current = source(item)
if (current === "env") return language.t("settings.providers.tag.environment") if (current === "env") return language.t("settings.providers.tag.environment")
if (current === "api") return language.t("provider.connect.method.apiKey") if (current === "api") return language.t("provider.connect.method.apiKey")
if (current === "config") { if (current === "config") return language.t("settings.providers.tag.config")
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.config")
}
if (current === "custom") return language.t("settings.providers.tag.custom") if (current === "custom") return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.other") return language.t("settings.providers.tag.other")
} }
@@ -105,19 +97,12 @@ export const SettingsProvidersV2: Component<{
const canDisconnect = (item: ProviderItem) => { const canDisconnect = (item: ProviderItem) => {
const current = integration(item.id) const current = integration(item.id)
if (current) return current.connections.some((connection) => connection.type === "credential") if (current) return current.connections.some((connection) => connection.type === "credential")
return source(item) !== "env" && !isConfigCustom(item.id) const currentSource = source(item)
return currentSource !== "env" && currentSource !== "config"
} }
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 provider = serverSync.data.config.provider?.[providerID]
if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false
return true
}
const disconnect = async (providerID: string, name: string) => { const disconnect = async (providerID: string, name: string) => {
const location = props.directory ? { directory: props.directory } : undefined const location = props.directory ? { directory: props.directory } : undefined
await serverSdk.api.integration await serverSdk.api.integration
@@ -1,6 +1,6 @@
import type { Component } from "solid-js" import type { Component } from "solid-js"
import { For, Show, createMemo } from "solid-js" import { For, Show, createMemo } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore } from "solid-js/store"
import type { SessionInfo } from "@opencode-ai/client/promise" import type { SessionInfo } from "@opencode-ai/client/promise"
import { useQuery } from "@tanstack/solid-query" import { useQuery } from "@tanstack/solid-query"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
@@ -10,12 +10,13 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/util/path"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useData } from "@/context/server"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getRelativeTime } from "@/utils/time" import { getRelativeTime } from "@/utils/time"
import { sessionLabel } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { InlineServerSelect } from "./parts/server-select" import { InlineServerSelect } from "./parts/server-select"
@@ -37,6 +38,7 @@ import {
} from "@/utils/workspace" } from "@/utils/workspace"
import { listAllSessions } from "@/utils/session" import { listAllSessions } from "@/utils/session"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { normalizeProjectInfo } from "@/context/global-sync/utils"
import "./settings-v2.css" import "./settings-v2.css"
type Workspace = { type Workspace = {
@@ -48,7 +50,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const serverSync = useServerSync() const data = useData()
const tabs = useTabs() const tabs = useTabs()
const platform = usePlatform() const platform = usePlatform()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
@@ -56,9 +58,22 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
transaction: undefined as "confirm" | "running" | undefined, transaction: undefined as "confirm" | "running" | undefined,
}) })
const workspaces = createMemo(() => workspaceInventory(serverSync.data.project)) const projectQuery = useQuery(() => ({
queryKey: [serverSDK.scope, "settings-workspace-projects"] as const,
queryFn: async () =>
Promise.all(
(await serverSDK.api.project.list()).map(async (project) => {
const worktrees = await serverSDK.api.worktree
.list({ projectID: project.id })
.catch(() => [{ directory: project.canonical }, ...project.sandboxes.map((directory) => ({ directory }))])
return normalizeProjectInfo({ ...project, worktrees })
}),
),
refetchOnMount: "always",
}))
const workspaces = createMemo(() => workspaceInventory(projectQuery.data ?? []))
const projects = createMemo(() => const projects = createMemo(() =>
serverSync.data.project.filter((project) => managedWorkspaceDirectories(project).length > 0), (projectQuery.data ?? []).filter((project) => managedWorkspaceDirectories(project).length > 0),
) )
const projectName = (project: Project) => project.name || getFilename(project.worktree) const projectName = (project: Project) => project.name || getFilename(project.worktree)
const projectOptions = createMemo(() => [ const projectOptions = createMemo(() => [
@@ -71,18 +86,21 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject())) const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
const captureDeleteContext = () => { const captureDeleteContext = () => {
const sdk = serverSDK const sdk = serverSDK
return { sdk, sync: serverSync, server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory } return {
sdk,
data,
server: ServerConnection.key(sdk.server),
activeDirectory: props.activeDirectory,
}
} }
const loadSessions = async (context = captureDeleteContext()) => { const loadSessions = async (context = captureDeleteContext()) => {
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" }) const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
return mergeWorkspaceSessionInventory( fetched.forEach(context.data.session.remember)
fetched, return mergeWorkspaceSessionInventory(fetched, context.data.session.list())
Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session),
)
} }
const sessionQuery = useQuery(() => ({ const sessionQuery = useQuery(() => ({
queryKey: [serverSDK.scope, null, "settings-workspace-sessions"] as const, queryKey: [serverSDK.scope, null, "settings-workspace-sessions"] as const,
queryFn: () => loadSessions(), queryFn: () => loadSessions().then(() => Date.now()),
refetchOnMount: "always", refetchOnMount: "always",
})) }))
const sessionsByWorkspace = createMemo( const sessionsByWorkspace = createMemo(
@@ -90,7 +108,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
new Map( new Map(
workspaces().map((workspace) => [ workspaces().map((workspace) => [
pathKey(workspace.directory), pathKey(workspace.directory),
sessionQuery.isSuccess ? sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory) : [], sessionQuery.isSuccess ? sessionsForWorkspace(data.session.list(), workspace.directory) : [],
]), ]),
), ),
) )
@@ -177,19 +195,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
}) })
}) })
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope) clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
context.sync.set( await projectQuery.refetch()
"project",
produce((draft) => {
const project = draft.find((item) => item.id === workspace.project.id)
if (!project) return
project.sandboxes = (project.sandboxes ?? []).filter(
(directory) => pathKey(directory) !== pathKey(workspace.directory),
)
project.worktrees = project.worktrees.filter(
(worktree) => pathKey(worktree.directory) !== pathKey(workspace.directory),
)
}),
)
} }
let inspectionID = 0 let inspectionID = 0
@@ -377,7 +383,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
<For each={linked()}> <For each={linked()}>
{(session) => ( {(session) => (
<div class="settings-v2-workspaces-session"> <div class="settings-v2-workspaces-session">
<span>{session.title}</span> <span>{sessionLabel(session)}</span>
<Show when={sessionTime(session)}> <Show when={sessionTime(session)}>
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>} {(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
</Show> </Show>
@@ -1,17 +1,11 @@
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@/utils/toast" import { createMemo, createResource, For, type JSXElement, Show } from "solid-js"
import { createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { ServerConnection, useServers } from "@/context/servers"
import { useSync } from "@/context/sync"
import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global"
import { useMcpToggle } from "@/context/mcp" import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk" import { useWorkspaceLocation } from "@/context/location"
import { useServer } from "@/context/server" import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
const pluginEmptyMessage = (value: string, file: string): JSXElement => { const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file) const parts = value.split(file)
@@ -25,111 +19,24 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
) )
} }
const listServersByHealth = (
list: ServerConnection.Any[],
active: ServerConnection.Key | undefined,
status: Record<ServerConnection.Key, ServerHealth | undefined>,
) => {
if (!list.length) return list
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (ServerConnection.key(a) === active) return -1
if (ServerConnection.key(b) === active) return 1
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
}
const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => {
const [state, setState] = createStore({
key: undefined as ServerConnection.Key | undefined,
tick: 0,
})
createEffect(() => {
state.tick
let dead = false
const result = get?.()
if (!result) {
setState("key", undefined)
onCleanup(() => {
dead = true
})
return
}
if (result instanceof Promise) {
void result.then((next) => {
if (dead) return
setState("key", next ?? undefined)
})
onCleanup(() => {
dead = true
})
return
}
setState("key", ServerConnection.Key.make(result))
onCleanup(() => {
dead = true
})
})
return {
key: () => {
return state.key
},
refresh: () => setState("tick", (value) => value + 1),
}
}
type ServerStatusItem = {
key: ServerConnection.Key
conn: ServerConnection.Any
health?: ServerHealth
blocked: boolean
active: boolean
onSelect: () => void
}
export function StatusPopoverBody(props: { shown: boolean }) { export function StatusPopoverBody(props: { shown: boolean }) {
const sync = useSync() const data = useData()
const sdk = useSDK() const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const language = useLanguage() const language = useLanguage()
const fail = (err: unknown) => { const toggleMcp = useMcpToggle(() => sdk().directory)
showToast({ const mcp = () => data.location.mcp.server.list({ directory: sdk().directory }) ?? []
variant: "error", const mcpNames = createMemo(() =>
title: language.t("common.requestFailed"), mcp()
description: err instanceof Error ? err.message : String(err), .map((server) => server.name)
}) .sort((a, b) => a.localeCompare(b)),
} )
const mcpStatus = (name: string) => mcp().find((server) => server.name === name)?.status.status
let dialogRun = 0
onCleanup(() => {
dialogRun += 1
})
const toggleMcp = useMcpToggle()
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync().data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
const [pluginList] = createResource( const [pluginList] = createResource(
() => (props.shown ? sdk().directory : undefined), () => (props.shown ? sdk().directory : undefined),
(directory) => (directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
sdk()
.api.plugin.list({ location: { directory } })
.then((result) => result.data),
) )
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id)) const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
const pluginCount = createMemo(() => plugins().length) const pluginCount = createMemo(() => plugins().length)
@@ -150,10 +57,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""} {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
{language.t("status.popover.tab.mcp")} {language.t("status.popover.tab.mcp")}
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular"> {/* TODO: Restore LSP status when V2 exposes it. */}
{lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")}
</Tabs.Trigger>
<Show when={true}> <Show when={true}>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular"> <Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""} {pluginCount() > 0 ? `${pluginCount()} ` : ""}
@@ -223,34 +127,6 @@ export function StatusPopoverBody(props: { shown: boolean }) {
</div> </div>
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="lsp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={lspItems().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.lsp.empty")}</div>
}
>
<For each={lspItems()}>
{(item) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": item.status === "connected",
"bg-icon-critical-base": item.status === "error",
}}
/>
<span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
</div>
)}
</For>
</Show>
</div>
</div>
</Tabs.Content>
<Show when={true}> <Show when={true}>
<Tabs.Content value="plugins"> <Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2"> <div class="flex flex-col px-2 pb-2">
+16 -14
View File
@@ -5,16 +5,14 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover" import { Popover } from "@opencode-ai/ui/popover"
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js" import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection, useServers } from "@/context/servers"
import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { import {
hasNonBlockingServiceIssue, hasNonBlockingServiceIssue,
hasServiceNeedingAttention, hasServiceNeedingAttention,
serverStatusDotClass, serverStatusDotClass,
} from "./status-popover-indicator" } from "./status-popover-indicator"
import { useServer } from "@/context/server" import { useData, useServer } from "@/context/server"
import { useWorkspaceLocation } from "@/context/location"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
@@ -22,19 +20,21 @@ export function StatusPopover() {
const language = useLanguage() const language = useLanguage()
const server = useServer() const server = useServer()
const global = useGlobal() const global = useGlobal()
const sync = useSync() const data = useData()
const sdk = useWorkspaceLocation()
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
const ready = createMemo(() => serverHealth() === false || mcp() !== undefined)
const attention = createMemo(() => const attention = createMemo(() =>
hasServiceNeedingAttention({ hasServiceNeedingAttention({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), mcp: (mcp() ?? []).map((item) => item.status.status),
}), }),
) )
const issue = createMemo(() => const issue = createMemo(() =>
hasNonBlockingServiceIssue({ hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), mcp: (mcp() ?? []).map((item) => item.status.status),
lsp: (sync().data.lsp ?? []).map((item) => item.status), lsp: [],
}), }),
) )
@@ -86,19 +86,21 @@ export function StatusPopoverV2() {
const language = useLanguage() const language = useLanguage()
const server = useServer() const server = useServer()
const global = useGlobal() const global = useGlobal()
const sync = useSync() const data = useData()
const sdk = useWorkspaceLocation()
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
const ready = createMemo(() => serverHealth() === false || mcp() !== undefined)
const attention = createMemo(() => const attention = createMemo(() =>
hasServiceNeedingAttention({ hasServiceNeedingAttention({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), mcp: (mcp() ?? []).map((item) => item.status.status),
}), }),
) )
const issue = createMemo(() => const issue = createMemo(() =>
hasNonBlockingServiceIssue({ hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), mcp: (mcp() ?? []).map((item) => item.status.status),
lsp: (sync().data.lsp ?? []).map((item) => item.status), lsp: [],
}), }),
) )
const state = createMemo<StatusPopoverState>(() => ({ const state = createMemo<StatusPopoverState>(() => ({
+9 -7
View File
@@ -10,7 +10,8 @@ import { SerializeAddon } from "@/addons/serialize"
import { matchKeybind, parseKeybind } from "@/context/command" import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk" import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk"
import { terminalFontFamily, useSettings } from "@/context/settings" import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal" import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
@@ -171,13 +172,14 @@ const persistTerminal = (input: {
export const Terminal = (props: TerminalProps) => { export const Terminal = (props: TerminalProps) => {
const platform = usePlatform() const platform = usePlatform()
const sdk = useSDK() const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const settings = useSettings() const settings = useSettings()
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
// Intentional mount-time capture: the imperative xterm/WebSocket lifecycle needs stable values, and Terminal remounts when the SDK scope changes. // Intentional mount-time capture: the imperative xterm/WebSocket lifecycle needs stable values, and Terminal remounts when the SDK scope changes.
const directory = sdk().directory const directory = sdk().directory
const url = sdk().url const url = serverSDK.url
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, [ const [local, others] = splitProps(props, [
"pty", "pty",
@@ -235,8 +237,8 @@ export const Terminal = (props: TerminalProps) => {
} }
const pushSize = async (cols: number, rows: number) => { const pushSize = async (cols: number, rows: number) => {
return sdk() return serverSDK.api.pty
.api.pty.update({ .update({
ptyID: id, ptyID: id,
location: { directory }, location: { directory },
size: { cols, rows }, size: { cols, rows },
@@ -519,8 +521,8 @@ export const Terminal = (props: TerminalProps) => {
} }
const gone = async () => { const gone = async () => {
return sdk() return serverSDK.api.pty
.api.pty.get({ ptyID: id, location: { directory } }) .get({ ptyID: id, location: { directory } })
.then((result) => result.data.status === "exited") .then((result) => result.data.status === "exited")
.catch((err) => { .catch((err) => {
if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount, Show } from "solid-js" import { createEffect, createMemo, createResource, For, onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { createTabPromptState } from "@/context/prompt" import { createTabPromptState } from "@/context/prompt"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order" import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
@@ -84,32 +84,35 @@ function SessionTabEntry(props: {
const tabs = useTabs() const tabs = useTabs()
const language = useLanguage() const language = useLanguage()
const sdk = createMemo(() => props.serverCtx?.sdk ?? null) const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx?.sync.session.peek(props.tab.sessionId)) const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id]) const persisted = createMemo(() => tabs.info[props.id])
const [loadedSession] = createResource( const [loadedSession] = createResource(
() => { () => {
const ctx = props.serverCtx const ctx = props.serverCtx
return ctx ? { id: props.tab.sessionId, ctx } : null return ctx ? { id: props.tab.sessionId, ctx } : null
}, },
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined), ({ id, ctx }) =>
ctx.data.session
.sync(id)
.then(() => ctx.data.session.get(id))
.catch(() => undefined),
) )
const session = createMemo(() => cachedSession() ?? loadedSession()) const session = createMemo(() => cachedSession() ?? loadedSession())
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session()) const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session())
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title) const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
let prefetched = false
const rename = async (title: string) => { const rename = async (title: string) => {
const value = session() const value = session()
const ctx = props.serverCtx const ctx = props.serverCtx
if (!value || !ctx) return if (!value || !ctx) return
ctx.sync.session.remember({ ...value, title }) ctx.data.session.remember({ ...value, title })
try { try {
await ctx.sdk.api.session.rename({ sessionID: value.id, title }) await ctx.sdk.api.session.rename({ sessionID: value.id, title })
} catch (err) { } catch (err) {
const current = session() const current = session()
const currentCtx = props.serverCtx const currentCtx = props.serverCtx
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title }) if (current && currentCtx) currentCtx.data.session.remember({ ...current, title: value.title })
showToast({ showToast({
title: language.t("common.requestFailed"), title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : undefined, description: err instanceof Error ? err.message : undefined,
@@ -122,19 +125,19 @@ function SessionTabEntry(props: {
createEffect(() => { createEffect(() => {
const ctx = props.serverCtx const ctx = props.serverCtx
const value = session() const value = session()
if (!ctx || !value || prefetched) return if (!ctx || !value || props.active || ctx.sdk.connection.status() !== "connected") return
prefetched = true const timer = window.setTimeout(
createRoot((dispose) => { () =>
try { void Promise.allSettled([
void ctx.sync ctx.data.session.sync(value.id, { children: true }),
.ensureDirSyncContext(value.location.directory) ctx.data.session.pending.sync(value.id),
.session.sync(value.id) ctx.data.session.message.sync(value.id),
.catch(() => {}) ctx.data.session.permission.sync(value.id),
.finally(dispose) ctx.data.session.form.sync(value.id),
} catch { ]),
dispose() 300 + props.index * 50,
} )
}) onCleanup(() => window.clearTimeout(timer))
}) })
createEffect(() => { createEffect(() => {
+8 -7
View File
@@ -254,13 +254,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
} }
case "home": { case "home": {
const selection = layout.home.selection() const selection = layout.home.selection()
const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server) const conn =
const project = conn global.servers.list().find((item) => ServerConnection.key(item) === selection.server) ??
? global global.servers.list()[0]
.ensureServerCtx(conn) const projects = conn ? global.ensureServerCtx(conn).projects : undefined
.projects.list() const project =
.find((item) => item.worktree === selection.directory) projects?.list().find((item) => item.worktree === selection.directory) ??
: undefined projects?.list().find((item) => item.worktree === projects.last()) ??
projects?.list()[0]
if (conn && project) { if (conn && project) {
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "") tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
return return
+3 -3
View File
@@ -2,14 +2,14 @@ import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { createScopedCache } from "@/utils/scoped-cache" import { createScopedCache } from "@/utils/scoped-cache"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
import type { SelectedLineRange } from "@/context/file" import type { SelectedLineRange } from "@/context/file"
import { useSDK } from "./sdk" import { useWorkspaceLocation } from "./location"
export type LineComment = { export type LineComment = {
id: string id: string
@@ -210,7 +210,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
gate: false, gate: false,
init: () => { init: () => {
const params = useParams() const params = useParams()
const sdk = useSDK() const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const cache = createScopedCache( const cache = createScopedCache(
(key) => { (key) => {
-127
View File
@@ -1,127 +0,0 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { SessionInboxInfo, SessionInfo } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk"
import type { createServerSyncContextInner } from "./server-sync"
import type { PromptEcho } from "./server-session"
import type { State } from "./global-sync/types"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const sessionFields = new Set([
"session_status",
"session_working",
"session_diff",
"todo",
"permission",
"question",
"message",
"session_message",
"part",
"part_text_accum_delta",
])
export const createDirSyncContext = (
directory: string,
serverSync: ReturnType<typeof createServerSyncContextInner>,
serverSDK: ReturnType<typeof createServerSdkContext>,
) => {
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const data = new Proxy({} as State, {
get(_, property: keyof State) {
if (property === "session_working") return serverSync.session.data.session_working.bind(serverSync.session.data)
if (sessionFields.has(property)) return serverSync.session.data[property as keyof typeof serverSync.session.data]
return current()[0][property]
},
})
const set = ((...input: unknown[]) => {
if (typeof input[0] === "string" && sessionFields.has(input[0])) {
return (serverSync.session.set as (...args: unknown[]) => unknown)(...input)
}
const result = (current()[1] as (...args: unknown[]) => unknown)(...input)
if (input[0] === "session") current()[0].session.forEach(serverSync.session.remember)
return result
}) as SetStoreFunction<State>
const index = (sessionID: string) => {
const session = serverSync.session.get(sessionID)
if (!session || session.location.directory !== directory) return
const [store, setStore] = current()
const result = Binary.search(store.session, session.id, (item) => item.id)
if (result.found) {
setStore("session", result.index, reconcile(session))
return
}
setStore(
"session",
produce((draft) => void draft.splice(result.index, 0, session)),
)
}
return {
data,
set,
get status() {
return current()[0].status
},
get ready() {
return current()[0].status !== "loading"
},
get project() {
const store = current()[0]
const match = Binary.search(serverSync.data.project, store.project, (project) => project.id)
if (match.found) return serverSync.data.project[match.index]
},
session: {
remember(session: SessionInfo) {
serverSync.session.remember(session)
index(session.id)
},
get(sessionID: string) {
const session = serverSync.session.get(sessionID)
if (session?.location.directory === directory) return session
},
inbox: {
echo(input: PromptEcho & { directory?: string }) {
serverSync.session.inbox.echo(input)
},
confirm(input: SessionInboxInfo) {
return serverSync.session.inbox.confirm(input)
},
clearEcho(input: { directory?: string; sessionID: string; messageID: string }) {
return serverSync.session.inbox.clearEcho(input)
},
},
async sync(sessionID: string, options?: { force?: boolean }) {
await serverSync.session.sync(sessionID, options)
index(sessionID)
},
todo: serverSync.session.todo,
history: serverSync.session.history,
evict(sessionID: string) {
serverSync.session.evict(sessionID)
},
fetch: async (count = 10) => {
const [store, setStore] = current()
setStore("limit", (value) => value + count)
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
const sessions = response.data.sort((a, b) => cmp(a.id, b.id)).slice(0, store.limit)
sessions.forEach(serverSync.session.remember)
setStore("session", reconcile(sessions, { key: "id" }))
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
// TODO: Restore archiving when the V2 client exposes a session archive API.
void sessionID
},
},
mcp: {
toggle: (name: string) => serverSync.mcp.toggle(directory, name),
},
absolute,
get directory() {
return current()[0].path.directory
},
}
}
+16 -20
View File
@@ -3,10 +3,9 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/util/path"
import { useSDK } from "./sdk" import { useWorkspaceLocation } from "./location"
import { useSync } from "./sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { createPathHelpers } from "./file/path" import { createPathHelpers } from "./file/path"
@@ -56,8 +55,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
name: "File", name: "File",
gate: false, gate: false,
init: () => { init: () => {
const sdk = useSDK() const sdk = useWorkspaceLocation()
useSync()
const params = useParams() const params = useParams()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const language = useLanguage() const language = useLanguage()
@@ -80,16 +78,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
scope, scope,
normalizeDir: path.normalizeDir, normalizeDir: path.normalizeDir,
list: (dir) => list: (dir) =>
sdk() serverSDK.api.file.list({ path: dir, location: { directory: scope() } }).then((x) =>
.api.file.list({ path: dir, location: { directory: scope() } }) x.data.map((entry) => ({
.then((x) => ...entry,
x.data.map((entry) => ({ name: entry.path.split("/").at(-1) ?? entry.path,
...entry, absolute: `${scope()}/${entry.path}`,
name: entry.path.split("/").at(-1) ?? entry.path, ignored: false,
absolute: `${scope()}/${entry.path}`, })),
ignored: false, ),
})),
),
onError: (message) => { onError: (message) => {
showToast({ showToast({
variant: "error", variant: "error",
@@ -187,8 +183,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setLoading(file) setLoading(file)
const promise = sdk() const promise = serverSDK.api.file
.api.file.read({ path: file, location: { directory } }) .read({ path: file, location: { directory } })
.then((data) => { .then((data) => {
if (scope() !== directory) return if (scope() !== directory) return
const content = { type: "text" as const, content: new TextDecoder().decode(data) } const content = { type: "text" as const, content: new TextDecoder().decode(data) }
@@ -229,8 +225,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}, },
) )
const stop = sdk().event.listen((e) => { const stop = sdk().event.on("filesystem.changed", (event) => {
invalidateFromWatcher(e.details, { invalidateFromWatcher(event, {
normalize: path.normalize, normalize: path.normalize,
hasFile: (file) => Boolean(store.file[file]), hasFile: (file) => Boolean(store.file[file]),
isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file), isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file),
+64 -114
View File
@@ -1,27 +1,28 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { invalidateFromWatcher } from "./watcher" import { invalidateFromWatcher } from "./watcher"
type FilesystemEvent = Extract<OpenCodeEvent, { type: "filesystem.changed" }>
const filesystemEvent = (file: string, event: FilesystemEvent["data"]["event"]): FilesystemEvent => ({
id: `evt_${file}`,
created: 1,
type: "filesystem.changed",
data: { file, event },
})
describe("file watcher invalidation", () => { describe("file watcher invalidation", () => {
test("reloads open files and refreshes loaded parent on add", () => { test("reloads open files and refreshes loaded parent on add", () => {
const loads: string[] = [] const loads: string[] = []
const refresh: string[] = [] const refresh: string[] = []
invalidateFromWatcher( invalidateFromWatcher(filesystemEvent("src/new.ts", "add"), {
{ normalize: (input) => input,
type: "filesystem.changed", hasFile: (path) => path === "src/new.ts",
properties: { loadFile: (path) => loads.push(path),
file: "src/new.ts", node: () => undefined,
event: "add", isDirLoaded: (path) => path === "src",
}, refreshDir: (path) => refresh.push(path),
}, })
{
normalize: (input) => input,
hasFile: (path) => path === "src/new.ts",
loadFile: (path) => loads.push(path),
node: () => undefined,
isDirLoaded: (path) => path === "src",
refreshDir: (path) => refresh.push(path),
},
)
expect(loads).toEqual(["src/new.ts"]) expect(loads).toEqual(["src/new.ts"])
expect(refresh).toEqual(["src"]) expect(refresh).toEqual(["src"])
@@ -30,30 +31,21 @@ describe("file watcher invalidation", () => {
test("reloads files that are open in tabs", () => { test("reloads files that are open in tabs", () => {
const loads: string[] = [] const loads: string[] = []
invalidateFromWatcher( invalidateFromWatcher(filesystemEvent("src/open.ts", "change"), {
{ normalize: (input) => input,
type: "filesystem.changed", hasFile: () => false,
properties: { isOpen: (path) => path === "src/open.ts",
file: "src/open.ts", loadFile: (path) => loads.push(path),
event: "change", node: () => ({
}, path: "src/open.ts",
}, type: "file",
{ name: "open.ts",
normalize: (input) => input, absolute: "/repo/src/open.ts",
hasFile: () => false, ignored: false,
isOpen: (path) => path === "src/open.ts", }),
loadFile: (path) => loads.push(path), isDirLoaded: () => false,
node: () => ({ refreshDir: () => {},
path: "src/open.ts", })
type: "file",
name: "open.ts",
absolute: "/repo/src/open.ts",
ignored: false,
}),
isDirLoaded: () => false,
refreshDir: () => {},
},
)
expect(loads).toEqual(["src/open.ts"]) expect(loads).toEqual(["src/open.ts"])
}) })
@@ -61,47 +53,29 @@ describe("file watcher invalidation", () => {
test("refreshes only changed loaded directory nodes", () => { test("refreshes only changed loaded directory nodes", () => {
const refresh: string[] = [] const refresh: string[] = []
invalidateFromWatcher( invalidateFromWatcher(filesystemEvent("src", "change"), {
{ normalize: (input) => input,
type: "filesystem.changed", hasFile: () => false,
properties: { loadFile: () => {},
file: "src", node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
event: "change", isDirLoaded: (path) => path === "src",
}, refreshDir: (path) => refresh.push(path),
}, })
{
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
isDirLoaded: (path) => path === "src",
refreshDir: (path) => refresh.push(path),
},
)
invalidateFromWatcher( invalidateFromWatcher(filesystemEvent("src/file.ts", "change"), {
{ normalize: (input) => input,
type: "filesystem.changed", hasFile: () => false,
properties: { loadFile: () => {},
file: "src/file.ts", node: () => ({
event: "change", path: "src/file.ts",
}, type: "file",
}, name: "file.ts",
{ absolute: "/repo/src/file.ts",
normalize: (input) => input, ignored: false,
hasFile: () => false, }),
loadFile: () => {}, isDirLoaded: () => true,
node: () => ({ refreshDir: (path) => refresh.push(path),
path: "src/file.ts", })
type: "file",
name: "file.ts",
absolute: "/repo/src/file.ts",
ignored: false,
}),
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
},
)
expect(refresh).toEqual(["src"]) expect(refresh).toEqual(["src"])
}) })
@@ -109,40 +83,16 @@ describe("file watcher invalidation", () => {
test("ignores invalid or git watcher updates", () => { test("ignores invalid or git watcher updates", () => {
const refresh: string[] = [] const refresh: string[] = []
invalidateFromWatcher( invalidateFromWatcher(filesystemEvent(".git/index.lock", "change"), {
{ normalize: (input) => input,
type: "filesystem.changed", hasFile: () => true,
properties: { loadFile: () => {
file: ".git/index.lock", throw new Error("should not load")
event: "change",
},
}, },
{ node: () => undefined,
normalize: (input) => input, isDirLoaded: () => true,
hasFile: () => true, refreshDir: (path) => refresh.push(path),
loadFile: () => { })
throw new Error("should not load")
},
node: () => undefined,
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
},
)
invalidateFromWatcher(
{
type: "project.updated",
properties: {},
},
{
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => undefined,
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
},
)
expect(refresh).toEqual([]) expect(refresh).toEqual([])
}) })
+7 -25
View File
@@ -1,9 +1,7 @@
import type { FileNode } from "@/types" import type { FileNode } from "@/types"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
type WatcherEvent = { type WatcherEvent = Extract<OpenCodeEvent, { type: "filesystem.changed" }>
type: string
properties: unknown
}
type WatcherOps = { type WatcherOps = {
normalize: (input: string) => string normalize: (input: string) => string
@@ -16,15 +14,7 @@ type WatcherOps = {
} }
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (event.type !== "filesystem.changed") return const path = ops.normalize(event.data.file)
const props =
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
const rawPath = typeof props?.file === "string" ? props.file : undefined
const kind = typeof props?.event === "string" ? props.event : undefined
if (!rawPath) return
if (!kind) return
const path = ops.normalize(rawPath)
if (!path) return if (!path) return
if (path.startsWith(".git/")) return if (path.startsWith(".git/")) return
@@ -32,20 +22,12 @@ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
ops.loadFile(path) ops.loadFile(path)
} }
if (kind === "change") { if (event.data.event === "change") {
const dir = (() => { if (ops.node(path)?.type !== "directory") return
if (path === "") return "" if (!ops.isDirLoaded(path)) return
const node = ops.node(path) ops.refreshDir(path)
if (node?.type !== "directory") return
return path
})()
if (dir === undefined) return
if (!ops.isDirLoaded(dir)) return
ops.refreshDir(dir)
return return
} }
if (kind !== "add" && kind !== "unlink") return
const parent = path.split("/").slice(0, -1).join("/") const parent = path.split("/").slice(0, -1).join("/")
if (!ops.isDirLoaded(parent)) return if (!ops.isDirLoaded(parent)) return
@@ -1,15 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { QueryClient } from "@tanstack/solid-query" import { QueryClient } from "@tanstack/solid-query"
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise" import { loadPathQuery, loadProjectsQuery } from "./bootstrap"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import {
loadAgentsQuery,
loadCommands,
loadPathQuery,
loadProjectsQuery,
loadProvidersQuery,
loadReferencesQuery,
} from "./bootstrap"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
@@ -18,44 +9,11 @@ type WorktreeApi = ServerApi["worktree"]
describe("query keys", () => { describe("query keys", () => {
test("partitions identical directories by server scope", () => { test("partitions identical directories by server scope", () => {
const api = {} as CatalogApi
const location = {} as ServerApi["location"] const location = {} as ServerApi["location"]
const remote = "https://debian.example" as typeof ServerScope.local const remote = "https://debian.example" as typeof ServerScope.local
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual(["local", "/repo", "path"]) expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual(["local", "/repo", "path"])
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual(["https://debian.example", "/repo", "path"]) expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
})
test("loads the current provider and model catalog", async () => {
const calls: unknown[] = []
const api = {
provider: {
list: async (input: unknown) => {
calls.push(["provider", input])
return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] }
},
},
model: {
list: async (input: unknown) => {
calls.push(["model", input])
return { location: {}, data: [] }
},
default: async (input: unknown) => {
calls.push(["default", input])
return { location: {}, data: null }
},
},
} as unknown as CatalogApi
const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api))
expect(calls).toEqual([
["provider", { location: { directory: "/repo" } }],
["model", { location: { directory: "/repo" } }],
["default", { location: { directory: "/repo" } }],
])
expect(result.connected).toEqual(["openai"])
}) })
test("loads current location metadata", async () => { test("loads current location metadata", async () => {
@@ -73,39 +31,6 @@ describe("query keys", () => {
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" }) expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
}) })
test("loads agents from the current location-scoped endpoint", async () => {
const calls: unknown[] = []
const api = {
list: async (input: unknown) => {
calls.push(input)
return { location: {}, data: [] }
},
} as unknown as AgentApi
const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api))
expect(calls).toEqual([{ location: { directory: "/repo" } }])
expect(result).toEqual([])
})
test("loads commands from the current location-scoped endpoint", async () => {
const calls: unknown[] = []
const api = {
list: async (input: unknown) => {
calls.push(input)
return {
location: {},
data: [{ name: "review", template: "Review files" /* source: "command" as const */ }],
}
},
} as unknown as CommandApi
const result = await loadCommands("/repo", api)
expect(calls).toEqual([{ location: { directory: "/repo" } }])
expect(result).toEqual([{ name: "review", template: "Review files" /* source: "command" */ }])
})
test("loads projects from the current endpoint", async () => { test("loads projects from the current endpoint", async () => {
const calls: string[] = [] const calls: string[] = []
const projects = { const projects = {
@@ -160,19 +85,4 @@ describe("query keys", () => {
{ id: "b", sandboxes: [] }, { id: "b", sandboxes: [] },
]) ])
}) })
test("loads references from the current location-scoped endpoint", async () => {
const calls: unknown[] = []
const api = {
list: async (input: unknown) => {
calls.push(input)
return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] }
},
} as unknown as ReferenceApi
const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api))
expect(calls).toEqual([{ location: { directory: "/repo" } }])
expect(result).toHaveLength(1)
})
}) })
@@ -1,46 +1,26 @@
import type { Config, Path, Project, ProviderAuthResponse } from "@/types" import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
import type { import type {
AgentListInput,
AgentListOutput,
CatalogApi,
CommandInfo,
CommandListInput,
CommandListOutput,
IntegrationListInput,
IntegrationListOutput,
LocationGetInput, LocationGetInput,
LocationGetOutput, LocationGetOutput,
PermissionRequest,
ProjectCurrentInput, ProjectCurrentInput,
ProjectCurrentOutput, ProjectCurrentOutput,
ProjectListOutput, ProjectListOutput,
ReferenceListInput,
ReferenceListOutput,
ReferenceInfo,
SessionApi,
SessionInfo,
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/util/path"
import { retry } from "@opencode-ai/core/util/retry" import { retry } from "@opencode-ai/util/retry"
import { batch } from "solid-js" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State } from "./types" import type { State } from "./types"
import type { ServerSession } from "../server-session" import { cmp, normalizeProjectInfo } from "./utils"
import { cmp, directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query" import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" import type { ServerScope } from "@/utils/server-scope"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
import { sameDirectory } from "@/utils/workspace" import { sameDirectory } from "@/utils/workspace"
type GlobalStore = { type GlobalStore = {
ready: boolean
path: Path path: Path
project: Project[] project: Project[]
provider: NormalizedProviderListResponse
provider_auth: ProviderAuthResponse provider_auth: ProviderAuthResponse
config: Config config: Config
reload: undefined | "pending" | "complete" reload: undefined | "pending" | "complete"
@@ -69,12 +49,6 @@ function errors(list: PromiseSettledResult<unknown>[]) {
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason) return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
} }
const providerRev = new Map<string, number>()
export function clearProviderRev(scope: ServerScope, directory: string) {
providerRev.delete(ScopedKey.from(scope, directory))
}
function runAll(list: Array<() => Promise<unknown>>) { function runAll(list: Array<() => Promise<unknown>>) {
return Promise.allSettled(list.map((item) => item())) return Promise.allSettled(list.map((item) => item()))
} }
@@ -109,10 +83,6 @@ type ProjectApi = {
type WorktreeApi = Pick<ServerApi["worktree"], "list"> type WorktreeApi = Pick<ServerApi["worktree"], "list">
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> } type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
type McpApi = ServerApi["mcp"]
type PermissionApi = ServerApi["permission"]
type VcsApi = ServerApi["vcs"]
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) => export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
queryOptions({ queryOptions({
queryKey: [scope, "project"], queryKey: [scope, "project"],
@@ -150,7 +120,7 @@ export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, work
}) })
export async function bootstrapGlobal(input: { export async function bootstrapGlobal(input: {
serverAPI: CatalogApi & { serverAPI: {
readonly location: LocationApi readonly location: LocationApi
readonly project: ProjectApi readonly project: ProjectApi
readonly worktree: WorktreeApi readonly worktree: WorktreeApi
@@ -164,7 +134,6 @@ export async function bootstrapGlobal(input: {
}) { }) {
const slow = [ const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)), () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverAPI)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)), () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
() => () =>
input.queryClient input.queryClient
@@ -180,97 +149,10 @@ export async function bootstrapGlobal(input: {
// }) // })
} }
function groupBySession<T extends { id: string; sessionID: string }>(input: T[]) {
return input.reduce<Record<string, T[]>>((acc, item) => {
if (!item?.id || !item.sessionID) return acc
const list = acc[item.sessionID]
if (list) list.push(item)
if (!list) acc[item.sessionID] = [item]
return acc
}, {})
}
function projectID(directory: string, projects: Project[]) { function projectID(directory: string, projects: Project[]) {
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
} }
function mergeSession(setStore: SetStoreFunction<State>, session: SessionInfo) {
setStore("session", (list) => {
const next = list.slice()
const idx = next.findIndex((item) => item.id >= session.id)
if (idx === -1) return [...next, session]
if (next[idx]?.id === session.id) {
next[idx] = session
return next
}
next.splice(idx, 0, session)
return next
})
}
function warmSessions(input: {
ids: string[]
store: Store<State>
setStore: SetStoreFunction<State>
api: SessionApi
}) {
const known = new Set(input.store.session.map((item) => item.id))
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
if (ids.length === 0) return Promise.resolve()
return Promise.all(
ids.map((sessionID) =>
retry(() => input.api.get({ sessionID })).then((session) => mergeSession(input.setStore, session)),
),
).then(() => undefined)
}
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: CatalogApi) =>
queryOptions({
queryKey: [scope, directory, "providers"],
queryFn: () =>
retry(async () => {
const location = directory ? { location: { directory } } : undefined
const [providers, models, defaultModel] = await Promise.all([
sdk.provider.list(location),
sdk.model.list(location),
sdk.model.default(location),
])
return normalizeProviderList(providers.data, models.data, defaultModel.data)
}),
})
type AgentListApi = {
readonly list: (input?: AgentListInput) => Promise<AgentListOutput>
}
type CommandListApi = {
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
}
type IntegrationListApi = {
readonly list: (input?: IntegrationListInput) => Promise<IntegrationListOutput>
}
type ReferenceListApi = {
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
}
export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: AgentListApi) =>
queryOptions({
queryKey: [scope, directory, "agents"],
queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
})
export const loadIntegrationsQuery = (scope: ServerScope, directory: string | null, sdk: IntegrationListApi) =>
queryOptions({
queryKey: [scope, directory, "integrations"] as const,
queryFn: () =>
retry(() => sdk.list(directory ? { location: { directory } } : undefined).then((result) => result.data)),
})
export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> =>
retry(() => api.list({ location: { directory } }).then((result) => result.data))
export const loadPathQuery = (scope: ServerScope, directory: string | null, api: LocationApi) => export const loadPathQuery = (scope: ServerScope, directory: string | null, api: LocationApi) =>
queryOptions<Path>({ queryOptions<Path>({
queryKey: [scope, directory, "path"], queryKey: [scope, directory, "path"],
@@ -284,130 +166,37 @@ export const loadPathQuery = (scope: ServerScope, directory: string | null, api:
})), })),
}) })
export const loadReferencesQuery = (scope: ServerScope, directory: string, api: ReferenceListApi) =>
queryOptions<ReferenceInfo[]>({
queryKey: [scope, directory, "references"] as const,
queryFn: () => retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []),
placeholderData: [],
})
export async function bootstrapDirectory(input: { export async function bootstrapDirectory(input: {
directory: string directory: string
scope: ServerScope scope: ServerScope
mcp: boolean mcp: boolean
api: CatalogApi & { api: {
readonly agent: AgentListApi
readonly command: CommandListApi
readonly mcp: McpApi
readonly permission: PermissionApi
readonly project: ProjectApi readonly project: ProjectApi
readonly reference: ReferenceListApi
readonly session: SessionApi
readonly vcs: VcsApi
readonly location: LocationApi
} }
store: Store<State> store: Store<State>
setStore: SetStoreFunction<State> setStore: SetStoreFunction<State>
loadSessions: (directory: string) => Promise<void> | void
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
global: { global: {
config: Config config: Config
path: Path path: Path
project: Project[] project: Project[]
provider: NormalizedProviderListResponse
} }
queryClient: QueryClient queryClient: QueryClient
session?: ServerSession
}) { }) {
const loading = input.store.status !== "complete" const loading = input.store.status !== "complete"
const seededProject = projectID(input.directory, input.global.project) const seededProject = projectID(input.directory, input.global.project)
const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined
if (seededProject) input.setStore("project", seededProject) if (seededProject) input.setStore("project", seededProject)
if (seededPath) input.setStore("path", seededPath)
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
input.setStore("config", reconcile(input.global.config, { merge: false })) input.setStore("config", reconcile(input.global.config, { merge: false }))
} }
if (loading) input.setStore("status", "partial") if (loading) input.setStore("status", "partial")
const revKey = ScopedKey.from(input.scope, input.directory)
const rev = (providerRev.get(revKey) ?? 0) + 1
providerRev.set(revKey, rev)
const slow = [ const slow = [
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
input.queryClient
.ensureQueryData(loadAgentsQuery(input.scope, directoryKey(input.directory), input.api.agent))
.then((data) => input.setStore("agent", data)),
!seededProject && !seededProject &&
(() => (() =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) => retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
input.setStore("project", project.id), input.setStore("project", project.id),
)), )),
!seededPath &&
(() =>
input.queryClient
.ensureQueryData(loadPathQuery(input.scope, directoryKey(input.directory), input.api.location))
.then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
input.mcp &&
(() => loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
() =>
input.queryClient.fetchQuery(
loadReferencesQuery(input.scope, directoryKey(input.directory), input.api.reference),
),
() =>
retry(() =>
input.api.permission.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession(
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() =>
batch(() => {
const current = input.session?.data.permission ?? input.store.permission
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("permission", sessionID, [])
if (!input.session) input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
const value = reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
)
if (input.session) input.session.set("permission", sessionID, value)
if (!input.session) input.setStore("permission", sessionID, value)
}
}),
)
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
input.mcp &&
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
input.mcp &&
(() =>
input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
() =>
input.queryClient
.fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api))
.catch((err) => {
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate),
})
}),
].filter(Boolean) as (() => Promise<any>)[] ].filter(Boolean) as (() => Promise<any>)[]
await waitForPaint() await waitForPaint()
@@ -1,14 +1,13 @@
import { beforeAll, describe, expect, mock, test } from "bun:test" import { beforeAll, describe, expect, mock, test } from "bun:test"
import { createRoot, getOwner, type Owner } from "solid-js" import { createRoot, getOwner, type Owner } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { State } from "./types" import type { State } from "./types"
import type { QueryOptionsApi } from "../server-sync" import type { QueryOptionsApi } from "../server-sync"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
import type { Data } from "@opencode-ai/client/solid"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager let createChildStoreManager: typeof import("./child-store").createChildStoreManager
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = [] const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
let providerQuerySuccess = true
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [ const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
store[0], store[0],
store[1], store[1],
@@ -17,12 +16,26 @@ const persist: typeof import("@/utils/persist").persisted = (_target, store) =>
] ]
const child = () => createStore({} as State) const child = () => createStore({} as State)
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse const path = { state: "", config: "", worktree: "", directory: "", home: "" }
const data = {
location: {
info: () => undefined,
agent: { list: () => undefined },
command: { list: () => undefined },
reference: { list: () => undefined },
provider: { list: () => undefined },
model: { list: () => undefined, default: () => undefined },
mcp: {
server: { list: () => undefined },
resource: { list: () => undefined },
},
vcs: { info: () => undefined },
},
} as unknown as Data
const queryOptionsApi = { const queryOptionsApi = {
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }), globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }), projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }),
providers: (directory: string | null) => ({ queryKey: [directory, "providers"], queryFn: async () => provider }),
path: (directory: string | null) => ({ path: (directory: string | null) => ({
queryKey: [directory, "path"], queryKey: [directory, "path"],
queryFn: async () => ({ queryFn: async () => ({
@@ -60,7 +73,7 @@ beforeAll(async () => {
return options().queryKey?.[1] === "path" return options().queryKey?.[1] === "path"
}, },
get isSuccess() { get isSuccess() {
return options().queryKey?.[1] === "providers" && options().enabled === true && providerQuerySuccess return false
}, },
get isRefetchError() { get isRefetchError() {
return false return false
@@ -69,7 +82,6 @@ beforeAll(async () => {
if (options().queryKey?.[1] === "path") throw new Error("pending path data read") if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
if (options().queryKey?.[1] === "lsp") return [] if (options().queryKey?.[1] === "lsp") return []
if (options().queryKey?.[1] === "providers") return providerQuerySuccess ? provider : undefined
return undefined return undefined
}, },
} }
@@ -100,7 +112,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
@@ -134,7 +147,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
@@ -144,7 +158,6 @@ describe("createChildStoreManager", () => {
const [store] = manager.child("/project") const [store] = manager.child("/project")
expect(store.status).toBe("loading") expect(store.status).toBe("loading")
expect(store.limit).toBe(5)
expect(bootstraps).toEqual(["/project"]) expect(bootstraps).toEqual(["/project"])
} finally { } finally {
dispose() dispose()
@@ -167,7 +180,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
@@ -198,7 +212,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
@@ -214,7 +229,7 @@ describe("createChildStoreManager", () => {
} }
}) })
test("enables MCP only when requested for the directory", () => { test("syncs MCP only when requested for the directory", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined let manager: ReturnType<typeof createChildStoreManager> | undefined
const offset = querySingles.length const offset = querySingles.length
const mcpLoads: string[] = [] const mcpLoads: string[] = []
@@ -234,30 +249,21 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
try { try {
if (!manager) throw new Error("manager required") if (!manager) throw new Error("manager required")
const [store, setStore] = manager.child("/project", { bootstrap: false }) const [, setStore] = manager.child("/project", { bootstrap: false })
expect(querySingles.length - offset).toBe(6) expect(querySingles.length - offset).toBe(1)
const query = querySingles[offset + 1]
const resourceQuery = querySingles[offset + 2]
if (!query) throw new Error("query required")
if (!resourceQuery) throw new Error("resource query required")
expect(query().enabled).toBe(false)
expect(resourceQuery().enabled).toBe(false)
setStore("status", "complete") setStore("status", "complete")
manager.child("/project", { bootstrap: false, mcp: true }) manager.child("/project", { bootstrap: false, mcp: true })
expect(query().enabled).toBe(true)
expect(resourceQuery().enabled).toBe(true)
expect(store.mcp).toEqual({ demo: { status: "disabled" } })
expect(mcpLoads).toEqual(["/project"]) expect(mcpLoads).toEqual(["/project"])
manager.disableMcp("/project") manager.disableMcp("/project")
expect(query().enabled).toBe(false)
expect(manager.mcp("/project")).toBe(false) expect(manager.mcp("/project")).toBe(false)
} finally { } finally {
dispose() dispose()
@@ -284,7 +290,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
@@ -293,11 +300,8 @@ describe("createChildStoreManager", () => {
const [store] = manager.child("/project", { bootstrap: false }) const [store] = manager.child("/project", { bootstrap: false })
const queries = querySingles.slice(offset) const queries = querySingles.slice(offset)
expect(queries).toHaveLength(6) expect(queries).toHaveLength(1)
expect(queries[0]?.().enabled).toBe(false) expect(queries[0]?.().enabled).toBe(false)
expect(queries[3]?.().enabled).toBe(false)
expect(queries[4]?.().enabled).toBe(false)
expect(queries[5]?.().enabled).toBe(false)
expect(store.path.directory).toBe("/project") expect(store.path.directory).toBe("/project")
expect(store.provider_ready).toBe(false) expect(store.provider_ready).toBe(false)
expect(store.lsp_ready).toBe(false) expect(store.lsp_ready).toBe(false)
@@ -305,9 +309,6 @@ describe("createChildStoreManager", () => {
manager.child("/project") manager.child("/project")
expect(queries[0]?.().enabled).toBe(true) expect(queries[0]?.().enabled).toBe(true)
expect(queries[3]?.().enabled).toBe(true)
expect(queries[4]?.().enabled).toBe(true)
expect(queries[5]?.().enabled).toBe(true)
expect(bootstraps).toEqual(["/project"]) expect(bootstraps).toEqual(["/project"])
manager.child("/project", { bootstrap: false }) manager.child("/project", { bootstrap: false })
@@ -317,9 +318,8 @@ describe("createChildStoreManager", () => {
} }
}) })
test("does not mark a cancelled provider query as ready", () => { test("does not mark unsynced provider data as ready", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined let manager: ReturnType<typeof createChildStoreManager> | undefined
providerQuerySuccess = false
const dispose = createOwner((owner) => { const dispose = createOwner((owner) => {
manager = createChildStoreManager({ manager = createChildStoreManager({
@@ -334,7 +334,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
@@ -343,7 +344,6 @@ describe("createChildStoreManager", () => {
const [store] = manager.child("/cancelled") const [store] = manager.child("/cancelled")
expect(store.provider_ready).toBe(false) expect(store.provider_ready).toBe(false)
} finally { } finally {
providerQuerySuccess = true
dispose() dispose()
} }
}) })
@@ -365,7 +365,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
@@ -374,11 +375,9 @@ describe("createChildStoreManager", () => {
manager.child("/handshake") manager.child("/handshake")
const queries = querySingles.slice(offset) const queries = querySingles.slice(offset)
expect(queries[0]?.().enabled).toBe(false) expect(queries[0]?.().enabled).toBe(false)
expect(queries[4]?.().enabled).toBe(false)
connected = true connected = true
expect(queries[0]?.().enabled).toBe(true) expect(queries[0]?.().enabled).toBe(true)
expect(queries[4]?.().enabled).toBe(true)
} finally { } finally {
dispose() dispose()
} }
@@ -1,7 +1,7 @@
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js" import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@/types" import type { Path, VcsInfo } from "@/types"
import { import {
DIR_IDLE_TTL_MS, DIR_IDLE_TTL_MS,
MAX_DIR_STORES, MAX_DIR_STORES,
@@ -17,8 +17,9 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQuery } from "@tanstack/solid-query" import { useQuery } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../server-sync" import { QueryOptionsApi } from "../server-sync"
import { directoryKey, type DirectoryKey } from "./utils" import { directoryKey, type DirectoryKey } from "./utils"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import type { Data } from "@opencode-ai/client/solid"
import { normalizeAgentList, normalizeProviderList } from "./utils"
export function createChildStoreManager(input: { export function createChildStoreManager(input: {
owner: Owner owner: Owner
@@ -32,8 +33,9 @@ export function createChildStoreManager(input: {
onDispose: (directory: string) => void onDispose: (directory: string) => void
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
queryOptions: QueryOptionsApi queryOptions: QueryOptionsApi
data: Data
global: { global: {
provider: NormalizedProviderListResponse path: Path
} }
}) { }) {
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {} const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
@@ -45,7 +47,6 @@ export function createChildStoreManager(input: {
const ownerPins = new WeakMap<object, Set<string>>() const ownerPins = new WeakMap<object, Set<string>>()
const disposers = new Map<string, () => void>() const disposers = new Map<string, () => void>()
const mcpDirectories = new Set<string>() const mcpDirectories = new Set<string>()
const mcpToggles = new Map<string, (enabled: boolean) => void>()
const activeDirectories = new Set<string>() const activeDirectories = new Set<string>()
const activationToggles = new Map<string, (enabled: boolean) => void>() const activationToggles = new Map<string, (enabled: boolean) => void>()
@@ -120,7 +121,6 @@ export function createChildStoreManager(input: {
iconCache.delete(key) iconCache.delete(key)
lifecycle.delete(key) lifecycle.delete(key)
mcpDirectories.delete(key) mcpDirectories.delete(key)
mcpToggles.delete(key)
activeDirectories.delete(key) activeDirectories.delete(key)
activationToggles.delete(key) activationToggles.delete(key)
const dispose = disposers.get(key) const dispose = disposers.get(key)
@@ -186,78 +186,66 @@ export function createChildStoreManager(input: {
createRoot((dispose) => { createRoot((dispose) => {
const initialMeta = meta[0].value const initialMeta = meta[0].value
const initialIcon = icon[0].value const initialIcon = icon[0].value
const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false) const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
const pathQuery = useQuery(() => ({
...input.queryOptions.path(key),
enabled: input.connected() && instanceQueriesEnabled(),
}))
const mcpQuery = useQuery(() => ({
...input.queryOptions.mcp(key),
enabled: input.connected() && mcpEnabled(),
}))
const mcpResourceQuery = useQuery(() => ({
...input.queryOptions.mcpResources(key),
enabled: input.connected() && mcpEnabled(),
}))
const lspQuery = useQuery(() => ({ const lspQuery = useQuery(() => ({
...input.queryOptions.lsp(key), ...input.queryOptions.lsp(key),
enabled: input.connected() && instanceQueriesEnabled(), enabled: input.connected() && instanceQueriesEnabled(),
})) }))
const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key),
enabled: input.connected() && instanceQueriesEnabled(),
}))
const referenceQuery = useQuery(() => ({
...input.queryOptions.references(key),
enabled: input.connected() && instanceQueriesEnabled(),
}))
const child = createStore<State>({ const child = createStore<State>({
project: "", project: "",
projectMeta: initialMeta, projectMeta: initialMeta,
icon: initialIcon, icon: initialIcon,
get provider_ready() { get provider_ready() {
return instanceQueriesEnabled() && (providerQuery.isSuccess || providerQuery.isRefetchError) return (
input.data.location.provider.list({ directory }) !== undefined &&
input.data.location.model.list({ directory }) !== undefined
)
}, },
get provider() { get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} } const provider = input.data.location.provider.list({ directory })
if (!providerQuery.isSuccess && !providerQuery.isRefetchError) return EMPTY const model = input.data.location.model.list({ directory })
const provider = providerQuery.data if (!provider || !model) return { all: new Map(), connected: [], default: {} }
if (provider.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider return normalizeProviderList(provider, model)
return provider
}, },
config: {}, config: {},
get path() { get path() {
const EMPTY = { state: "", config: "", worktree: "", directory, home: "" } const location = input.data.location.info({ directory })
if (pathQuery.isLoading) return EMPTY return {
return pathQuery.data ?? EMPTY state: "",
config: "",
worktree: location?.project.directory ?? "",
directory: location?.directory ?? directory,
home: input.global.path.home,
}
}, },
status: "loading" as const, status: "loading" as const,
agent: [], get agent() {
command: [], return normalizeAgentList(input.data.location.agent.list({ directory }) ?? [])
},
get command() {
return input.data.location.command.list({ directory }) ?? []
},
get reference() { get reference() {
return referenceQuery.isLoading ? [] : (referenceQuery.data ?? []) return input.data.location.reference.list({ directory }) ?? []
}, },
session: [],
sessionTotal: 0,
session_status: {},
session_working(id: string) {
const type = this.session_status[id]?.type
return (type ?? "idle") !== "idle"
},
session_diff: {},
todo: {},
permission: {},
get mcp_ready() { get mcp_ready() {
return !mcpQuery.isLoading return input.data.location.mcp.server.list({ directory }) !== undefined
}, },
get mcp() { get mcp() {
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {}) return Object.fromEntries(
(input.data.location.mcp.server.list({ directory }) ?? []).map((server) => [
server.name,
server.status,
]),
)
}, },
get mcp_resource() { get mcp_resource() {
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {}) return Object.fromEntries(
(input.data.location.mcp.resource.list({ directory }) ?? []).map((resource) => [
`${resource.server}:${resource.uri}`,
resource,
]),
)
}, },
get lsp_ready() { get lsp_ready() {
return instanceQueriesEnabled() && !lspQuery.isLoading return instanceQueriesEnabled() && !lspQuery.isLoading
@@ -265,16 +253,14 @@ export function createChildStoreManager(input: {
get lsp() { get lsp() {
return lspQuery.isLoading ? [] : (lspQuery.data ?? []) return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
}, },
vcs: vcsStore.value, get vcs() {
limit: 5, const vcs = input.data.location.vcs.info({ directory })
message: {}, if (!vcs) return vcsStore.value
session_message: {}, return { branch: vcs.branch.current, default_branch: vcs.branch.default }
part: {}, },
part_text_accum_delta: {},
}) })
children[key] = child children[key] = child
disposers.set(key, dispose) disposers.set(key, dispose)
mcpToggles.set(key, setMcpEnabled)
activationToggles.set(key, setInstanceQueriesEnabled) activationToggles.set(key, setInstanceQueriesEnabled)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => { const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
@@ -338,14 +324,11 @@ export function createChildStoreManager(input: {
function enableMcp(directory: string, key: DirectoryKey, childStore: [Store<State>, SetStoreFunction<State>]) { function enableMcp(directory: string, key: DirectoryKey, childStore: [Store<State>, SetStoreFunction<State>]) {
if (mcpDirectories.has(key)) return if (mcpDirectories.has(key)) return
mcpDirectories.add(key) mcpDirectories.add(key)
mcpToggles.get(key)?.(true)
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1]) if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
} }
// Passive Home/project metadata reads must not initialize the directory. // Passive project metadata reads must not initialize the directory.
// A real directory access enables these queries once for the store lifetime. // A real directory access enables these queries once for the store lifetime.
// TODO(v2): After Home switches to v2.project.list and root-filtered,
// updated-time v2.session.list, remove any Home-only passive child creation.
function activate(key: DirectoryKey) { function activate(key: DirectoryKey) {
if (activeDirectories.has(key)) return if (activeDirectories.has(key)) return
activeDirectories.add(key) activeDirectories.add(key)
@@ -355,7 +338,6 @@ export function createChildStoreManager(input: {
function disableMcp(directory: string) { function disableMcp(directory: string) {
const key = directoryKey(directory) const key = directoryKey(directory)
if (!mcpDirectories.delete(key)) return if (!mcpDirectories.delete(key)) return
mcpToggles.get(key)?.(false)
} }
function projectMeta(directory: string, patch: ProjectMeta) { function projectMeta(directory: string, patch: ProjectMeta) {
@@ -1,566 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, Project } from "@/types"
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { createStore } from "solid-js/store"
import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
({
id: input.id,
parentID: input.parentID,
time: {
created: 1,
updated: 1,
archived: input.archived,
},
}) as SessionInfo
const userMessage = (id: string, sessionID: string, created = 1) =>
({
id,
sessionID,
role: "user",
time: { created },
agent: "assistant",
model: { providerID: "openai", modelID: "gpt" },
}) as Message
const textPart = (id: string, sessionID: string, messageID: string) =>
({
id,
sessionID,
messageID,
type: "text",
text: id,
}) as Part
const permissionRequest = (id: string, sessionID: string, title = id) =>
({
id,
sessionID,
action: title,
resources: ["*"],
metadata: {},
save: [],
}) as PermissionRequest
const baseState = (input: Partial<State> = {}) =>
({
status: "complete",
agent: [],
command: [],
project: "",
projectMeta: undefined,
icon: undefined,
provider: {} as State["provider"],
config: {} as State["config"],
path: { directory: "/tmp" } as State["path"],
session: [],
sessionTotal: 0,
session_status: {},
session_diff: {},
todo: {},
permission: {},
mcp: {},
lsp: [],
vcs: undefined,
limit: 10,
message: {},
session_message: {},
part: {},
part_text_accum_delta: {},
...input,
}) as State
describe("applyGlobalEvent", () => {
test("upserts project.updated in sorted position", () => {
const project = [{ id: "a" }, { id: "c" }] as Project[]
let refreshCount = 0
applyGlobalEvent({
event: { type: "project.updated", properties: { id: "b" } },
project,
refresh: () => {
refreshCount += 1
},
setGlobalProject(next) {
if (typeof next === "function") next(project)
},
})
expect(project.map((x) => x.id)).toEqual(["a", "b", "c"])
expect(refreshCount).toBe(0)
})
test("handles global.disposed by triggering refresh", () => {
let refreshCount = 0
applyGlobalEvent({
event: { type: "global.disposed" },
project: [],
refresh: () => {
refreshCount += 1
},
setGlobalProject() {},
})
expect(refreshCount).toBe(1)
})
test("leaves server.connected refresh to the connection sync", () => {
let refreshCount = 0
applyGlobalEvent({
event: { type: "server.connected" },
project: [],
refresh: () => {
refreshCount += 1
},
setGlobalProject() {},
})
expect(refreshCount).toBe(0)
})
})
describe("applyDirectoryEvent", () => {
test("initializes text delta accumulation from the current part text", () => {
const part = { ...textPart("part", "session", "message"), text: "existing" }
const [store, setStore] = createStore(baseState({ part: { message: [part] } }))
applyDirectoryEvent({
event: {
type: "message.part.delta",
properties: { messageID: "message", partID: "part", field: "text", delta: " appended" },
},
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.part_text_accum_delta.part).toBe("existing appended")
expect((store.part.message?.[0] as { text: string }).text).toBe("existing appended")
})
test("preserves a Home-specific retained session limit", () => {
const [store, setStore] = createStore(
baseState({
limit: 1,
session: [rootSession({ id: "a" }), rootSession({ id: "b" }), rootSession({ id: "c" })],
}),
)
applyDirectoryEvent({
event: { type: "session.created", properties: { info: rootSession({ id: "d" }) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
retainedLimit: 3,
})
expect(store.session).toHaveLength(3)
})
test("inserts root sessions in sorted order and updates sessionTotal", () => {
const [store, setStore] = createStore(
baseState({
session: [rootSession({ id: "b" })],
sessionTotal: 1,
}),
)
applyDirectoryEvent({
event: { type: "session.created", properties: { info: rootSession({ id: "a" }) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.session.map((x) => x.id)).toEqual(["a", "b"])
expect(store.sessionTotal).toBe(2)
applyDirectoryEvent({
event: { type: "session.created", properties: { info: rootSession({ id: "c", parentID: "a" }) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.sessionTotal).toBe(2)
})
test("cleans session caches when archived", () => {
const message = userMessage("msg_1", "ses_1")
const [store, setStore] = createStore(
baseState({
session: [rootSession({ id: "ses_1" }), rootSession({ id: "ses_2" })],
sessionTotal: 2,
message: { ses_1: [message] },
part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
session_diff: { ses_1: [] },
todo: { ses_1: [] },
permission: { ses_1: [] },
session_status: { ses_1: { type: "busy" } },
}),
)
applyDirectoryEvent({
event: { type: "session.updated", properties: { info: rootSession({ id: "ses_1", archived: 10 }) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.session.map((x) => x.id)).toEqual(["ses_2"])
expect(store.sessionTotal).toBe(1)
expect(store.message.ses_1).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff.ses_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined()
})
test("ignores an archived session absent from a passive directory store", () => {
const [store, setStore] = createStore(baseState({ session: [], sessionTotal: 0 }))
applyDirectoryEvent({
event: { type: "session.updated", properties: { info: rootSession({ id: "missing", archived: 10 }) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.session).toEqual([])
expect(store.sessionTotal).toBe(0)
})
test("cleans session caches when deleted and decrements only root totals", () => {
const cases = [
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1, current: false },
{ info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2, current: true },
]
for (const item of cases) {
const message = userMessage("msg_1", item.info.id)
const [store, setStore] = createStore(
baseState({
session: [
rootSession({ id: "ses_1" }),
rootSession({ id: "ses_2", parentID: "ses_1" }),
rootSession({ id: "ses_3" }),
],
sessionTotal: 2,
message: { [item.info.id]: [message] },
part: { [message.id]: [textPart("prt_1", item.info.id, message.id)] },
session_diff: { [item.info.id]: [] },
todo: { [item.info.id]: [] },
permission: { [item.info.id]: [] },
session_status: { [item.info.id]: { type: "busy" } },
}),
)
applyDirectoryEvent({
event: {
type: "session.deleted",
properties: item.current ? { sessionID: item.info.id } : { info: item.info },
},
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.session.find((x) => x.id === item.info.id)).toBeUndefined()
expect(store.sessionTotal).toBe(item.expectedTotal)
expect(store.message[item.info.id]).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff[item.info.id]).toBeUndefined()
expect(store.todo[item.info.id]).toBeUndefined()
expect(store.permission[item.info.id]).toBeUndefined()
expect(store.session_status[item.info.id]).toBeUndefined()
}
})
test("cleans caches for trimmed sessions on session.created", () => {
const dropped = rootSession({ id: "ses_b" })
const kept = rootSession({ id: "ses_a" })
const message = userMessage("msg_1", dropped.id)
const todos: string[] = []
const [store, setStore] = createStore(
baseState({
limit: 1,
session: [dropped],
message: { [dropped.id]: [message] },
part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
session_diff: { [dropped.id]: [] },
todo: { [dropped.id]: [] },
permission: { [dropped.id]: [] },
session_status: { [dropped.id]: { type: "busy" } },
}),
)
applyDirectoryEvent({
event: { type: "session.created", properties: { info: kept } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
setSessionTodo(sessionID, value) {
if (value !== undefined) return
todos.push(sessionID)
},
})
expect(store.session.map((x) => x.id)).toEqual([kept.id])
expect(store.message[dropped.id]).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff[dropped.id]).toBeUndefined()
expect(store.todo[dropped.id]).toBeUndefined()
expect(store.permission[dropped.id]).toBeUndefined()
expect(store.session_status[dropped.id]).toBeUndefined()
expect(todos).toEqual([dropped.id])
})
test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
const [store, setStore] = createStore(
baseState({
session: [rootSession({ id: "ses_keep" })],
part: { msg_1: [textPart("prt_1", "ses_drop", "msg_1")] },
}),
)
cleanupDroppedSessionCaches(store, setStore, store.session)
expect(store.part.msg_1).toBeUndefined()
})
test("upserts and removes messages while clearing orphaned parts", () => {
const sessionID = "ses_1"
const [store, setStore] = createStore(
baseState({
message: { [sessionID]: [userMessage("msg_z", sessionID, 1), userMessage("msg_b", sessionID, 3)] },
part: { msg_a: [textPart("prt_1", sessionID, "msg_a")] },
}),
)
applyDirectoryEvent({
event: { type: "message.updated", properties: { info: userMessage("msg_a", sessionID, 2) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a", "msg_b"])
applyDirectoryEvent({
event: {
type: "message.updated",
properties: {
info: {
...userMessage("msg_a", sessionID, 2),
role: "assistant",
} as Message,
},
},
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.message[sessionID]?.find((x) => x.id === "msg_a")?.role).toBe("assistant")
applyDirectoryEvent({
event: { type: "message.removed", properties: { sessionID, messageID: "msg_a" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_b"])
expect(store.part.msg_a).toBeUndefined()
})
test("upserts and prunes message parts", () => {
const sessionID = "ses_1"
const messageID = "msg_1"
const [store, setStore] = createStore(
baseState({
part: { [messageID]: [textPart("prt_1", sessionID, messageID), textPart("prt_3", sessionID, messageID)] },
}),
)
applyDirectoryEvent({
event: { type: "message.part.updated", properties: { part: textPart("prt_2", sessionID, messageID) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.part[messageID]?.map((x) => x.id)).toEqual(["prt_1", "prt_2", "prt_3"])
applyDirectoryEvent({
event: {
type: "message.part.updated",
properties: {
part: {
...textPart("prt_2", sessionID, messageID),
text: "changed",
} as Part,
},
},
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
const updated = store.part[messageID]?.find((x) => x.id === "prt_2")
expect(updated?.type).toBe("text")
if (updated?.type === "text") expect(updated.text).toBe("changed")
applyDirectoryEvent({
event: { type: "message.part.removed", properties: { messageID, partID: "prt_1" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
applyDirectoryEvent({
event: { type: "message.part.removed", properties: { messageID, partID: "prt_2" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
applyDirectoryEvent({
event: { type: "message.part.removed", properties: { messageID, partID: "prt_3" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.part[messageID]).toBeUndefined()
})
test("tracks permission request lifecycles", () => {
const sessionID = "ses_1"
const [store, setStore] = createStore(
baseState({
permission: { [sessionID]: [permissionRequest("perm_1", sessionID), permissionRequest("perm_3", sessionID)] },
}),
)
applyDirectoryEvent({
event: { type: "permission.asked", properties: permissionRequest("perm_2", sessionID) },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_2", "perm_3"])
applyDirectoryEvent({
event: { type: "permission.asked", properties: permissionRequest("perm_2", sessionID, "updated") },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.action).toBe("updated")
applyDirectoryEvent({
event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"])
})
test("updates vcs branch in store and cache", () => {
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
const [cacheStore, setCacheStore] = createStore({
value: { branch: "main", default_branch: "main" } as State["vcs"],
})
applyDirectoryEvent({
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
vcsCache: {
store: cacheStore,
setStore: setCacheStore,
ready: () => true,
},
})
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
})
test("routes disposal and lsp events to side-effect handlers", () => {
const [store, setStore] = createStore(baseState())
const pushes: string[] = []
let lspLoads = 0
applyDirectoryEvent({
event: { type: "server.instance.disposed" },
store,
setStore,
push(directory) {
pushes.push(directory)
},
directory: "/tmp",
loadLsp() {
lspLoads += 1
},
})
applyDirectoryEvent({
event: { type: "lsp.updated" },
store,
setStore,
push(directory) {
pushes.push(directory)
},
directory: "/tmp",
loadLsp() {
lspLoads += 1
},
})
expect(pushes).toEqual(["/tmp"])
expect(lspLoads).toBe(1)
})
})
@@ -1,440 +0,0 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { Worktree } from "@opencode-ai/schema/worktree"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { Message, Part, Project, Todo } from "@/types"
import type { FileDiffInfo, PermissionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
import { messageKey } from "@/utils/session-message"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const SESSION_CONTENT_EVENTS = new Set([
"session.diff",
"todo.updated",
"session.status",
"message.updated",
"message.removed",
"message.part.updated",
"message.part.removed",
"message.part.delta",
"permission.asked",
"permission.replied",
])
export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
project: Project[]
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
refresh: () => void
}) {
if (input.event.type === "global.disposed") {
input.refresh()
return
}
if (input.event.type !== "project.updated") return
const properties = input.event.properties as Project
const result = Binary.search(input.project, properties.id, (s) => s.id)
if (result.found) {
input.setGlobalProject(
produce((draft) => {
draft[result.index] = { ...draft[result.index], ...properties }
}),
)
return
}
input.setGlobalProject(
produce((draft) => {
draft.splice(result.index, 0, properties)
}),
)
}
function cleanupSessionCaches(
setStore: SetStoreFunction<State>,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
if (!sessionID) return
setSessionTodo?.(sessionID, undefined)
setStore(
produce((draft) => {
dropSessionCaches(draft, [sessionID])
}),
)
}
export function cleanupDroppedSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
next: SessionInfo[],
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
const keep = new Set(next.map((item) => item.id))
const stale = [
...Object.keys(store.message),
...Object.keys(store.session_diff),
...Object.keys(store.todo),
...Object.keys(store.permission),
...Object.keys(store.session_status),
...Object.values(store.part)
.map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID)
.filter((sessionID): sessionID is string => !!sessionID),
].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
if (stale.length === 0) return
for (const sessionID of stale) {
setSessionTodo?.(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, stale)
}),
)
}
export function applyDirectoryEvent(input: {
event: { type: string; properties?: unknown }
store: Store<State>
setStore: SetStoreFunction<State>
push: (directory: string) => void
directory: string
loadLsp: () => void
loadReferences?: () => void
vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
retainedLimit?: number
sessionContent?: boolean
permission?: State["permission"]
}) {
const event = input.event
if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
switch (event.type) {
case "server.instance.disposed": {
input.push(input.directory)
return
}
case "session.created": {
const info = (event.properties as { info: SessionInfo }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (result.found) {
input.setStore("session", result.index, reconcile(info))
break
}
const next = input.store.session.slice()
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
break
}
case "session.updated": {
const info = (event.properties as { info: SessionInfo }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (info.time.archived) {
if (!result.found) break
if (input.store.session[result.index]!.time.archived === info.time.archived) break
input.setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
if (result.found) {
input.setStore("session", result.index, reconcile(info))
break
}
const next = input.store.session.slice()
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
break
}
case "session.deleted": {
const properties = event.properties as { sessionID?: string; info?: SessionInfo }
const sessionID = properties.info?.id ?? properties.sessionID
if (!sessionID) break
const result = Binary.search(input.store.session, sessionID, (s) => s.id)
const info = properties.info ?? (result.found ? input.store.session[result.index] : undefined)
if (result.found) {
input.setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo)
if (info?.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
case "worktree.resolved": {
const properties = event.properties as { projectID: string; directory: string; previous: string }
input.store.session.forEach((session, index) => {
const adopted = Worktree.adopt(
{ projectID: session.projectID, directory: session.location.directory },
properties,
)
if (!adopted) return
input.setStore("session", index, (current) => ({ ...current, ...adopted }))
})
break
}
case "session.renamed": {
const properties = event.properties as { sessionID: string; title: string }
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
if (!result.found) break
input.setStore("session", result.index, (session) => ({
...session,
title: properties.title,
time: { ...session.time, updated: Date.now() },
}))
break
}
case "session.usage.updated": {
const properties = event.properties as Pick<SessionInfo, "cost" | "tokens"> & { sessionID: string }
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
if (!result.found) break
input.setStore("session", result.index, (session) => ({
...session,
cost: properties.cost,
tokens: properties.tokens,
}))
break
}
// case "session.archived": {
// const properties = event.properties as { sessionID: string }
// const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
// if (!result.found) break
// const info = input.store.session[result.index]
// input.setStore(
// "session",
// produce((draft) => void draft.splice(result.index, 1)),
// )
// cleanupSessionCaches(input.setStore, properties.sessionID)
// if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
// break
// }
case "session.moved": {
const properties = event.properties as {
sessionID: string
location: { directory: string; workspaceID?: string }
projectID?: string
subpath?: string
}
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
if (!result.found) break
if (properties.location.directory === input.directory) {
input.setStore("session", result.index, (session) => ({
...session,
projectID: properties.projectID ?? session.projectID,
location: properties.location,
subpath: properties.subpath,
time: { ...session.time, updated: Date.now() },
}))
break
}
const info = input.store.session[result.index]
input.setStore(
"session",
produce((draft) => void draft.splice(result.index, 1)),
)
if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff) as FileDiffInfo[], { key: "file" }))
break
}
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
input.setSessionTodo?.(props.sessionID, props.todos)
break
}
case "session.status": {
const props = event.properties as { sessionID: string; status: SessionStatus }
input.setStore("session_status", props.sessionID, reconcile(props.status))
break
}
case "message.updated": {
const info = clean((event.properties as { info: Message }).info)
const messages = input.store.message[info.sessionID]
if (!messages) {
input.setStore("message", info.sessionID, [info])
break
}
const result = Binary.search(messages, messageKey(info), messageKey)
if (result.found) {
input.setStore("message", info.sessionID, result.index, reconcile(info))
break
}
input.setStore(
"message",
info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, info)
}),
)
break
}
case "message.removed": {
const props = event.properties as { sessionID: string; messageID: string }
input.setStore(
produce((draft) => {
const messages = draft.message[props.sessionID]
if (messages) {
const index = messages.findIndex((message) => message.id === props.messageID)
if (index >= 0) messages.splice(index, 1)
}
const parts = draft.part[props.messageID]
if (parts) {
for (const part of parts) {
delete draft.part_text_accum_delta[part.id]
}
}
delete draft.part[props.messageID]
}),
)
break
}
case "message.part.updated": {
const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) break
input.setStore(
produce((draft) => {
delete draft.part_text_accum_delta[part.id]
}),
)
const parts = input.store.part[part.messageID]
if (!parts) {
input.setStore("part", part.messageID, [part])
break
}
const result = Binary.search(parts, part.id, (item) => item.id)
if (result.found) {
input.setStore("part", part.messageID, result.index, reconcile(part))
break
}
input.setStore(
"part",
part.messageID,
produce((draft) => {
draft.splice(result.index, 0, part)
}),
)
break
}
case "message.part.removed": {
const props = event.properties as { messageID: string; partID: string }
input.setStore(
produce((draft) => {
delete draft.part_text_accum_delta[props.partID]
}),
)
const parts = input.store.part[props.messageID]
if (!parts) break
const result = Binary.search(parts, props.partID, (part) => part.id)
if (result.found) {
input.setStore(
produce((draft) => {
const list = draft.part[props.messageID]
if (!list) return
const next = Binary.search(list, props.partID, (part) => part.id)
if (!next.found) return
list.splice(next.index, 1)
if (list.length === 0) delete draft.part[props.messageID]
}),
)
}
break
}
case "message.part.delta": {
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
const parts = input.store.part[props.messageID]
if (!parts) break
const result = Binary.search(parts, props.partID, (part) => part.id)
if (!result.found) break
const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field]
input.setStore(
"part_text_accum_delta",
props.partID,
(existing) => (existing ?? (typeof current === "string" ? current : "")) + props.delta,
)
input.setStore(
"part",
props.messageID,
produce((draft) => {
const part = draft[result.index]
const field = props.field as keyof typeof part
const existing = part[field] as string | undefined
;(part[field] as string) = (existing ?? "") + props.delta
}),
)
break
}
case "vcs.branch.updated": {
const props = event.properties as { branch?: string }
if (input.store.vcs?.branch === props.branch) break
const next = { ...input.store.vcs, branch: props.branch }
input.setStore("vcs", next)
if (input.vcsCache) input.vcsCache.setStore("value", next)
break
}
case "permission.asked": {
const permission = event.properties as PermissionRequest
const permissions = input.store.permission[permission.sessionID]
if (!permissions) {
input.setStore("permission", permission.sessionID, [permission])
break
}
const result = Binary.search(permissions, permission.id, (p) => p.id)
if (result.found) {
input.setStore("permission", permission.sessionID, result.index, reconcile(permission))
break
}
input.setStore(
"permission",
permission.sessionID,
produce((draft) => {
draft.splice(result.index, 0, permission)
}),
)
break
}
case "permission.replied": {
const props = event.properties as { sessionID: string; requestID: string }
const permissions = input.store.permission[props.sessionID]
if (!permissions) break
const result = Binary.search(permissions, props.requestID, (p) => p.id)
if (!result.found) break
input.setStore(
"permission",
props.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
break
}
case "lsp.updated": {
input.loadLsp()
break
}
case "reference.updated": {
input.loadReferences?.()
break
}
}
}
@@ -1,146 +1,53 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise" import type { SessionInfo } from "@opencode-ai/client/promise"
import { import {
applyHomeSessionEvent,
appendHomeSessionEvent,
HOME_V2_SESSION_PAGE_LIMIT, HOME_V2_SESSION_PAGE_LIMIT,
loadHomeSessionIndex, loadHomeSessionIndex,
homeSessionIndexSessions,
homeSessionIndexRefresh,
parseHomeSessionIndex, parseHomeSessionIndex,
retainHomeSessions, retainHomeSessions,
} from "./home-session-index" } from "./home-session-index"
const session = (input: { const session = (id: string, input: Partial<SessionInfo> = {}) =>
id: string ({
directory?: string id,
parentID?: string projectID: "project",
archived?: number title: id,
updated?: number time: { created: 1, updated: 1 },
}) => ({ location: { directory: "/repo" },
id: input.id, ...input,
parentID: input.parentID, }) as SessionInfo
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: input.updated ?? 1, archived: input.archived },
title: input.id,
location: { directory: input.directory ?? "/project" },
})
describe("Home V2 session index", () => { describe("Home V2 session index", () => {
test("loads the Home index with one global V2 request", async () => { test("loads all pages", async () => {
const calls: unknown[] = [] const first = Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session(`session-${index}`))
const calls: Array<{ cursor?: string }> = []
const result = await loadHomeSessionIndex(async (input) => { const result = await loadHomeSessionIndex(async (input) => {
calls.push(input) calls.push(input)
return { data: [session({ id: "root" })], cursor: {} } if (!input.cursor) return { data: first, cursor: { next: "next" } }
return { data: [session("last")], cursor: {} }
}) })
expect(result.sessions).toHaveLength(1) expect(result).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
expect(calls).toEqual([{ limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }]) expect(calls.map((call) => call.cursor)).toEqual([undefined, "next"])
}) })
test("loads subsequent pages until the session index is complete", async () => { test("keeps only visible roots", () => {
const calls: unknown[] = []
const controller = new AbortController()
const result = await loadHomeSessionIndex(
async (input, options) => {
calls.push({ input, signal: options.signal })
if (!("cursor" in input)) {
return {
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session({ id: `page-1-${index}` })),
cursor: { next: "next-page" },
}
}
return { data: [session({ id: "page-2" })], cursor: {} }
},
0,
controller.signal,
)
expect(result.sessions).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
expect(calls).toEqual([
{ input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, signal: controller.signal },
{
input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" },
signal: controller.signal,
},
])
})
test("maps visible roots to Home session summaries", () => {
const activeNull = {
...session({ id: "active-null", updated: 20 }),
time: { created: 1, updated: 20, archived: null },
} as unknown as SessionInfo
const result = parseHomeSessionIndex([
session({ id: "root", updated: 30 }),
activeNull,
session({ id: "child", parentID: "root", updated: 40 }),
session({ id: "archived", archived: 50, updated: 50 }),
])
expect(result).toEqual([
expect.objectContaining({
id: "root",
location: { directory: "/project" },
projectID: "project",
title: "root",
time: { created: 1, updated: 30 },
}),
expect.objectContaining({
id: "active-null",
time: { created: 1, updated: 20, archived: null },
}),
])
})
test("preserves the per-directory Home retention limit", () => {
const now = 10 * 60 * 60 * 1000
const sessions = Array.from({ length: 80 }, (_, index) => ({
...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
location: { directory: index % 2 === 0 ? "/one" : "/two" },
}))
const retained = retainHomeSessions(sessions, 10, now)
expect(retained.filter((item) => item.location.directory === "/one")).toHaveLength(10)
expect(retained.filter((item) => item.location.directory === "/two")).toHaveLength(10)
})
test("replays session events over the loaded index", () => {
const initial = parseHomeSessionIndex([session({ id: "old" })])
const created = { ...initial[0], id: "new", title: "new", time: { created: 2, updated: 2 } }
const afterCreate = applyHomeSessionEvent(initial, {
type: "session.created",
properties: { sessionID: created.id, info: created },
})
expect( expect(
applyHomeSessionEvent(afterCreate, { parseHomeSessionIndex([
type: "session.deleted", session("root"),
properties: { sessionID: initial[0]!.id, info: initial[0]! }, session("child", { parentID: "root" }),
}), session("archived", { time: { created: 1, updated: 1, archived: 2 } }),
).toEqual([created]) ]).map((item) => item.id),
).toEqual(["root"])
}) })
test("applies only events newer than the index baseline", () => { test("preserves the per-directory retention limit", () => {
const initial = parseHomeSessionIndex([session({ id: "old" })]) const now = Date.now()
const stale = { ...initial[0], title: "stale" } const result = retainHomeSessions(
const current = { ...initial[0], title: "current" } [session("a", { time: { created: 1, updated: 1 } }), session("b", { time: { created: 2, updated: 2 } })],
const first = appendHomeSessionEvent(undefined, { 1,
type: "session.updated", now,
properties: { sessionID: stale.id, info: stale }, )
}) expect(result.map((item) => item.id)).toEqual(["b"])
const events = appendHomeSessionEvent(first, {
type: "session.updated",
properties: { sessionID: current.id, info: current },
})
expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
})
test("refetches after reconnect", () => {
expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
}) })
}) })
@@ -1,33 +1,14 @@
import type { Event } from "@/types"
import type { SessionInfo, SessionsResponse } from "@opencode-ai/client/promise" import type { SessionInfo, SessionsResponse } from "@opencode-ai/client/promise"
import type { QueryClient } from "@tanstack/solid-query"
import { trimSessions } from "./session-trim"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000 export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
export type HomeSessionEvent = {
type: "session.created" | "session.updated" | "session.deleted"
properties: { sessionID: string; info?: SessionInfo }
}
export type HomeSessionEvents = {
sequence: number
entries: Array<{ sequence: number; event: HomeSessionEvent }>
}
export type HomeSessionIndex = {
sessions: SessionInfo[]
eventSequence: number
}
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
export async function loadHomeSessionIndex( export async function loadHomeSessionIndex(
list: ( list: (
input: { limit: number; order: "desc"; cursor?: string }, input: { limit: number; order: "desc"; cursor?: string },
options: { signal?: AbortSignal }, options: { signal?: AbortSignal },
) => Promise<SessionsResponse>, ) => Promise<SessionsResponse>,
eventSequence = 0,
signal?: AbortSignal, signal?: AbortSignal,
) { ) {
const data: SessionInfo[] = [] const data: SessionInfo[] = []
@@ -42,115 +23,34 @@ export async function loadHomeSessionIndex(
}, },
{ signal }, { signal },
) )
const page = response data.push(...response.data)
data.push(...page.data) if (response.data.length < HOME_V2_SESSION_PAGE_LIMIT || !response.cursor.next) return parseHomeSessionIndex(data)
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next) cursor = response.cursor.next
return { sessions: parseHomeSessionIndex(data), eventSequence }
cursor = page.cursor.next
} }
} }
export function appendHomeSessionEvent(current: HomeSessionEvents | undefined, event: HomeSessionEvent) { // The V2 API cannot yet filter roots, archives, or several directories, so Home
const sequence = (current?.sequence ?? 0) + 1 // seeds createData from a full scan and derives its visible index there.
return { export function parseHomeSessionIndex(sessions: SessionInfo[]) {
sequence, return sessions.filter((session) => !session.parentID && typeof session.time.archived !== "number")
entries: [...(current?.entries ?? []), { sequence, event }],
}
} }
export function trimHomeSessionEvents(current: HomeSessionEvents | undefined, sequence: number): HomeSessionEvents { export function mergeHomeSessionIndex(fetched: SessionInfo[], known: SessionInfo[]) {
return { return parseHomeSessionIndex([
sequence: current?.sequence ?? sequence, ...new Map([...fetched, ...known].map((session) => [session.id, session] as const)).values(),
entries: (current?.entries ?? []).filter((entry) => entry.sequence > sequence), ])
}
}
export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
if (!index) return []
return (events?.entries ?? [])
.filter((entry) => entry.sequence > index.eventSequence)
.reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions)
}
export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
if (event === "server.connected") return { connected: true, refetch: connected }
return { connected, refetch: false }
}
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
const indexKey = homeSessionIndexKey(server)
const eventsKey = homeSessionEventsKey(server)
let connected = false
return {
indexKey,
eventsKey,
eventSequence() {
return queryClient.getQueryData<HomeSessionEvents>(eventsKey)?.sequence ?? 0
},
complete(sequence: number) {
// Keep events received after the fetch began so its response cannot overwrite them.
queryClient.setQueryData<HomeSessionEvents>(eventsKey, (current) => trimHomeSessionEvents(current, sequence))
},
sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
return homeSessionIndexSessions(index, events)
},
apply(event: HomeSessionEvent) {
if (!queryClient.getQueryState(indexKey)) return
const next = appendHomeSessionEvent(queryClient.getQueryData<HomeSessionEvents>(eventsKey), event)
if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) {
queryClient.setQueryData(eventsKey, next)
return
}
const index = queryClient.getQueryData<HomeSessionIndex>(indexKey)
if (index) {
queryClient.setQueryData<HomeSessionIndex>(indexKey, {
sessions: homeSessionIndexSessions(index, next),
eventSequence: next.sequence,
})
}
queryClient.setQueryData<HomeSessionEvents>(eventsKey, { sequence: next.sequence, entries: [] })
},
refresh(event: Event["type"]) {
const result = homeSessionIndexRefresh(event, connected)
connected = result.connected
if (!result.refetch) return
void queryClient.refetchQueries({ queryKey: indexKey, exact: true, type: "active" })
},
}
}
// TODO(v2): This deliberately dumb full-table scan is necessary because the
// current V2 API orders by creation time and cannot filter roots, archives, or
// multiple directories. A bounded page could omit an old session updated today.
// Once released, use client.v2.project.list() and client.v2.session.list({
// parentID: null, order: "desc" }) and replace this full-table scan.
export function parseHomeSessionIndex(sessions: SessionInfo[]): SessionInfo[] {
return sessions.flatMap((item) => {
if (item.parentID || typeof item.time.archived === "number") return []
return [item]
})
} }
export function retainHomeSessions(sessions: SessionInfo[], limit: number, now: number) { export function retainHomeSessions(sessions: SessionInfo[], limit: number, now: number) {
const grouped = Map.groupBy(sessions, (session) => pathKey(session.location.directory)) return [...Map.groupBy(sessions, (session) => pathKey(session.location.directory)).values()].flatMap((items) => {
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now })) const sorted = items.toSorted((a, b) => {
} const updated = (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)
return updated || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
export function applyHomeSessionEvent(sessions: SessionInfo[], event: HomeSessionEvent) { })
const info = event.properties.info const recent = sorted
const index = sessions.findIndex((session) => session.id === (info?.id ?? event.properties.sessionID)) .slice(limit)
if (event.type === "session.deleted") { .filter((session) => (session.time.updated ?? session.time.created) > now - SESSION_RECENT_WINDOW)
if (index === -1) return sessions .slice(0, SESSION_RECENT_LIMIT)
return sessions.toSpliced(index, 1) return [...sorted.slice(0, limit), ...recent]
} })
if (!info) return sessions
if (info.parentID || typeof info.time.archived === "number") {
if (index === -1) return sessions
return sessions.toSpliced(index, 1)
}
if (event.type !== "session.created" && event.type !== "session.updated") return sessions
if (index === -1) return [...sessions, info]
return sessions.with(index, info)
} }
@@ -1,105 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, Todo } from "@/types"
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
const msg = (id: string, sessionID: string) =>
({
id,
sessionID,
role: "user",
time: { created: 1 },
agent: "assistant",
model: { providerID: "openai", modelID: "gpt" },
}) as Message
const part = (id: string, sessionID: string, messageID: string) =>
({
id,
sessionID,
messageID,
type: "text",
text: id,
}) as Part
describe("app session cache", () => {
test("dropSessionCaches clears orphaned parts without message rows", () => {
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
session_message: Record<string, never[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
form: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
} = {
session_status: { ses_1: { type: "busy" } as SessionStatus },
session_diff: { ses_1: [] },
todo: { ses_1: [] as Todo[] },
message: {},
session_message: {},
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] },
form: { ses_1: [] as FormInfo[] },
part_text_accum_delta: { prt_1: "streamed text" },
}
dropSessionCaches(store, ["ses_1"])
expect(store.message.ses_1).toBeUndefined()
expect(store.part.msg_1).toBeUndefined()
expect(store.part_text_accum_delta.prt_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined()
expect(store.session_diff.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
expect(store.form.ses_1).toBeUndefined()
})
test("dropSessionCaches clears message-backed parts", () => {
const m = msg("msg_1", "ses_1")
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
session_message: Record<string, never[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
form: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
} = {
session_status: {},
session_diff: {},
todo: {},
message: { ses_1: [m] },
session_message: {},
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {},
form: {},
part_text_accum_delta: {},
}
dropSessionCaches(store, ["ses_1"])
expect(store.message.ses_1).toBeUndefined()
expect(store.part[m.id]).toBeUndefined()
})
test("pickSessionCacheEvictions preserves requested sessions", () => {
const seen = new Set(["ses_1", "ses_2", "ses_3"])
const stale = pickSessionCacheEvictions({
seen,
keep: "ses_4",
limit: 2,
preserve: ["ses_1"],
})
expect(stale).toEqual(["ses_2", "ses_3"])
expect([...seen]).toEqual(["ses_1", "ses_4"])
})
})
@@ -1,63 +0,0 @@
import type { Message, Part, Todo } from "@/types"
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
export const SESSION_CACHE_LIMIT = 40
type SessionCache = {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
session_message: Record<string, SessionMessageInfo[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
form?: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
}
export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
const stale = new Set(Array.from(sessionIDs).filter(Boolean))
if (stale.size === 0) return
for (const key of Object.keys(store.part)) {
const parts = store.part[key]
if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
for (const part of parts) {
delete store.part_text_accum_delta[part.id]
}
delete store.part[key]
}
for (const sessionID of stale) {
delete store.message[sessionID]
delete store.todo[sessionID]
delete store.session_message[sessionID]
delete store.session_diff[sessionID]
delete store.session_status[sessionID]
delete store.permission[sessionID]
if (store.form) delete store.form[sessionID]
}
}
export function pickSessionCacheEvictions(input: {
seen: Set<string>
keep: string
limit: number
preserve?: Iterable<string>
}) {
const stale: string[] = []
const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])])
if (input.seen.has(input.keep)) input.seen.delete(input.keep)
input.seen.add(input.keep)
for (const id of input.seen) {
if (input.seen.size - stale.length <= input.limit) break
if (keep.has(id)) continue
stale.push(id)
}
for (const id of stale) {
input.seen.delete(id)
}
return stale
}
@@ -1,21 +0,0 @@
import type { SessionApi } from "@opencode-ai/client/promise"
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
const result = await input.api.list({
directory: input.directory,
parentID: null,
limit: input.limit,
order: "desc",
})
return {
data: result.data,
limit: input.limit,
limited: true,
} as const
}
export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
if (!input.limited) return input.count
if (input.count < input.limit) return input.count
return input.count + 1
}
@@ -1,59 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { trimSessions } from "./session-trim"
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
({
id: input.id,
parentID: input.parentID,
time: {
created: input.created,
updated: input.updated,
archived: input.archived,
},
}) as SessionInfo
describe("trimSessions", () => {
test("keeps base roots and recent roots beyond the limit", () => {
const now = 1_000_000
const list = [
session({ id: "a", created: now - 100_000 }),
session({ id: "b", created: now - 90_000 }),
session({ id: "c", created: now - 80_000 }),
session({ id: "d", created: now - 70_000, updated: now - 1_000 }),
session({ id: "e", created: now - 60_000, archived: now - 10 }),
]
const result = trimSessions(list, { limit: 2, permission: {}, now })
expect(result.map((x) => x.id)).toEqual(["a", "b", "c", "d"])
})
test("keeps children when root is kept, permission exists, or child is recent", () => {
const now = 1_000_000
const list = [
session({ id: "root-1", created: now - 1000 }),
session({ id: "root-2", created: now - 2000 }),
session({ id: "z-root", created: now - 30_000_000 }),
session({ id: "child-kept-by-root", parentID: "root-1", created: now - 20_000_000 }),
session({ id: "child-kept-by-permission", parentID: "z-root", created: now - 20_000_000 }),
session({ id: "child-kept-by-recency", parentID: "z-root", created: now - 500 }),
session({ id: "child-trimmed", parentID: "z-root", created: now - 20_000_000 }),
]
const result = trimSessions(list, {
limit: 2,
permission: {
"child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest],
},
now,
})
expect(result.map((x) => x.id)).toEqual([
"child-kept-by-permission",
"child-kept-by-recency",
"child-kept-by-root",
"root-1",
"root-2",
])
})
})
@@ -1,58 +0,0 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import { cmp } from "./utils"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
export function sessionUpdatedAt(session: SessionInfo) {
return session.time.updated ?? session.time.created
}
export function compareSessionRecent(a: SessionInfo, b: SessionInfo) {
const aUpdated = sessionUpdatedAt(a)
const bUpdated = sessionUpdatedAt(b)
if (aUpdated !== bUpdated) return bUpdated - aUpdated
return cmp(a.id, b.id)
}
export function takeRecentSessions(sessions: SessionInfo[], limit: number, cutoff: number) {
if (limit <= 0) return [] as SessionInfo[]
const selected: SessionInfo[] = []
const seen = new Set<string>()
for (const session of sessions) {
if (!session?.id) continue
if (seen.has(session.id)) continue
seen.add(session.id)
if (sessionUpdatedAt(session) <= cutoff) continue
const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0)
if (index === -1) selected.push(session)
if (index !== -1) selected.splice(index, 0, session)
if (selected.length > limit) selected.pop()
}
return selected
}
export function trimSessions(
input: SessionInfo[],
options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
) {
const limit = Math.max(0, options.limit)
const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW
const all = input
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.sort((a, b) => cmp(a.id, b.id))
const roots = all.filter((s) => !s.parentID)
roots.sort(compareSessionRecent)
const children = all.filter((s) => !!s.parentID)
const base = roots.slice(0, limit)
const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff)
const keepRoots = [...base, ...recent]
const keepRootIds = new Set(keepRoots.map((s) => s.id))
const keepChildren = children.filter((s) => {
if (s.parentID && keepRootIds.has(s.parentID)) return true
const perms = options.permission[s.id] ?? []
if (perms.length > 0) return true
return sessionUpdatedAt(s) > cutoff
})
return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id))
}
+4 -39
View File
@@ -1,13 +1,6 @@
import type { Agent, Config, LspStatus, Message, Part, Path, Todo, VcsInfo } from "@/types" import type { Agent, Config, LspStatus, Path, ProviderListResponse, VcsInfo } from "@/types"
import type { import type { ReferenceInfo } from "@opencode-ai/client/promise"
FileDiffInfo, import type { CommandInfo, McpResource, McpServer } from "@opencode-ai/client/promise"
PermissionRequest,
ReferenceInfo,
SessionInfo,
SessionStatus,
} from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store" import type { SetStoreFunction, Store } from "solid-js/store"
@@ -31,24 +24,9 @@ export type State = {
projectMeta: ProjectMeta | undefined projectMeta: ProjectMeta | undefined
icon: string | undefined icon: string | undefined
provider_ready: boolean provider_ready: boolean
provider: NormalizedProviderListResponse provider: ProviderListResponse
config: Config config: Config
path: Path path: Path
session: SessionInfo[]
sessionTotal: number
session_status: {
[sessionID: string]: SessionStatus
}
session_working(id: string): boolean
session_diff: {
[sessionID: string]: FileDiffInfo[]
}
todo: {
[sessionID: string]: Todo[]
}
permission: {
[sessionID: string]: PermissionRequest[]
}
mcp_ready: boolean mcp_ready: boolean
mcp: { mcp: {
[name: string]: McpServer["status"] [name: string]: McpServer["status"]
@@ -59,19 +37,6 @@ export type State = {
lsp_ready: boolean lsp_ready: boolean
lsp: LspStatus[] lsp: LspStatus[]
vcs: VcsInfo | undefined vcs: VcsInfo | undefined
limit: number
message: {
[sessionID: string]: Message[]
}
session_message: {
[sessionID: string]: SessionMessageInfo[]
}
part: {
[messageID: string]: Part[]
}
part_text_accum_delta: {
[partID: string]: string
}
} }
export type VcsCache = { export type VcsCache = {

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