Compare commits
224 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c590e27639 | |||
| 8f4b62eb49 | |||
| 945d1c8cb2 | |||
| 610e618bc5 | |||
| 9daa4d85a4 | |||
| 62af66a74f | |||
| 5b44e5bf41 | |||
| a15afbe8f2 | |||
| 8e0856c43b | |||
| f66c829231 | |||
| 3baaabede8 | |||
| afe3ebbc35 | |||
| e2faeb84e5 | |||
| 35ed09ff37 | |||
| 9751615651 | |||
| c9b24ef027 | |||
| b04d8d53e6 | |||
| 7bd3f8ac83 | |||
| 6ae2fa5196 | |||
| 650d774372 | |||
| 64e4f6f91b | |||
| d097cc8065 | |||
| 438654768c | |||
| 37b26e495b | |||
| 5d14c7a185 | |||
| e66cbf36e9 | |||
| 50977dd4fe | |||
| a644e0e7a0 | |||
| ca861fdf43 | |||
| 34a08cbdb8 | |||
| bf3ae45439 | |||
| 394e0b9045 | |||
| 4790a2772c | |||
| 2ff19171dc | |||
| 1401901529 | |||
| 1b88ff8d53 | |||
| 08293e74a0 | |||
| 24d26365e6 | |||
| c22793d5f6 | |||
| c6a52a39b5 | |||
| aad8d90dd1 | |||
| 6f47459c68 | |||
| dd768e30e2 | |||
| de476aa51b | |||
| bd8d858bf7 | |||
| 5fc4e18b82 | |||
| 12d9f4b29b | |||
| a2769b5ade | |||
| 1fa605ad5e | |||
| 698ef25f33 | |||
| 88dc960af8 | |||
| 5657cec4f2 | |||
| 8d790a9ed5 | |||
| b62d24e40b | |||
| a1b6c84135 | |||
| 85fde8c11d | |||
| c167bde6a0 | |||
| d9bf30fc22 | |||
| 3ce5e9800d | |||
| af5eabcb26 | |||
| 0087dd56d9 | |||
| ef2140d121 | |||
| 1de3c6e4a6 | |||
| b6c3a8ea7b | |||
| efcf2c3f5d | |||
| e65477ab1d | |||
| cd0b274856 | |||
| 57e9e9771d | |||
| 1aae92c42a | |||
| 6555df912a | |||
| 7ebd344fa2 | |||
| 460cdc5aec | |||
| b0ca5520a1 | |||
| bc2e270f82 | |||
| 33705e632a | |||
| cff2345c12 | |||
| 0405518180 | |||
| c9604c86ec | |||
| 967c44552e | |||
| ec95694e2f | |||
| c176baee82 | |||
| 016d296f7c | |||
| dbaa53329c | |||
| 7843f8fb38 | |||
| 4045041554 | |||
| 7ec7413fdb | |||
| f016392368 | |||
| 140224b0fc | |||
| cfd35c9354 | |||
| 674d08f9be | |||
| 02f012f5d1 | |||
| bea6e1499d | |||
| 50a1fe49bc | |||
| 6380919fda | |||
| d972aa9d83 | |||
| d544ab4d91 | |||
| ce228bfd7c | |||
| eabf85aea2 | |||
| 2f4a688790 | |||
| 4a90ffedfb | |||
| 95cf5039be | |||
| 041215996c | |||
| d0b920e187 | |||
| 8c94e9005f | |||
| 932a40cfd9 | |||
| 716f6658db | |||
| 9f1a8f2149 | |||
| b3650e2316 | |||
| fb884bb91e | |||
| 98c09884bf | |||
| 5e47501b8b | |||
| d51ba6ed94 | |||
| a6983b65fc | |||
| 1a06198954 | |||
| 72ec09cf74 | |||
| e2bca216a2 | |||
| a10733dbf4 | |||
| b5823d1077 | |||
| e1fafa2e54 | |||
| ae49b21376 | |||
| 5710064ae1 | |||
| 4617b03ca9 | |||
| 6a91a682e4 | |||
| f8626865b9 | |||
| 2324a63fc6 | |||
| 1ce607c230 | |||
| c26f6f95f7 | |||
| d77b87ea65 | |||
| 6ca6566bd3 | |||
| 8dd993d25a | |||
| 24ab17e718 | |||
| 0a60662d71 | |||
| 194b0615e0 | |||
| a5c51e11d0 | |||
| 8f1db7d06d | |||
| b1ca070b3b | |||
| 12887e572e | |||
| 75715e2115 | |||
| a1250cd690 | |||
| 23adaaaeab | |||
| 524ee8fc03 | |||
| 0f9719a7b5 | |||
| 1b83c08b8a | |||
| 02cb350880 | |||
| 4ce830a919 | |||
| 461a1c3ab4 | |||
| 6846542115 | |||
| f928b5be07 | |||
| ecfa918760 | |||
| f80624cf17 | |||
| fe59174c23 | |||
| 2fe057324f | |||
| 19a5b5a05d | |||
| ff4cab03c1 | |||
| b2d46ecd7e | |||
| 360d85a521 | |||
| c65a7d50c1 | |||
| fa73546a86 | |||
| 935ac2db91 | |||
| 01edae4a7f | |||
| 381d67572e | |||
| e8ac44430b | |||
| 5ae93092aa | |||
| 595c6bd4a7 | |||
| 7073e8797f | |||
| f7034a35a8 | |||
| 53b93b6991 | |||
| 6067019434 | |||
| 42a3cf9645 | |||
| 04c6bed240 | |||
| 94e3a29d2f | |||
| 41283933ff | |||
| 11bf8d8a42 | |||
| c22973ab9f | |||
| 612009e0f1 | |||
| a0a6c9545e | |||
| bceb6d0a9e | |||
| 49e7dc191f | |||
| f4720be08e | |||
| d1d7ebc2c6 | |||
| cd942d0669 | |||
| 4741e3ee6b | |||
| 4673bfbaa0 | |||
| b458dd8c63 | |||
| 7edd6c3a1d | |||
| f43f066741 | |||
| b6553d14e1 | |||
| ab0042a666 | |||
| 5df049d081 | |||
| 573ab9c24b | |||
| 6a16c41e8f | |||
| a491cbee64 | |||
| 658cbe9caf | |||
| 62b2bc39df | |||
| beb2c52c3f | |||
| 655adbf46e | |||
| ac2a78391f | |||
| 9a9bdaba95 | |||
| 8b682c42b6 | |||
| ad4f1c1018 | |||
| a0afb63ed0 | |||
| df9ecb8f6a | |||
| e6f660fecf | |||
| 0e2dd4ad15 | |||
| 11d2f3e5f8 | |||
| 1ac6b4bec4 | |||
| 0befd9b049 | |||
| 850a0dfe7c | |||
| 18a419e634 | |||
| 82f47cb312 | |||
| 7b1fe33ed3 | |||
| c72cca8def | |||
| 05ce6bc275 | |||
| 1de7368580 | |||
| 36f901588a | |||
| 92f1a17b67 | |||
| ebf4007efd | |||
| 1aea999d7c | |||
| 4a8fee3b2d | |||
| 0d3e0fc8f4 | |||
| f254476043 | |||
| 639c8e6475 | |||
| e3bfd4cce6 | |||
| c2882268d6 |
@@ -8,6 +8,13 @@ inputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
# node-gyp@latest (invoked via bunx for native install scripts) requires Node >=22;
|
||||
# some runner images ship an older system Node on PATH
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Get baseline download URL
|
||||
id: bun-url
|
||||
shell: bash
|
||||
|
||||
@@ -34,10 +34,48 @@ jobs:
|
||||
|
||||
const now = Date.now();
|
||||
const twoHours = 2 * 60 * 60 * 1000;
|
||||
const orgMemberAssociations = new Set(['OWNER', 'MEMBER']);
|
||||
const agentLogin = 'opencode-agent[bot]';
|
||||
const { data: file } = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: '.github/TEAM_MEMBERS',
|
||||
ref: 'dev',
|
||||
});
|
||||
const teamMembers = new Set(
|
||||
Buffer.from(file.content, 'base64')
|
||||
.toString()
|
||||
.split('\n')
|
||||
.map((line) => line.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
function isExempt(item) {
|
||||
const login = item.user?.login?.toLowerCase();
|
||||
return (
|
||||
login === agentLogin ||
|
||||
orgMemberAssociations.has(item.author_association) ||
|
||||
(login && teamMembers.has(login))
|
||||
);
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const isPR = !!item.pull_request;
|
||||
const kind = isPR ? 'PR' : 'issue';
|
||||
const login = item.user?.login;
|
||||
|
||||
if (isExempt(item)) {
|
||||
core.info(`Skipping ${kind} #${item.number}; author ${login || 'unknown'} is exempt`);
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: item.number,
|
||||
name: 'needs:compliance',
|
||||
});
|
||||
} catch (e) {}
|
||||
continue;
|
||||
}
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
|
||||
@@ -17,12 +17,31 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check exempt issue author
|
||||
id: author
|
||||
run: |
|
||||
LOGIN="${{ github.event.issue.user.login }}"
|
||||
ASSOCIATION="${{ github.event.issue.author_association }}"
|
||||
|
||||
if [ "$LOGIN" = "opencode-agent[bot]" ] ||
|
||||
[ "$ASSOCIATION" = "OWNER" ] ||
|
||||
[ "$ASSOCIATION" = "MEMBER" ] ||
|
||||
grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
if: steps.author.outputs.skip != 'true'
|
||||
|
||||
- name: Install opencode
|
||||
if: steps.author.outputs.skip != 'true'
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Check duplicates and compliance
|
||||
if: steps.author.outputs.skip != 'true'
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -38,6 +57,7 @@ jobs:
|
||||
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
|
||||
|
||||
Issue number: ${{ github.event.issue.number }}
|
||||
Issue author association: ${{ github.event.issue.author_association }}
|
||||
|
||||
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
||||
|
||||
@@ -49,6 +69,8 @@ jobs:
|
||||
|
||||
Check whether the issue follows our contributing guidelines and issue templates.
|
||||
|
||||
If the issue author association is OWNER or MEMBER, skip this compliance check. Do not add the needs:compliance label for organization-owned issues.
|
||||
|
||||
This project has three issue templates that every issue MUST use one of:
|
||||
|
||||
1. Bug Report - requires a Description field with real content
|
||||
@@ -83,7 +105,7 @@ jobs:
|
||||
|
||||
Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
|
||||
|
||||
If the issue is NOT compliant, start the comment with:
|
||||
If the issue is NOT compliant and the author association is not OWNER or MEMBER, start the comment with:
|
||||
<!-- issue-compliance -->
|
||||
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
|
||||
|
||||
@@ -129,12 +151,31 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check exempt issue author
|
||||
id: author
|
||||
run: |
|
||||
LOGIN="${{ github.event.issue.user.login }}"
|
||||
ASSOCIATION="${{ github.event.issue.author_association }}"
|
||||
|
||||
if [ "$LOGIN" = "opencode-agent[bot]" ] ||
|
||||
[ "$ASSOCIATION" = "OWNER" ] ||
|
||||
[ "$ASSOCIATION" = "MEMBER" ] ||
|
||||
grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
if: steps.author.outputs.skip != 'true'
|
||||
|
||||
- name: Install opencode
|
||||
if: steps.author.outputs.skip != 'true'
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Recheck compliance
|
||||
if: steps.author.outputs.skip != 'true'
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -148,9 +189,12 @@ jobs:
|
||||
}
|
||||
run: |
|
||||
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
|
||||
Issue author association: ${{ github.event.issue.author_association }}
|
||||
|
||||
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
||||
|
||||
If the issue author association is OWNER or MEMBER, remove the needs:compliance label if present, delete the previous compliance comment if present, and do not post a new comment.
|
||||
|
||||
Re-check whether the issue now follows our contributing guidelines and issue templates.
|
||||
|
||||
This project has three issue templates that every issue MUST use one of:
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
branches:
|
||||
- ci
|
||||
- dev
|
||||
- v2
|
||||
- beta
|
||||
- fix/npm-native-binary-install
|
||||
- snapshot-*
|
||||
@@ -31,6 +32,9 @@ permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -122,7 +126,7 @@ jobs:
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -221,7 +225,7 @@ jobs:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -447,6 +451,7 @@ jobs:
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- v2
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -74,13 +75,9 @@ jobs:
|
||||
working-directory: packages/client
|
||||
run: bun run check:generated
|
||||
|
||||
- name: Run HttpApi exerciser gates
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/opencode
|
||||
run: bun run test:httpapi
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
if: github.ref_name != 'v2' && github.head_ref != 'v2'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -16,13 +16,32 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check exempt issue author
|
||||
id: author
|
||||
run: |
|
||||
LOGIN="${{ github.event.issue.user.login }}"
|
||||
ASSOCIATION="${{ github.event.issue.author_association }}"
|
||||
|
||||
if [ "$LOGIN" = "opencode-agent[bot]" ] ||
|
||||
[ "$ASSOCIATION" = "OWNER" ] ||
|
||||
[ "$ASSOCIATION" = "MEMBER" ] ||
|
||||
grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Bun
|
||||
if: steps.author.outputs.skip != 'true'
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install opencode
|
||||
if: steps.author.outputs.skip != 'true'
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Triage issue
|
||||
if: steps.author.outputs.skip != 'true'
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -2,9 +2,9 @@ name: typecheck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
branches: [dev, v2]
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
branches: [dev, v2]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: debug-opencode
|
||||
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.
|
||||
|
||||
## Migration context
|
||||
|
||||
- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state.
|
||||
- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI (see "Comparing V2 against the legacy TUI" below) rather than guessing.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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 the 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.
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: sample-skill
|
||||
description: Use when the user says sample skill, skill demo, or asks how an opencode SKILL.md should be structured; demonstrates a tiny project-local skill with practical assistant workflow guidance.
|
||||
---
|
||||
|
||||
# Sample Skill
|
||||
|
||||
This is a minimal project-local opencode skill. It exists as a reference for how a skill is structured and as a tiny reusable workflow the assistant can load when the user asks for a skill example.
|
||||
|
||||
## When To Use
|
||||
|
||||
- Use when the user asks for a sample skill or skill template.
|
||||
- Use when demonstrating the required `SKILL.md` frontmatter and body format.
|
||||
- Do not use for unrelated coding tasks just because a skill exists.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Confirm the specific outcome the user wants if the request is ambiguous.
|
||||
- Inspect the relevant files before changing anything.
|
||||
- Make the smallest correct change.
|
||||
- Verify the result with a focused read, typecheck, test, or other lightweight check when available.
|
||||
- Summarize the changed files and any required restart or reload step.
|
||||
|
||||
## Example Response Style
|
||||
|
||||
When this skill is relevant, keep responses direct and actionable:
|
||||
|
||||
```text
|
||||
I created a project-local skill at .opencode/skills/sample-skill/SKILL.md.
|
||||
Restart opencode for the new skill to be discovered by future sessions.
|
||||
```
|
||||
@@ -1,6 +1,7 @@
|
||||
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- The default branch in this repo is `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
|
||||
@@ -150,12 +151,15 @@ const table = sqliteTable("session", {
|
||||
|
||||
## V2 Session Core
|
||||
|
||||
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
||||
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Preserve one explicit `llm.stream(request)` call per step and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
|
||||
- Keep the System Context algebra and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Checkpoint persistence Session-owned. The runner composes all context producers explicitly in `loadSystemContext`; there is no context registry.
|
||||
- The durable Applied record is what the model was last told, per source. Reconcile narrates drift as chronological System updates and never rewrites the baseline; only completed compaction rebaselines, and move or committed revert resets the checkpoint. Unavailable sources keep the model's prior belief, blocking only a session's first baseline.
|
||||
|
||||
+38
-23
@@ -9,7 +9,7 @@ The structured collection of contextual facts presented to the model as initial
|
||||
_Avoid_: System prompt
|
||||
|
||||
**Session History**:
|
||||
The projected chronological conversation selected for a provider turn after applying the active compaction and **Context Epoch** cutoffs.
|
||||
The projected chronological conversation selected for a **Step** after applying the active compaction and **Context Epoch** cutoffs.
|
||||
_Avoid_: Session Context
|
||||
|
||||
**Context Source**:
|
||||
@@ -31,13 +31,13 @@ The full **System Context** rendered at the start of a **Context Epoch**.
|
||||
_Avoid_: Live system prompt
|
||||
|
||||
**Context Snapshot**:
|
||||
The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a provider turn.
|
||||
The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a **Step**.
|
||||
|
||||
**Unavailable Context**:
|
||||
An expected temporary inability to observe a **Context Source** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded.
|
||||
|
||||
**Safe Provider-Turn Boundary**:
|
||||
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
|
||||
**Safe Step Boundary**:
|
||||
The point immediately before a provider request, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
|
||||
|
||||
**Admitted Prompt**:
|
||||
A durable user input accepted into the Session inbox but not yet included in **Session History**.
|
||||
@@ -45,11 +45,24 @@ A durable user input accepted into the Session inbox but not yet included in **S
|
||||
**Prompt Promotion**:
|
||||
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
|
||||
|
||||
**Provider Turn**:
|
||||
One request to a model provider and the response projected from that request.
|
||||
**Step**:
|
||||
One logical LLM call spanning pre-flight context checkpoint preparation, input promotion, request build, and compaction check; the provider stream; and tool settlement.
|
||||
_Avoid_: provider turn, turn (unqualified)
|
||||
|
||||
**Physical Attempt**:
|
||||
One actual provider request on the wire in service of a **Step**; most Steps have one Physical Attempt, while overflow-triggered compaction recovery may give one Step two.
|
||||
|
||||
**Assistant Turn**:
|
||||
A reserved name for the not-yet-modeled unit containing all **Steps** from prompt promotion until the assistant yields the floor; do not reify it until something durable needs it.
|
||||
|
||||
**Settlement**:
|
||||
The terminal transition for a unit of work: Step and tool settlement are durable, while drain and execution settlement are coordinator-observed.
|
||||
|
||||
**Execution**:
|
||||
One session-scoped coordinator busy period from first wake until idle. An Execution is process-local coordination rather than a durable domain entity.
|
||||
|
||||
**Session Drain**:
|
||||
One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
|
||||
One process-local execution span that promotes eligible input and runs required **Steps** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
|
||||
|
||||
**Model Tool Output**:
|
||||
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
|
||||
@@ -89,23 +102,25 @@ _Avoid_: Response envelope
|
||||
|
||||
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
||||
- **Session History** contains projected conversational messages and admitted **Mid-Conversation System Messages**; the active **Baseline System Context** remains separate provider-request state.
|
||||
- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Provider-Turn Boundary**.
|
||||
- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Step Boundary**.
|
||||
- A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state.
|
||||
- A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model.
|
||||
- The current **Context Snapshot** advances atomically with the corresponding durable **Mid-Conversation System Message**.
|
||||
- A **Context Snapshot** stores one codec-encoded JSON value and, for removable dynamic sources, a pre-rendered removal message per stable **Context Source** key.
|
||||
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
|
||||
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
|
||||
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
|
||||
- Context changes are sampled and admitted lazily at a **Safe Step Boundary**, never pushed asynchronously when their source changes.
|
||||
- At a **Safe Step Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
|
||||
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
|
||||
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
|
||||
- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once.
|
||||
- Steering prompts promote at the next **Safe Step Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's step allowance; multiple prompts promoted at one boundary reset it once.
|
||||
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
|
||||
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity.
|
||||
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
|
||||
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, physical attempts, and tool state rather than inventing an enclosing execution identity.
|
||||
- An **Execution** contains one or more **Session Drains**; a **Session Drain** contains one reserved assistant-turn span at a time; that span contains **Steps**; and each **Step** contains one or more **Physical Attempts** plus any tool calls it requires.
|
||||
- A **Step** record covers only the model-visible span from first assistant output through tool settlement; pre-flight leaves no record, and one Step settles at most one record.
|
||||
- The first **Step** renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the Step instead of persisting an incomplete baseline.
|
||||
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
|
||||
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
|
||||
- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Provider-Turn Boundary**.
|
||||
- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Step Boundary**.
|
||||
- **Context Source** keys are stable and namespaced; duplicate keys fail composition. `SystemContext.combine(...)` preserves caller order; the **System Context Registry** evaluates producers concurrently and combines them in stable contribution-key order so rendered context remains deterministic.
|
||||
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
|
||||
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
|
||||
@@ -113,26 +128,26 @@ _Avoid_: Response envelope
|
||||
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
|
||||
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
||||
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
|
||||
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
|
||||
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Step Boundary**.
|
||||
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
|
||||
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
|
||||
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
|
||||
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
|
||||
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Step Boundary**.
|
||||
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
|
||||
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
||||
- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn.
|
||||
- The selected agent and model are sampled when a **Step** starts. Changes admitted after that boundary apply to the next Step and do not restart the current Step.
|
||||
- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline.
|
||||
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
|
||||
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
|
||||
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
|
||||
- Local tool authorization and pending permission requests retain the effective agent of the **Step** that issued the call; a later agent switch cannot change that call's policy.
|
||||
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Step Boundary** loads and compares current values lazily.
|
||||
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following **Physical Attempt** fails and is replayed unchanged on retry.
|
||||
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
|
||||
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
||||
- A **Context Epoch** begins with one immutable **Baseline System Context**.
|
||||
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
|
||||
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
||||
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
|
||||
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
|
||||
- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
|
||||
- Completed compaction starts a new **Context Epoch** on the next **Physical Attempt**, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
|
||||
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next **Step**.
|
||||
- **Native Continuation Metadata** remains in durable history. Step projection includes it only for a successful exact originating provider/model match; failed Steps and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
|
||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "5.0.1",
|
||||
"@ast-grep/cli": "0.44.0",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -29,7 +30,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
@@ -83,6 +84,7 @@
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
@@ -91,12 +93,13 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
@@ -105,26 +108,28 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode-ai/client",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -137,9 +142,23 @@
|
||||
"effect",
|
||||
],
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -175,7 +194,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -202,7 +221,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -224,7 +243,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -248,7 +267,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -268,7 +287,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -299,6 +318,7 @@
|
||||
"@effect/sql-sqlite-bun": "catalog:",
|
||||
"@ff-labs/fff-bun": "0.9.4",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@npmcli/config": "10.8.1",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
@@ -316,10 +336,11 @@
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"bun-pty": "0.4.8",
|
||||
"cross-spawn": "catalog:",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.9.3",
|
||||
"gitlab-ai-provider": "6.10.0",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -332,7 +353,7 @@
|
||||
"npm-package-arg": "13.0.2",
|
||||
"semver": "^7.6.3",
|
||||
"turndown": "7.2.0",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"venice-ai-sdk-provider": "2.1.1",
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"zod": "catalog:",
|
||||
@@ -361,7 +382,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"effect": "catalog:",
|
||||
@@ -415,7 +436,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -429,7 +450,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -441,7 +462,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -473,7 +494,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -489,7 +510,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
@@ -520,7 +541,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
@@ -539,7 +560,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -576,6 +597,7 @@
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -611,7 +633,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.9.3",
|
||||
"gitlab-ai-provider": "6.10.0",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -635,7 +657,7 @@
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
"ulid": "catalog:",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"venice-ai-sdk-provider": "2.1.1",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"ws": "8.21.0",
|
||||
@@ -669,9 +691,13 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:",
|
||||
@@ -680,6 +706,7 @@
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -698,6 +725,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode-ai/protocol",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -706,10 +734,12 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode-ai/schema",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -717,6 +747,7 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/script": {
|
||||
@@ -734,6 +765,7 @@
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -745,7 +777,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -760,10 +792,11 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -775,7 +808,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -817,9 +850,24 @@
|
||||
"vite": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode-ai/simulation",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -832,7 +880,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@opencode-ai/stats-core": "workspace:*",
|
||||
@@ -865,7 +913,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -884,7 +932,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -925,15 +973,18 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"clipboardy": "4.0.0",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -952,7 +1003,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -990,6 +1041,7 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
@@ -1002,7 +1054,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1048,6 +1100,7 @@
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
||||
@@ -1201,6 +1254,22 @@
|
||||
|
||||
"@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="],
|
||||
|
||||
"@ast-grep/cli": ["@ast-grep/cli@0.44.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.44.0", "@ast-grep/cli-darwin-x64": "0.44.0", "@ast-grep/cli-linux-arm64-gnu": "0.44.0", "@ast-grep/cli-linux-x64-gnu": "0.44.0", "@ast-grep/cli-win32-arm64-msvc": "0.44.0", "@ast-grep/cli-win32-ia32-msvc": "0.44.0", "@ast-grep/cli-win32-x64-msvc": "0.44.0" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-Jf4PuP7XjzsMa3m9gYxmzV8KyWZc4w1ZzKe/t0+90wWxmSasQJe6AtMkJxHEi98MGgfAF1nWziqjDd0/6EsBjA=="],
|
||||
|
||||
"@ast-grep/cli-darwin-arm64": ["@ast-grep/cli-darwin-arm64@0.44.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bF7euu/hF/cYg4510z8110vh60rrqfrBdsfRqVGd6xqNSPENu7CJnTVN/Z4Nk5U1NM8YKzUD+dYx1ySUJ0CUNQ=="],
|
||||
|
||||
"@ast-grep/cli-darwin-x64": ["@ast-grep/cli-darwin-x64@0.44.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-0fI9caQGp1dFcmBATNlVytIRdAeYb91v1D2xjMIi1bSX+l8Uj846JUiaimUGBuBZmyFq+BScoWM4RnprEmZMpQ=="],
|
||||
|
||||
"@ast-grep/cli-linux-arm64-gnu": ["@ast-grep/cli-linux-arm64-gnu@0.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-JB6EUnqEtGGtyg1GqNquld/++1CvaWD7r84IwwhddX1qx0NmDoHyn2mKd8vnQ24Z0RkV3g7y7foMLakELbGtDw=="],
|
||||
|
||||
"@ast-grep/cli-linux-x64-gnu": ["@ast-grep/cli-linux-x64-gnu@0.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rNL0LsI682D9EMzfaGVEtZa1xaqTtGb2I+Zk4ZzidX6u+fF7f79wdqyKahKjXzoIrGkuhkoL3gcyLKAtQd9+qg=="],
|
||||
|
||||
"@ast-grep/cli-win32-arm64-msvc": ["@ast-grep/cli-win32-arm64-msvc@0.44.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-lqD0MhGQAddh2YoV/brKQ6GVcFLmRiTBwIElutwedaUvRCdasTGukFPYuSWk/iI8Kv19xom6s7l+mGuZ7v+xwQ=="],
|
||||
|
||||
"@ast-grep/cli-win32-ia32-msvc": ["@ast-grep/cli-win32-ia32-msvc@0.44.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ZJrnS+2OkNfwyr6yrN69glP67uybBxDvl9mqZvh1J44vB3OFn9U9c+cVAoZIAo7JD5F4rZNxwyu3gcy4+xuwEA=="],
|
||||
|
||||
"@ast-grep/cli-win32-x64-msvc": ["@ast-grep/cli-win32-x64-msvc@0.44.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OJEo7f95YYaSuS1byUB7ZctbzxoA7/wCoAol+pt6pvfdW/8Wq+L1qU28glwx7dQ0HgTsnPZbWpXQwmZpCBHhZg=="],
|
||||
|
||||
"@astrojs/check": ["@astrojs/check@0.9.6", "", { "dependencies": { "@astrojs/language-server": "^2.16.1", "chokidar": "^4.0.1", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA=="],
|
||||
|
||||
"@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="],
|
||||
@@ -1919,6 +1988,8 @@
|
||||
|
||||
"@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"],
|
||||
|
||||
"@opencode-ai/codemode": ["@opencode-ai/codemode@workspace:packages/codemode"],
|
||||
|
||||
"@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"],
|
||||
|
||||
"@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"],
|
||||
@@ -1965,6 +2036,8 @@
|
||||
|
||||
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
|
||||
|
||||
"@opencode-ai/simulation": ["@opencode-ai/simulation@workspace:packages/simulation"],
|
||||
|
||||
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
|
||||
|
||||
"@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"],
|
||||
@@ -3003,9 +3076,9 @@
|
||||
|
||||
"abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
@@ -3173,7 +3246,7 @@
|
||||
|
||||
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
|
||||
|
||||
"body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="],
|
||||
|
||||
@@ -3341,7 +3414,7 @@
|
||||
|
||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||
|
||||
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
|
||||
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
@@ -3351,7 +3424,7 @@
|
||||
|
||||
"cookie-es": ["cookie-es@2.0.1", "", {}, "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
@@ -3453,7 +3526,7 @@
|
||||
|
||||
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
|
||||
|
||||
"detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
|
||||
|
||||
@@ -3659,7 +3732,7 @@
|
||||
|
||||
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
|
||||
|
||||
"express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="],
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
|
||||
|
||||
@@ -3717,7 +3790,7 @@
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
|
||||
|
||||
@@ -3755,7 +3828,7 @@
|
||||
|
||||
"framer-motion": ["framer-motion@8.5.5", "", { "dependencies": { "@motionone/dom": "^10.15.3", "hey-listen": "^1.0.8", "tslib": "^2.4.0" }, "optionalDependencies": { "@emotion/is-prop-valid": "^0.8.2" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-5IDx5bxkjWHWUF3CVJoSyUVOtrbAxtzYBBowRE2uYI/6VYhkEBD+rbTHEGuUmbGHRj6YqqSfoG7Aa1cLyWCrBA=="],
|
||||
|
||||
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
|
||||
|
||||
@@ -3807,7 +3880,7 @@
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.9.3", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-lWo6b6es5+k9iXaDIvE9ECzyK4zfEza4+dQ5FN8SJpEuVRi3ZBCpHIOTa32QoYEDCBaiPh+tcyca86PfNodmlg=="],
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.10.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-oWEZ06rDO6JjB7INHO882wyBAQqCZVHiDHwCs5M+VPmdDj8TzhGXcYesA2CcV5RoI5lfHLKwGp5uKFB62VWpqw=="],
|
||||
|
||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||
|
||||
@@ -4319,11 +4392,11 @@
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
||||
|
||||
@@ -4809,7 +4882,7 @@
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
|
||||
|
||||
@@ -4995,7 +5068,7 @@
|
||||
|
||||
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
|
||||
|
||||
"send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
|
||||
|
||||
@@ -5005,7 +5078,7 @@
|
||||
|
||||
"seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="],
|
||||
|
||||
"serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||
|
||||
@@ -5315,11 +5388,13 @@
|
||||
|
||||
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
||||
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
|
||||
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
||||
|
||||
@@ -5423,7 +5498,7 @@
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.2", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.47", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.27" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-aoa05nI3BTK5aGbjBflq+Gfln2AHAkwNbWuGGvCzUIsOfp5Y3iPD4O4PUGDAEiWVJWbjpPn0KfDa0H/HebwsaA=="],
|
||||
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.1.1", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.51", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.30" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-w3OHkuzzKZ3r2TOxER6myBYzZJNoDqol+DUHu3NnfBN/GETnUVxecZJab0CHQQ8GZc0jjzpFymepjcLDPS4SQg=="],
|
||||
|
||||
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
|
||||
|
||||
@@ -5685,6 +5760,8 @@
|
||||
|
||||
"@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/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"@astrojs/sitemap/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
@@ -5895,16 +5972,14 @@
|
||||
|
||||
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||
|
||||
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
"@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
|
||||
@@ -5973,10 +6048,14 @@
|
||||
|
||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
"@opencode-ai/core/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
|
||||
|
||||
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
@@ -5987,6 +6066,8 @@
|
||||
|
||||
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
|
||||
|
||||
"@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"@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/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=="],
|
||||
@@ -6003,6 +6084,8 @@
|
||||
|
||||
"@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
|
||||
|
||||
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||
|
||||
"@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
@@ -6035,8 +6118,12 @@
|
||||
|
||||
"@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"@slack/bolt/express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="],
|
||||
|
||||
"@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"@slack/bolt/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
|
||||
|
||||
"@slack/oauth/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="],
|
||||
|
||||
"@slack/socket-mode/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="],
|
||||
@@ -6065,8 +6152,6 @@
|
||||
|
||||
"@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
|
||||
|
||||
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
@@ -6103,10 +6188,6 @@
|
||||
|
||||
"@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
|
||||
|
||||
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.107", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8nT08pGPy25rleJNk56ep00UHK6kCtCmu+ZNqVVSSPDieADlIZqcaN1iRXAFBoCH0Fb9F6C2EjFDaySdsargfQ=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
|
||||
@@ -6141,6 +6222,8 @@
|
||||
|
||||
"astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
|
||||
|
||||
"astro/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
|
||||
|
||||
"astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="],
|
||||
@@ -6161,10 +6244,6 @@
|
||||
|
||||
"babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
|
||||
|
||||
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
@@ -6231,6 +6310,8 @@
|
||||
|
||||
"engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
|
||||
|
||||
"esast-util-from-js/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
@@ -6243,16 +6324,10 @@
|
||||
|
||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"express/path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
|
||||
|
||||
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||
|
||||
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
@@ -6289,12 +6364,12 @@
|
||||
|
||||
"light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
|
||||
|
||||
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
|
||||
|
||||
"micromark-extension-mdxjs/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
|
||||
@@ -6319,8 +6394,6 @@
|
||||
|
||||
"node-gyp/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
|
||||
|
||||
"node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"nypm/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
|
||||
@@ -6335,6 +6408,8 @@
|
||||
|
||||
"opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="],
|
||||
|
||||
"opencode/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"opencode-gitlab-auth/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=="],
|
||||
|
||||
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
@@ -6375,8 +6450,6 @@
|
||||
|
||||
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||
|
||||
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
@@ -6385,14 +6458,8 @@
|
||||
|
||||
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||
|
||||
"serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
||||
|
||||
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
|
||||
|
||||
"shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
@@ -6419,6 +6486,8 @@
|
||||
|
||||
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"terser/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
|
||||
@@ -6433,24 +6502,28 @@
|
||||
|
||||
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
|
||||
|
||||
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
||||
|
||||
"unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
|
||||
|
||||
"unzipper/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SoPSkrL5cbNQnAljRsJ7pOzJ2FmWgnhC0lfFOda873ycCdFJL1A+h3Ib7mX2spcv3XnNaO13y/45/0RyqNWlIQ=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.32", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Kwj499fTcN9bP/AfGoPU7JWIXeP6VZqKI6omsH062c9E2G4gdjeJczkz4z/tYSkzYjLE2AI3DtZbMfs6D7vn2Q=="],
|
||||
|
||||
"verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
|
||||
|
||||
"vite-plugin-dynamic-import/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"vitest/@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="],
|
||||
@@ -6703,28 +6776,6 @@
|
||||
|
||||
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
@@ -6847,6 +6898,34 @@
|
||||
|
||||
"@shikijs/stream/@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"@slack/bolt/express/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"@slack/bolt/express/body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
|
||||
|
||||
"@slack/bolt/express/content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
|
||||
|
||||
"@slack/bolt/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"@slack/bolt/express/cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
|
||||
|
||||
"@slack/bolt/express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"@slack/bolt/express/finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
|
||||
|
||||
"@slack/bolt/express/fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
|
||||
|
||||
"@slack/bolt/express/merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
|
||||
|
||||
"@slack/bolt/express/path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
|
||||
|
||||
"@slack/bolt/express/send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
|
||||
|
||||
"@slack/bolt/express/serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
|
||||
|
||||
"@slack/bolt/express/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
||||
|
||||
"@slack/bolt/raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
@@ -6869,6 +6948,8 @@
|
||||
|
||||
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@storybook/csf-plugin/unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
@@ -6877,8 +6958,6 @@
|
||||
|
||||
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
@@ -6943,8 +7022,6 @@
|
||||
|
||||
"babel-plugin-module-resolver/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=="],
|
||||
|
||||
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
@@ -7003,12 +7080,8 @@
|
||||
|
||||
"esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"filelist/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
||||
|
||||
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
|
||||
@@ -7045,8 +7118,6 @@
|
||||
|
||||
"rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"storybook/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
@@ -7061,12 +7132,14 @@
|
||||
|
||||
"tw-to-css/tailwindcss/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
||||
|
||||
"type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
@@ -7251,10 +7324,6 @@
|
||||
|
||||
"@jsx-email/cli/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
@@ -7287,6 +7356,20 @@
|
||||
|
||||
"@sentry/bundler-plugin-core/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"@slack/bolt/express/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"@slack/bolt/express/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
|
||||
"@slack/bolt/express/body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"@slack/bolt/express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"@slack/bolt/express/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||
|
||||
"@slack/bolt/express/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||
|
||||
"@slack/bolt/express/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"@slack/web-api/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
||||
@@ -7415,6 +7498,10 @@
|
||||
|
||||
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"@slack/bolt/express/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"@slack/bolt/express/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="],
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
|
||||
"aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
|
||||
"aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
|
||||
"x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
|
||||
"x86_64-linux": "sha256-zjYaBa+TKuHVBO0z0YqOOFLOxpxj2hB6Iq1o+BXBeVc=",
|
||||
"aarch64-linux": "sha256-S2QDDoKcg8QCqjxCnMmLp/3xmiVHAbCJ0CuHQRjDBbE=",
|
||||
"aarch64-darwin": "sha256-GtdfHbk4fhvosgo6iEjXrWSxteK+eNrT0P9swEccbew=",
|
||||
"x86_64-darwin": "sha256-zmWFdPSTS/DpU40CgHA/KKiSIs0PlI092TzLv/cG0jE="
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -2,17 +2,20 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"lint": "oxlint",
|
||||
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
||||
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"postinstall": "bun run --cwd packages/core fix-node-pty",
|
||||
@@ -94,6 +97,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "5.0.1",
|
||||
"@ast-grep/cli": "0.44.0",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -153,6 +157,7 @@
|
||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch"
|
||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function installStressSessionTabs(page: Page, input?: { draftID?: s
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
...sessionIDs.map((sessionId) => ({
|
||||
type: "session",
|
||||
|
||||
@@ -14,7 +14,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
|
||||
{ type: "session", server: serverB, sessionId: sessionB },
|
||||
@@ -52,7 +52,7 @@ test("legacy session routes preserve an existing tab's server", async ({ page })
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -66,7 +66,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
||||
await expect(control).toBeVisible()
|
||||
|
||||
await control.locator('[data-action="prompt-model-variant"]').click()
|
||||
const high = page.getByRole("option", { name: "high" })
|
||||
const high = page.getByRole("menuitemradio", { name: "high" })
|
||||
await expect(high).toBeVisible()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toBeVisible()
|
||||
|
||||
@@ -38,6 +38,29 @@ test("shows a pending question dock", async ({ page }) => {
|
||||
await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
|
||||
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
|
||||
|
||||
const rejectRequests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
|
||||
})
|
||||
|
||||
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
||||
await expect(question).toBeVisible()
|
||||
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
|
||||
await expect(question.getByText("Select one answer")).toBeHidden()
|
||||
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeHidden()
|
||||
await expect(question.getByRole("radio", { name: /Extended/ })).toBeHidden()
|
||||
await expect(question.getByRole("button", { name: "Dismiss" })).toBeVisible()
|
||||
await expect(question.getByRole("button", { name: "Submit" })).toBeVisible()
|
||||
await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0)
|
||||
expect(rejectRequests).toEqual([])
|
||||
|
||||
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
||||
await expect(question).toBeVisible()
|
||||
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
|
||||
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
|
||||
expect(rejectRequests).toEqual([])
|
||||
|
||||
await question.getByRole("radio", { name: /Minimal/ }).click()
|
||||
const reply = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
|
||||
|
||||
@@ -145,7 +145,7 @@ async function configurePage(page: Page) {
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -127,7 +127,7 @@ test.describe("smoke: session timeline", () => {
|
||||
await page.addInitScript(
|
||||
({ dirBase64, sourceID, targetID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(
|
||||
[sourceID, targetID].map((sessionId) => ({
|
||||
type: "session",
|
||||
@@ -253,7 +253,7 @@ test.describe("smoke: session timeline", () => {
|
||||
await page.addInitScript(
|
||||
({ dirBase64, sourceID, targetID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(
|
||||
[sourceID, targetID].map((sessionId) => ({
|
||||
type: "session",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -38,6 +38,7 @@
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
|
||||
+44
-108
@@ -28,11 +28,11 @@ import {
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider } from "@/context/command"
|
||||
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
@@ -40,10 +40,10 @@ import { LayoutProvider } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { NotificationProvider } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
@@ -52,16 +52,9 @@ import LegacyLayout from "@/pages/layout"
|
||||
import NewLayout from "@/pages/layout-new"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import {
|
||||
legacySessionHref,
|
||||
legacySessionServer,
|
||||
requireServerKey,
|
||||
selectSessionLineage,
|
||||
sessionHref,
|
||||
} from "./utils/session-route"
|
||||
import { isSessionNotFoundError } from "./utils/server-errors"
|
||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
|
||||
import Session from "@/pages/session"
|
||||
import { SessionPage, TargetSessionRoute as TargetSessionRouteContent } from "@/pages/session"
|
||||
import { NewHome, LegacyHome } from "@/pages/home"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
@@ -95,11 +88,7 @@ const SessionRoute = () => {
|
||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
)
|
||||
return <SessionPage />
|
||||
}
|
||||
|
||||
const TargetSessionRoute = () => {
|
||||
@@ -114,76 +103,13 @@ const TargetSessionRoute = () => {
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<ResolvedTargetSessionRoute />
|
||||
<TargetSessionRouteContent />
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedTargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const sync = useServerSync()
|
||||
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
||||
const cached = createMemo(() => sync().session.lineage.peek(params.id))
|
||||
const [resolved] = createResource(
|
||||
() => {
|
||||
if (cached()) return
|
||||
return { id: params.id, server: serverKey(), sync: sync() }
|
||||
},
|
||||
({ id, server, sync }) =>
|
||||
sync.session.lineage.resolve(id).catch((error) => {
|
||||
if (isSessionNotFoundError(error, id)) tabs.removeSessionTab({ server, sessionId: id })
|
||||
throw error
|
||||
}),
|
||||
)
|
||||
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
|
||||
const directory = createMemo(() => current()?.session.directory)
|
||||
const targetDirectory = () => directory()!
|
||||
|
||||
createEffect(() => {
|
||||
const session = current()
|
||||
if (!session) return
|
||||
tabs.addSessionTab({
|
||||
server: serverKey(),
|
||||
sessionId: session.root.id,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||
<Show when={!!current() || resolved.state !== "errored"} fallback={<ErrorPage error={resolved.error} />}>
|
||||
<Show when={directory()}>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
|
||||
>
|
||||
<SDKProvider directory={targetDirectory}>
|
||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||
<TargetSessionPage />
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</TargetServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetSessionPage() {
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
return (
|
||||
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
||||
// Notification/Models + the visual Layout) for that server.
|
||||
@@ -231,7 +157,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<TargetServerScopedProviders directory={directory}>
|
||||
<DraftServerScopedProviders directory={directory}>
|
||||
<SDKProvider directory={directory}>
|
||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||
<DraftProviders>
|
||||
@@ -239,7 +165,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</TargetServerScopedProviders>
|
||||
</DraftServerScopedProviders>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
@@ -300,12 +226,36 @@ function SharedProviders(props: ParentProps) {
|
||||
<>
|
||||
<BodyDesignClass />
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<HighlightsProvider>{props.children}</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DesktopCommands() {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
|
||||
command.register("desktop", () => {
|
||||
const commands: CommandOption[] = []
|
||||
if (platform.platform === "desktop" && platform.exportDebugLogs) {
|
||||
commands.push({
|
||||
id: "logs.export",
|
||||
title: "Export logs",
|
||||
category: language.t("command.category.settings"),
|
||||
onSelect: () => {
|
||||
void platform.exportDebugLogs?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
return commands
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
||||
type ServerScopedShellProps = ParentProps<{
|
||||
directory?: () => string | undefined
|
||||
@@ -316,9 +266,7 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
@@ -342,28 +290,14 @@ function NewAppLayout(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
||||
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionProviders(props: ParentProps) {
|
||||
return (
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<CommentsProvider>{props.children}</CommentsProvider>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||
function DraftProviders(props: ParentProps) {
|
||||
@@ -560,11 +494,13 @@ export function AppInterface(props: {
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
<NotificationProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
</NotificationProvider>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
type Mem = Performance & {
|
||||
@@ -51,31 +52,62 @@ const bad = (n: number | undefined, limit: number, low = false) => {
|
||||
|
||||
const session = (path: string) => path.includes("/session")
|
||||
|
||||
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) {
|
||||
return (
|
||||
<Tooltip value={props.tip} placement="top">
|
||||
function Cell(props: {
|
||||
bad?: boolean
|
||||
dim?: boolean
|
||||
inline?: boolean
|
||||
label: string
|
||||
tip: string
|
||||
value: string
|
||||
wide?: boolean
|
||||
}) {
|
||||
const content = () => (
|
||||
<div
|
||||
classList={{
|
||||
"flex min-w-0 items-center": true,
|
||||
"min-h-[20px] w-fit flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline,
|
||||
"justify-center text-center": !props.inline,
|
||||
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
|
||||
"col-span-2": !!props.wide && !props.inline,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
|
||||
"col-span-2": !!props.wide,
|
||||
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
|
||||
}}
|
||||
>
|
||||
<div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div>
|
||||
<div
|
||||
classList={{
|
||||
"text-[13px] leading-none font-bold tabular-nums sm:text-[14px]": true,
|
||||
"text-text-on-critical-base": !!props.bad,
|
||||
"opacity-70": !!props.dim,
|
||||
}}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
{props.label}
|
||||
</div>
|
||||
<div
|
||||
classList={{
|
||||
"uppercase leading-none font-bold tabular-nums": true,
|
||||
"text-[11px]": !!props.inline,
|
||||
"text-[13px] sm:text-[14px]": !props.inline,
|
||||
"text-text-on-critical-base": !!props.bad,
|
||||
"opacity-70": !!props.dim,
|
||||
}}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (props.inline) {
|
||||
return (
|
||||
<TooltipV2 value={props.tip} placement="top">
|
||||
{content()}
|
||||
</TooltipV2>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip value={props.tip} placement="top">
|
||||
{content()}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function DebugBar() {
|
||||
export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
const language = useLanguage()
|
||||
const location = useLocation()
|
||||
const routing = useIsRouting()
|
||||
@@ -101,7 +133,7 @@ export function DebugBar() {
|
||||
},
|
||||
})
|
||||
|
||||
const na = () => language.t("debugBar.na")
|
||||
const na = () => language.t("debugBar.na").toUpperCase()
|
||||
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
|
||||
const heapv = () => {
|
||||
const value = heap()
|
||||
@@ -363,15 +395,30 @@ export function DebugBar() {
|
||||
return (
|
||||
<aside
|
||||
aria-label={language.t("debugBar.ariaLabel")}
|
||||
class="pointer-events-auto fixed bottom-3 right-3 z-50 hidden w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] md:block sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||
classList={{
|
||||
"pointer-events-auto hidden overflow-hidden text-text-strong md:block": true,
|
||||
"mt-[-6px] w-full shrink-0 px-3 py-1": !!props.inline,
|
||||
"fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]":
|
||||
!props.inline,
|
||||
}}
|
||||
>
|
||||
<div class="grid grid-cols-5 gap-px font-mono">
|
||||
<div
|
||||
classList={{
|
||||
"font-mono": true,
|
||||
"gap-[9px]": !!props.inline,
|
||||
"gap-px": !props.inline,
|
||||
"flex w-full flex-nowrap items-center justify-start": !!props.inline,
|
||||
"grid-cols-5": !props.inline,
|
||||
grid: !props.inline,
|
||||
}}
|
||||
>
|
||||
<Cell
|
||||
label={language.t("debugBar.nav.label")}
|
||||
tip={language.t("debugBar.nav.tip")}
|
||||
value={navv()}
|
||||
bad={bad(state.nav.dur, 400)}
|
||||
dim={state.nav.dur === undefined && !state.nav.pending}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.fps.label")}
|
||||
@@ -379,6 +426,7 @@ export function DebugBar() {
|
||||
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
|
||||
bad={bad(state.fps, 50, true)}
|
||||
dim={state.fps === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.frame.label")}
|
||||
@@ -386,6 +434,7 @@ export function DebugBar() {
|
||||
value={time(state.gap) ?? na()}
|
||||
bad={bad(state.gap, 50)}
|
||||
dim={state.gap === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.jank.label")}
|
||||
@@ -393,6 +442,7 @@ export function DebugBar() {
|
||||
value={state.jank === undefined ? na() : `${state.jank}`}
|
||||
bad={bad(state.jank, 8)}
|
||||
dim={state.jank === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.long.label")}
|
||||
@@ -400,6 +450,7 @@ export function DebugBar() {
|
||||
value={longv()}
|
||||
bad={bad(state.long.block, 200)}
|
||||
dim={state.long.count === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.delay.label")}
|
||||
@@ -407,6 +458,7 @@ export function DebugBar() {
|
||||
value={time(state.delay) ?? na()}
|
||||
bad={bad(state.delay, 100)}
|
||||
dim={state.delay === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.inp.label")}
|
||||
@@ -414,6 +466,7 @@ export function DebugBar() {
|
||||
value={time(state.inp) ?? na()}
|
||||
bad={bad(state.inp, 200)}
|
||||
dim={state.inp === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.cls.label")}
|
||||
@@ -421,6 +474,7 @@ export function DebugBar() {
|
||||
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
|
||||
bad={bad(state.cls, 0.1)}
|
||||
dim={state.cls === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.mem.label")}
|
||||
@@ -435,6 +489,7 @@ export function DebugBar() {
|
||||
value={heapv()}
|
||||
bad={bad(heap(), 0.8)}
|
||||
dim={state.heap.used === undefined}
|
||||
inline={props.inline}
|
||||
wide
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -65,6 +65,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
promptInputs: undefined as undefined | Record<string, string>,
|
||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
@@ -73,6 +74,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
| { type: "method.select"; index: number }
|
||||
| { type: "method.reset" }
|
||||
| { type: "auth.prompt" }
|
||||
| { type: "auth.inputs"; inputs: Record<string, string> }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||
| { type: "auth.error"; error: string }
|
||||
@@ -83,6 +85,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
if (action.type === "method.select") {
|
||||
draft.methodIndex = action.index
|
||||
draft.authorization = undefined
|
||||
draft.promptInputs = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
@@ -90,6 +93,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
if (action.type === "method.reset") {
|
||||
draft.methodIndex = undefined
|
||||
draft.authorization = undefined
|
||||
draft.promptInputs = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
@@ -99,6 +103,12 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.inputs") {
|
||||
draft.promptInputs = action.inputs
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.pending") {
|
||||
draft.state = "pending"
|
||||
draft.error = undefined
|
||||
@@ -151,6 +161,15 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
const method = methods()[index]
|
||||
dispatch({ type: "method.select", index })
|
||||
|
||||
if (method.type === "api" && method.prompts?.length) {
|
||||
if (!inputs) {
|
||||
dispatch({ type: "auth.prompt" })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.inputs", inputs })
|
||||
return
|
||||
}
|
||||
|
||||
if (method.type === "oauth") {
|
||||
if (method.prompts?.length && !inputs) {
|
||||
dispatch({ type: "auth.prompt" })
|
||||
@@ -190,7 +209,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
}
|
||||
}
|
||||
|
||||
function OAuthPromptsView() {
|
||||
function AuthPromptsView() {
|
||||
const [formStore, setFormStore] = createStore({
|
||||
value: {} as Record<string, string>,
|
||||
index: 0,
|
||||
@@ -198,8 +217,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
|
||||
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
||||
const value = method()
|
||||
if (value?.type !== "oauth") return []
|
||||
return value.prompts ?? []
|
||||
return value?.prompts ?? []
|
||||
})
|
||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||
if (!prompt.when) return true
|
||||
@@ -230,6 +248,10 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
setFormStore("index", next)
|
||||
return
|
||||
}
|
||||
if (method()?.type === "api") {
|
||||
dispatch({ type: "auth.inputs", inputs: value })
|
||||
return
|
||||
}
|
||||
await selectMethod(store.methodIndex, value)
|
||||
}
|
||||
|
||||
@@ -414,6 +436,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
auth: {
|
||||
type: "api",
|
||||
key: apiKey,
|
||||
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
|
||||
},
|
||||
})
|
||||
await complete()
|
||||
@@ -622,7 +645,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.state === "prompt"}>
|
||||
<OAuthPromptsView />
|
||||
<AuthPromptsView />
|
||||
</Match>
|
||||
<Match when={store.state === "error"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
|
||||
@@ -177,7 +177,7 @@ export function DialogCustomProvider(props: Props) {
|
||||
>
|
||||
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
||||
<div class="px-2.5 flex gap-4 items-center">
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,13 +3,26 @@ import { List } from "@opencode-ai/ui/list"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import type { Component } from "solid-js"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog as DialogV2, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { Switch as SwitchV2 } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { For, Show, type Component } from "solid-js"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { SettingsListV2 } from "./settings-v2/parts/list"
|
||||
import { SettingsRowV2 } from "./settings-v2/parts/row"
|
||||
import "./settings-v2/settings-v2.css"
|
||||
|
||||
type ModelItem = ReturnType<ReturnType<typeof useLocal>["model"]["list"]>[number]
|
||||
|
||||
export const DialogManageModels: Component = () => {
|
||||
const local = useLocal()
|
||||
@@ -102,3 +115,151 @@ export const DialogManageModels: Component = () => {
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export const DialogManageModelsV2: Component = () => {
|
||||
const local = useLocal()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
dialog.show(() => <DialogSelectProvider directory={directory} />)
|
||||
}
|
||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||
const providerVisible = (providerID: string) =>
|
||||
providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
|
||||
const setProviderVisibility = (providerID: string, checked: boolean) => {
|
||||
providerList(providerID).forEach((x) => {
|
||||
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
|
||||
})
|
||||
}
|
||||
const setModelVisibility = (item: ModelItem, checked: boolean) => {
|
||||
local.model.setVisibility({ modelID: item.id, providerID: item.provider.id }, checked)
|
||||
}
|
||||
const list = useFilteredList<ModelItem>({
|
||||
items: () => local.model.list(),
|
||||
key: (x) => `${x.provider.id}:${x.id}`,
|
||||
filterKeys: ["provider.name", "name", "id"],
|
||||
sortBy: (a, b) => a.name.localeCompare(b.name),
|
||||
groupBy: (x) => x.provider.id,
|
||||
sortGroupsBy: (a, b) => {
|
||||
const aRank = popularProviders.indexOf(a.category)
|
||||
const bRank = popularProviders.indexOf(b.category)
|
||||
const aPopular = aRank >= 0
|
||||
const bPopular = bRank >= 0
|
||||
if (aPopular && !bPopular) return -1
|
||||
if (!aPopular && bPopular) return 1
|
||||
return aRank - bRank
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogV2 size="large" variant="settings" class="settings-v2-manage-models-dialog">
|
||||
<DialogHeader hideClose={true} closeLabel={language.t("common.close")}>
|
||||
<DialogTitleGroup
|
||||
title={language.t("dialog.model.manage")}
|
||||
description={language.t("dialog.model.manage.description")}
|
||||
/>
|
||||
<ButtonV2 variant="neutral" icon="plus" onClick={handleConnectProvider}>
|
||||
{language.t("command.provider.connect")}
|
||||
</ButtonV2>
|
||||
</DialogHeader>
|
||||
<DialogBody class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="px-4 pt-px pb-3">
|
||||
<div class="relative">
|
||||
<TextInputV2
|
||||
type="search"
|
||||
appearance="base"
|
||||
class="!w-full self-stretch"
|
||||
value={list.filter()}
|
||||
onInput={(event) => list.onInput(event.currentTarget.value)}
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autofocus
|
||||
aria-label={language.t("dialog.model.search.placeholder")}
|
||||
/>
|
||||
<Show when={list.filter()}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-v2-tab-search-clear"
|
||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => list.clear()}
|
||||
aria-label={language.t("common.clear")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="manage-models-scroll" class="relative min-h-0 flex-1">
|
||||
<div class="settings-v2-panel settings-v2-models h-full px-4 pt-4 pb-4">
|
||||
<Show
|
||||
when={!list.grouped.loading}
|
||||
fallback={
|
||||
<div class="settings-v2-models-status">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={list.flat().length > 0}
|
||||
fallback={
|
||||
<div class="settings-v2-models-status">
|
||||
<span>{language.t("dialog.model.empty")}</span>
|
||||
<Show when={list.filter()}>
|
||||
<span class="settings-v2-models-status-filter">"{list.filter()}"</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={list.grouped.latest}>
|
||||
{(group) => (
|
||||
<div class="settings-v2-section" data-component="settings-models-provider">
|
||||
<div class="settings-v2-models-group-header justify-between">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<ProviderIcon id={group.category} width={16} height={16} class="ml-4 shrink-0" />
|
||||
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
|
||||
</div>
|
||||
<div>
|
||||
<SwitchV2
|
||||
class="mr-6"
|
||||
checked={providerVisible(group.category)}
|
||||
onChange={(checked) => setProviderVisibility(group.category, checked)}
|
||||
hideLabel
|
||||
>
|
||||
{group.items[0].provider.name}
|
||||
</SwitchV2>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsListV2>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<SettingsRowV2 title={item.name} description="">
|
||||
<div>
|
||||
<SwitchV2
|
||||
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
|
||||
onChange={(checked) => setModelVisibility(item, checked)}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
</SwitchV2>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
)}
|
||||
</For>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogV2>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||
import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js"
|
||||
import { Component, ComponentProps, createMemo, For, JSX, Show, ValidComponent } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
@@ -18,6 +22,22 @@ const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
type ModelItem = ReturnType<ModelState["list"]>[number]
|
||||
|
||||
const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}`
|
||||
const manageKey = "action:manage"
|
||||
|
||||
const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { category: string; items: ModelItem[] }) => {
|
||||
const aIndex = popularProviders.indexOf(a.category)
|
||||
const bIndex = popularProviders.indexOf(b.category)
|
||||
const aPopular = aIndex >= 0
|
||||
const bPopular = bIndex >= 0
|
||||
|
||||
if (aPopular && !bPopular) return -1
|
||||
if (!aPopular && bPopular) return 1
|
||||
if (aPopular && bPopular) return aIndex - bIndex
|
||||
return a.items[0].provider.name.localeCompare(b.items[0].provider.name)
|
||||
}
|
||||
|
||||
const ModelList: Component<{
|
||||
provider?: string
|
||||
@@ -59,6 +79,7 @@ const ModelList: Component<{
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||
>
|
||||
{node}
|
||||
@@ -199,6 +220,271 @@ export function ModelSelectorPopover(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelSelectorPopoverV2(props: {
|
||||
provider?: string
|
||||
model?: ModelState
|
||||
children?: JSX.Element
|
||||
triggerAs?: ValidComponent
|
||||
triggerProps?: ModelSelectorTriggerProps
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const model = props.model ?? useLocal().model
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||
let searchRef: HTMLInputElement | undefined
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let restoreTrigger = true
|
||||
|
||||
const allModels = createMemo(() =>
|
||||
model
|
||||
.list()
|
||||
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
||||
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
||||
)
|
||||
const models = createMemo(() => {
|
||||
const search = store.search.trim().toLowerCase()
|
||||
const filtered = search
|
||||
? allModels().filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(search) ||
|
||||
item.id.toLowerCase().includes(search) ||
|
||||
item.provider.name.toLowerCase().includes(search),
|
||||
)
|
||||
: allModels()
|
||||
|
||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
const groups = createMemo(() => {
|
||||
const byProvider = new Map<string, ModelItem[]>()
|
||||
for (const item of models()) {
|
||||
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
||||
}
|
||||
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
||||
})
|
||||
const keys = () => [...models().map(modelKey), manageKey]
|
||||
const current = () => {
|
||||
const value = model.current()
|
||||
return value ? `${value.provider.id}:${value.id}` : undefined
|
||||
}
|
||||
const initialActive = () => {
|
||||
const selected = current()
|
||||
const options = keys()
|
||||
if (selected && options.includes(selected)) return selected
|
||||
return options[0] ?? ""
|
||||
}
|
||||
const activeItem = () =>
|
||||
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
||||
const afterClose = (callback: () => void) => {
|
||||
const complete = () => {
|
||||
if (contentRef?.isConnected) {
|
||||
requestAnimationFrame(complete)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => requestAnimationFrame(callback))
|
||||
}
|
||||
requestAnimationFrame(complete)
|
||||
}
|
||||
const setOpen = (open: boolean) => {
|
||||
if (open) {
|
||||
restoreTrigger = true
|
||||
setStore({ open: true, active: initialActive() })
|
||||
setTimeout(() =>
|
||||
requestAnimationFrame(() => {
|
||||
searchRef?.focus()
|
||||
activeItem()?.scrollIntoView({ block: "nearest" })
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
}
|
||||
const select = (item: ModelItem) => {
|
||||
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||
props.onClose?.()
|
||||
}
|
||||
const selectModel = (item: ModelItem) => {
|
||||
restoreTrigger = false
|
||||
setOpen(false)
|
||||
afterClose(() => select(item))
|
||||
}
|
||||
const manage = () => {
|
||||
restoreTrigger = false
|
||||
setOpen(false)
|
||||
afterClose(() => {
|
||||
void import("./dialog-manage-models").then((x) => {
|
||||
dialog.show(() => <x.DialogManageModelsV2 />)
|
||||
})
|
||||
})
|
||||
}
|
||||
const selectActive = () => {
|
||||
const item = models().find((item) => modelKey(item) === store.active)
|
||||
if (item) {
|
||||
selectModel(item)
|
||||
return
|
||||
}
|
||||
if (store.active === manageKey) manage()
|
||||
}
|
||||
const moveActive = (delta: number) => {
|
||||
const options = keys()
|
||||
if (options.length === 0) return
|
||||
const index = options.indexOf(store.active)
|
||||
const start = index === -1 ? 0 : index
|
||||
setStore("active", options[(start + delta + options.length) % options.length])
|
||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||
}
|
||||
const setSearch = (value: string) => {
|
||||
const search = value.trim().toLowerCase()
|
||||
const first = [...allModels()]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.find(
|
||||
(item) =>
|
||||
!search ||
|
||||
item.name.toLowerCase().includes(search) ||
|
||||
item.id.toLowerCase().includes(search) ||
|
||||
item.provider.name.toLowerCase().includes(search),
|
||||
)
|
||||
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
||||
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||
{props.children}
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content
|
||||
ref={(el: HTMLDivElement) => (contentRef = el)}
|
||||
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
onPointerDownOutside={() => (restoreTrigger = false)}
|
||||
onFocusOutside={() => (restoreTrigger = false)}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!restoreTrigger) event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col p-0.5">
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={(el) => (searchRef = el)}
|
||||
value={store.search}
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Tab") return
|
||||
event.stopPropagation()
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
restoreTrigger = false
|
||||
setOpen(false)
|
||||
afterClose(() => props.onClose?.())
|
||||
return
|
||||
}
|
||||
if (event.altKey || event.metaKey) return
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
moveActive(1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
moveActive(-1)
|
||||
return
|
||||
}
|
||||
if (event.key === "Enter" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
selectActive()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Show when={store.search.trim()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => setSearch("")}
|
||||
aria-label={language.t("common.clear")}
|
||||
>
|
||||
<Icon name="close" size="small" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<ScrollView data-slot="model-selector-scroll" class="max-h-[220px] min-h-0">
|
||||
<div class="flex flex-col p-0.5 pt-0">
|
||||
<Show
|
||||
when={models().length > 0}
|
||||
fallback={
|
||||
<div class="flex h-12 items-center px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
|
||||
{language.t("dialog.model.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||
</MenuV2.GroupLabel>
|
||||
<MenuV2.RadioGroup value={current()}>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<MenuV2.RadioItem
|
||||
value={modelKey(item)}
|
||||
data-option-key={modelKey(item)}
|
||||
data-selected-model={current() === modelKey(item) ? true : undefined}
|
||||
class="scroll-my-6"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||
onMouseEnter={() => {
|
||||
setStore("active", modelKey(item))
|
||||
setTimeout(() => searchRef?.focus())
|
||||
}}
|
||||
onSelect={() => selectModel(item)}
|
||||
>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
|
||||
</Show>
|
||||
<Show when={item.latest}>
|
||||
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
|
||||
</Show>
|
||||
</MenuV2.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</MenuV2.RadioGroup>
|
||||
</MenuV2.Group>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollView>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<div class="flex flex-col p-0.5">
|
||||
<MenuV2.Item
|
||||
data-option-key={manageKey}
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === manageKey }}
|
||||
onMouseEnter={() => {
|
||||
setStore("active", manageKey)
|
||||
setTimeout(() => searchRef?.focus())
|
||||
}}
|
||||
onSelect={manage}
|
||||
>
|
||||
<Icon name="outline-sliders" size="small" />
|
||||
<span class="min-w-0 flex-1 truncate leading-5">{language.t("dialog.model.manage")}</span>
|
||||
</MenuV2.Item>
|
||||
</div>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
)
|
||||
}
|
||||
|
||||
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
@@ -35,10 +35,14 @@ import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { usePermission } from "@/context/permission"
|
||||
@@ -66,6 +70,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||
|
||||
@@ -212,6 +217,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
let fileInputRef: HTMLInputElement | undefined
|
||||
let scrollRef!: HTMLDivElement
|
||||
let slashPopoverRef!: HTMLDivElement
|
||||
let restoreEndOnFocus = true
|
||||
|
||||
const mirror = { input: false }
|
||||
const inset = 56
|
||||
@@ -591,6 +597,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
if (!restoreEndOnFocus) return
|
||||
restoreEndOnFocus = false
|
||||
requestAnimationFrame(() => {
|
||||
if (document.activeElement !== editorRef) return
|
||||
setCursorPosition(editorRef, prompt.cursor() ?? promptLength(prompt.current()))
|
||||
queueScroll()
|
||||
})
|
||||
}
|
||||
|
||||
const renderEditorWithCursor = (parts: Prompt) => {
|
||||
const cursor = currentCursor()
|
||||
renderEditor(parts)
|
||||
@@ -627,24 +643,89 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
}
|
||||
|
||||
const referenceDescription = (reference: ReferenceInfo) =>
|
||||
reference.source.type === "git" ? reference.source.repository : reference.source.path
|
||||
|
||||
const referenceList = createMemo(() =>
|
||||
sync()
|
||||
.data.reference.filter((reference) => !reference.hidden)
|
||||
.map(
|
||||
(reference): AtOption => ({
|
||||
type: "reference",
|
||||
name: reference.name,
|
||||
path: reference.path,
|
||||
display: reference.name,
|
||||
description: reference.description ?? referenceDescription(reference),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const agentList = createMemo(() =>
|
||||
props.controls.agents.available
|
||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
|
||||
)
|
||||
|
||||
const mcpResourceList = createMemo(() =>
|
||||
Object.values(sync().data.mcp_resource).map(
|
||||
(resource): AtOption => ({
|
||||
type: "resource",
|
||||
name: resource.name,
|
||||
uri: resource.uri,
|
||||
client: resource.client,
|
||||
display: resource.name,
|
||||
description: resource.description,
|
||||
mime: resource.mimeType,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const handleAtSelect = (option: AtOption | undefined) => {
|
||||
if (!option) return
|
||||
if (option.type === "agent") {
|
||||
addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 })
|
||||
} else {
|
||||
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
|
||||
return
|
||||
}
|
||||
if (option.type === "reference") {
|
||||
addPart({
|
||||
type: "file",
|
||||
path: option.path,
|
||||
content: "@" + option.name,
|
||||
start: 0,
|
||||
end: 0,
|
||||
mime: "application/x-directory",
|
||||
filename: option.name,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (option.type === "resource") {
|
||||
addPart({
|
||||
type: "file",
|
||||
path: option.uri,
|
||||
content: "@" + option.name,
|
||||
start: 0,
|
||||
end: 0,
|
||||
mime: option.mime ?? "text/plain",
|
||||
filename: option.name,
|
||||
url: option.uri,
|
||||
source: {
|
||||
type: "resource",
|
||||
text: { value: "@" + option.name, start: 0, end: 0 },
|
||||
clientName: option.client,
|
||||
uri: option.uri,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
|
||||
}
|
||||
|
||||
const atKey = (x: AtOption | undefined) => {
|
||||
if (!x) return ""
|
||||
return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}`
|
||||
if (x.type === "agent") return `agent:${x.name}`
|
||||
if (x.type === "reference") return `reference:${x.name}`
|
||||
if (x.type === "resource") return `resource:${x.client}:${x.uri}`
|
||||
return `file:${x.path}`
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -655,30 +736,36 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onKeyDown: atOnKeyDown,
|
||||
} = useFilteredList<AtOption>({
|
||||
items: async (query) => {
|
||||
const references = referenceList()
|
||||
const agents = agentList()
|
||||
const mcpResources = mcpResourceList()
|
||||
const open = recent()
|
||||
const seen = new Set(open)
|
||||
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
|
||||
if (!query.trim()) return [...agents, ...pinned]
|
||||
if (!query.trim()) return [...references, ...agents, ...mcpResources, ...pinned]
|
||||
const paths = await files.searchFilesAndDirectories(query)
|
||||
const fileOptions: AtOption[] = paths
|
||||
.filter((path) => !seen.has(path))
|
||||
.map((path) => ({ type: "file", path, display: path }))
|
||||
return [...agents, ...pinned, ...fileOptions]
|
||||
return [...references, ...agents, ...mcpResources, ...pinned, ...fileOptions]
|
||||
},
|
||||
key: atKey,
|
||||
filterKeys: ["display"],
|
||||
skipFilter: (item) => item.type === "file" && !item.recent,
|
||||
groupBy: (item) => {
|
||||
if (item.type === "reference") return "reference"
|
||||
if (item.type === "agent") return "agent"
|
||||
if (item.type === "resource") return "resource"
|
||||
if (item.recent) return "recent"
|
||||
return "file"
|
||||
},
|
||||
sortGroupsBy: (a, b) => {
|
||||
const rank = (category: string) => {
|
||||
if (category === "agent") return 0
|
||||
if (category === "recent") return 1
|
||||
return 2
|
||||
if (category === "reference") return 0
|
||||
if (category === "agent") return 1
|
||||
if (category === "resource") return 2
|
||||
if (category === "recent") return 3
|
||||
return 4
|
||||
}
|
||||
return rank(a.category) - rank(b.category)
|
||||
},
|
||||
@@ -744,7 +831,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const pill = document.createElement("span")
|
||||
pill.textContent = part.content
|
||||
pill.setAttribute("data-type", part.type)
|
||||
if (part.type === "file") pill.setAttribute("data-path", part.path)
|
||||
if (part.type === "file") {
|
||||
pill.setAttribute("data-path", part.path)
|
||||
if (part.mime) pill.setAttribute("data-mime", part.mime)
|
||||
if (part.filename) pill.setAttribute("data-filename", part.filename)
|
||||
if (part.url) pill.setAttribute("data-url", part.url)
|
||||
if (part.source?.type === "resource") {
|
||||
pill.setAttribute("data-source-type", part.source.type)
|
||||
pill.setAttribute("data-source-client-name", part.source.clientName)
|
||||
pill.setAttribute("data-source-uri", part.source.uri)
|
||||
}
|
||||
}
|
||||
if (part.type === "agent") pill.setAttribute("data-name", part.name)
|
||||
pill.setAttribute("contenteditable", "false")
|
||||
pill.style.userSelect = "text"
|
||||
@@ -789,8 +886,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll active command into view when navigating with keyboard
|
||||
createEffect(() => {
|
||||
const scrollSlashActiveIntoView = () => {
|
||||
const activeId = slashActive()
|
||||
if (!activeId || !slashPopoverRef) return
|
||||
|
||||
@@ -798,7 +894,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`)
|
||||
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
})
|
||||
})
|
||||
}
|
||||
const selectPopoverActive = () => {
|
||||
if (store.popover === "at") {
|
||||
const items = atFlat()
|
||||
@@ -860,12 +956,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const pushFile = (file: HTMLElement) => {
|
||||
const content = file.textContent ?? ""
|
||||
const source =
|
||||
file.dataset.sourceType === "resource" && file.dataset.sourceClientName && file.dataset.sourceUri
|
||||
? {
|
||||
type: "resource" as const,
|
||||
text: {
|
||||
value: content,
|
||||
start: position,
|
||||
end: position + content.length,
|
||||
},
|
||||
clientName: file.dataset.sourceClientName,
|
||||
uri: file.dataset.sourceUri,
|
||||
}
|
||||
: undefined
|
||||
parts.push({
|
||||
type: "file",
|
||||
path: file.dataset.path!,
|
||||
content,
|
||||
start: position,
|
||||
end: position + content.length,
|
||||
...(file.dataset.mime ? { mime: file.dataset.mime } : {}),
|
||||
...(file.dataset.filename ? { filename: file.dataset.filename } : {}),
|
||||
...(file.dataset.url ? { url: file.dataset.url } : {}),
|
||||
...(source ? { source } : {}),
|
||||
})
|
||||
position += content.length
|
||||
}
|
||||
@@ -1285,6 +1398,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
if (store.popover === "slash") {
|
||||
slashOnKeyDown(event)
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) {
|
||||
scrollSlashActiveIntoView()
|
||||
}
|
||||
}
|
||||
event.preventDefault()
|
||||
return
|
||||
@@ -1343,9 +1459,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
const agentsLoading = () => props.controls.agents.loading
|
||||
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
|
||||
const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading())
|
||||
const providersLoading = () => props.controls.model.loading
|
||||
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
||||
const providersShouldFadeIn = createMemo<boolean>((prev) => prev ?? providersLoading())
|
||||
|
||||
const [promptReady] = createResource(
|
||||
() => prompt.ready.promise,
|
||||
@@ -1359,12 +1475,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
||||
loading: providersLoading(),
|
||||
shouldAnimate: providersShouldFadeIn(),
|
||||
paid: props.controls.model.paid,
|
||||
title: language.t("command.model.choose"),
|
||||
keybind: command.keybind("model.choose"),
|
||||
keybind: command.keybindParts("model.choose"),
|
||||
model: props.controls.model.selection,
|
||||
providerID: props.controls.model.selection.current()?.provider?.id,
|
||||
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
||||
newLayoutDesigns: props.controls.newLayoutDesigns,
|
||||
style: control(),
|
||||
onClose: restoreFocus,
|
||||
onUnpaidClick: () => {
|
||||
@@ -1375,10 +1493,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}))
|
||||
|
||||
const newSession = () => props.variant === "new-session"
|
||||
const bindEditorRef = (el: HTMLDivElement) => {
|
||||
editorRef = el
|
||||
restoreEndOnFocus = true
|
||||
props.ref?.(el)
|
||||
}
|
||||
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
|
||||
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
|
||||
title: language.t("command.agent.cycle"),
|
||||
keybind: command.keybind("agent.cycle"),
|
||||
keybind: command.keybindParts("agent.cycle"),
|
||||
options: props.controls.agents.options,
|
||||
current: props.controls.agents.current,
|
||||
style: control(),
|
||||
@@ -1403,6 +1526,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setSlashActive={setSlashActive}
|
||||
onSlashSelect={handleSlashSelect}
|
||||
commandKeybind={command.keybind}
|
||||
commandKeybindParts={command.keybindParts}
|
||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<Switch>
|
||||
@@ -1456,10 +1581,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
|
||||
<div
|
||||
data-component="prompt-input"
|
||||
ref={(el) => {
|
||||
editorRef = el
|
||||
props.ref?.(el)
|
||||
}}
|
||||
ref={bindEditorRef}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={designPlaceholder()}
|
||||
@@ -1474,6 +1596,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
@@ -1494,12 +1617,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-11 items-center px-2">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-0">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1">
|
||||
{fileAttachmentInput()}
|
||||
<TooltipKeybind
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
title={language.t("prompt.action.attachFile")}
|
||||
keybind={command.keybind("file.attach")}
|
||||
value={
|
||||
<>
|
||||
{language.t("prompt.action.attachFile")}
|
||||
<KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
data-action="prompt-attach"
|
||||
@@ -1513,47 +1640,76 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
aria-label={language.t("prompt.action.attachFile")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
<Show when={showAgentControl()}>
|
||||
<ComposerAgentControl state={agentControlState()} />
|
||||
</Show>
|
||||
{props.toolbar}
|
||||
<ComposerModelControl state={modelControlState()} />
|
||||
<Show when={store.mode !== "shell" && showVariantControl()}>
|
||||
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
|
||||
<div
|
||||
data-component="prompt-variant-control"
|
||||
class="[&_[data-action=prompt-model-variant]]:![font-weight:440]"
|
||||
classList={{
|
||||
"animate-in fade-in": providersShouldFadeIn(),
|
||||
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
||||
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
||||
}}
|
||||
>
|
||||
<TooltipKeybind
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.variant.cycle")}
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
value={
|
||||
<>
|
||||
{language.t("command.model.variant.cycle")}
|
||||
<KeybindV2 keys={command.keybindParts("model.variant.cycle")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={props.controls.model.selection.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
<MenuV2
|
||||
gutter={6}
|
||||
modal={false}
|
||||
placement="top-start"
|
||||
onOpenChange={(open) => setStore("variantOpen", open)}
|
||||
onSelect={(value) => {
|
||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
|
||||
valueClass="truncate text-[13px] font-[440] leading-5 text-v2-text-text-faint"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
>
|
||||
<MenuV2.Trigger
|
||||
as={ButtonV2}
|
||||
data-action="prompt-model-variant"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="max-w-[160px] justify-start capitalize"
|
||||
style={control()}
|
||||
>
|
||||
<span class="truncate">
|
||||
{props.controls.model.selection.variant.current() ?? language.t("common.default")}
|
||||
</span>
|
||||
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.RadioGroup
|
||||
value={props.controls.model.selection.variant.current() ?? "default"}
|
||||
onChange={(value) => {
|
||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
>
|
||||
{variants().map((value) => (
|
||||
<MenuV2.RadioItem value={value} class="capitalize">
|
||||
{value === "default" ? language.t("common.default") : value}
|
||||
</MenuV2.RadioItem>
|
||||
))}
|
||||
</MenuV2.RadioGroup>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
||||
<TooltipV2 placement="top" inactive={!working() && blank()} value={tip()}>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
@@ -1568,7 +1724,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}}
|
||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</DockShellForm>
|
||||
</div>
|
||||
@@ -1628,10 +1784,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
>
|
||||
<div
|
||||
data-component="prompt-input"
|
||||
ref={(el) => {
|
||||
editorRef = el
|
||||
props.ref?.(el)
|
||||
}}
|
||||
ref={bindEditorRef}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={placeholder()}
|
||||
@@ -1646,6 +1799,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
@@ -1765,7 +1919,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={!agentsLoading()}>
|
||||
<div
|
||||
data-component="prompt-agent-control"
|
||||
style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
@@ -1794,7 +1948,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={store.mode !== "shell"}>
|
||||
<div
|
||||
data-component="prompt-model-control"
|
||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||
>
|
||||
<Show
|
||||
when={props.controls.model.paid}
|
||||
@@ -1873,7 +2027,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={showVariantControl()}>
|
||||
<div
|
||||
data-component="prompt-variant-control"
|
||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
@@ -1914,7 +2068,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
type ComposerAgentControlState = {
|
||||
title: string
|
||||
keybind: string
|
||||
keybind: string[]
|
||||
options: string[]
|
||||
current: string
|
||||
style: JSX.CSSProperties | undefined
|
||||
@@ -1923,12 +2077,14 @@ type ComposerAgentControlState = {
|
||||
|
||||
type ComposerModelControlState = {
|
||||
loading: boolean
|
||||
shouldAnimate: boolean
|
||||
paid: boolean
|
||||
title: string
|
||||
keybind: string
|
||||
keybind: string[]
|
||||
model: ReturnType<typeof useLocal>["model"]
|
||||
providerID?: string
|
||||
modelName: string
|
||||
newLayoutDesigns: boolean
|
||||
style: JSX.CSSProperties | undefined
|
||||
onClose: () => void
|
||||
onUnpaidClick: () => void
|
||||
@@ -1940,7 +2096,16 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
||||
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Icon name="sliders" size="small" />
|
||||
</div>
|
||||
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
<>
|
||||
{props.state.title}
|
||||
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={props.state.options}
|
||||
@@ -1952,7 +2117,7 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
||||
triggerProps={{ "data-action": "prompt-agent" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1963,13 +2128,23 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
<Show
|
||||
when={props.state.paid}
|
||||
fallback={
|
||||
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
<>
|
||||
{props.state.title}
|
||||
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
|
||||
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
||||
style={props.state.style}
|
||||
onClick={props.state.onUnpaidClick}
|
||||
>
|
||||
@@ -1987,39 +2162,78 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
}
|
||||
>
|
||||
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
|
||||
<ModelSelectorPopover
|
||||
model={props.state.model}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: props.state.style,
|
||||
class:
|
||||
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={props.state.onClose}
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
<>
|
||||
{props.state.title}
|
||||
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.state.newLayoutDesigns}
|
||||
fallback={
|
||||
<ModelSelectorPopover
|
||||
model={props.state.model}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: props.state.style,
|
||||
class:
|
||||
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
||||
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={props.state.onClose}
|
||||
>
|
||||
<ModelControlContent state={props.state} />
|
||||
</ModelSelectorPopover>
|
||||
}
|
||||
>
|
||||
<Show when={props.state.providerID}>
|
||||
{(providerID) => (
|
||||
<ProviderIcon
|
||||
id={providerID()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<span class="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
<ModelSelectorPopoverV2
|
||||
model={props.state.model}
|
||||
triggerAs={ButtonV2}
|
||||
triggerProps={{
|
||||
variant: "ghost-muted",
|
||||
size: "normal",
|
||||
style: props.state.style,
|
||||
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
|
||||
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={props.state.onClose}
|
||||
>
|
||||
<ModelControlContent state={props.state} v2 />
|
||||
</ModelSelectorPopoverV2>
|
||||
</Show>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelControlContent(props: { state: ComposerModelControlState; v2?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<Show when={props.state.providerID}>
|
||||
{(providerID) => (
|
||||
<ProviderIcon
|
||||
id={providerID()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,6 +100,41 @@ describe("buildRequestParts", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves reference aliases as directory file parts", () => {
|
||||
const result = buildRequestParts({
|
||||
prompt: [
|
||||
{
|
||||
type: "file",
|
||||
path: "/repo/../docs",
|
||||
content: "@docs",
|
||||
start: 0,
|
||||
end: 5,
|
||||
mime: "application/x-directory",
|
||||
filename: "docs",
|
||||
},
|
||||
],
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@docs",
|
||||
messageID: "msg_reference",
|
||||
sessionID: "ses_reference",
|
||||
sessionDirectory: "/repo/app",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
expect(filePart.mime).toBe("application/x-directory")
|
||||
expect(filePart.filename).toBe("docs")
|
||||
expect(filePart.url).toBe("file:///repo/../docs")
|
||||
expect(filePart.source?.type).toBe("file")
|
||||
if (filePart.source?.type === "file") {
|
||||
expect(filePart.source.path).toBe("/repo/../docs")
|
||||
expect(filePart.source.text.value).toBe("@docs")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("deduplicates context files when prompt already includes same path", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||
|
||||
|
||||
@@ -99,21 +99,31 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
|
||||
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
||||
const path = absolute(input.sessionDirectory, attachment.path)
|
||||
const source = attachment.source
|
||||
? {
|
||||
...attachment.source,
|
||||
text: {
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
}
|
||||
: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
path,
|
||||
}
|
||||
return {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
|
||||
filename: getFilename(attachment.path),
|
||||
source: {
|
||||
type: "file",
|
||||
text: {
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
path,
|
||||
},
|
||||
mime: attachment.mime ?? "text/plain",
|
||||
url: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
|
||||
filename: attachment.filename ?? getFilename(attachment.path),
|
||||
source,
|
||||
} satisfies PromptRequestPart
|
||||
})
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
openDelay={2000}
|
||||
openDelay={800}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { Component, For, Match, Show, Switch } from "solid-js"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
|
||||
export type AtOption =
|
||||
| { type: "agent"; name: string; display: string }
|
||||
| {
|
||||
type: "resource"
|
||||
name: string
|
||||
uri: string
|
||||
client: string
|
||||
display: string
|
||||
description?: string
|
||||
mime?: string
|
||||
}
|
||||
| { type: "reference"; name: string; path: string; display: string; description: string }
|
||||
| { type: "file"; path: string; display: string; recent?: boolean }
|
||||
|
||||
export interface SlashCommand {
|
||||
@@ -30,6 +42,8 @@ type PromptPopoverProps = {
|
||||
setSlashActive: (id: string) => void
|
||||
onSlashSelect: (item: SlashCommand) => void
|
||||
commandKeybind: (id: string) => string | undefined
|
||||
commandKeybindParts: (id: string) => string[]
|
||||
newLayoutDesigns: boolean
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
@@ -41,15 +55,29 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
if (props.popover === "slash") props.setSlashPopoverRef(el)
|
||||
}}
|
||||
class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10
|
||||
overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px]
|
||||
bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
|
||||
overflow-auto no-scrollbar flex flex-col p-2"
|
||||
classList={{
|
||||
"z-[70] rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": props.newLayoutDesigns,
|
||||
"rounded-[12px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]":
|
||||
!props.newLayoutDesigns,
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={props.popover === "at"}>
|
||||
<Show
|
||||
when={props.atFlat.length > 0}
|
||||
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
|
||||
fallback={
|
||||
<div
|
||||
class="px-2 py-1"
|
||||
classList={{
|
||||
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||
"text-text-weak": !props.newLayoutDesigns,
|
||||
}}
|
||||
>
|
||||
{props.t("prompt.popover.emptyResults")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={props.atFlat.slice(0, 10)}>
|
||||
{(item) => {
|
||||
@@ -58,13 +86,117 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
if (item.type === "agent") {
|
||||
return (
|
||||
<button
|
||||
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
|
||||
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
|
||||
class="w-full flex items-center gap-x-2 px-2 py-0.5"
|
||||
classList={{
|
||||
"rounded-[4px]": props.newLayoutDesigns,
|
||||
"rounded-md": !props.newLayoutDesigns,
|
||||
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
|
||||
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
|
||||
}}
|
||||
onClick={() => props.onAtSelect(item)}
|
||||
onMouseEnter={() => props.setAtActive(key)}
|
||||
onPointerMove={() => props.setAtActive(key)}
|
||||
>
|
||||
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
|
||||
<span
|
||||
class="whitespace-nowrap"
|
||||
classList={{
|
||||
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
|
||||
props.newLayoutDesigns,
|
||||
"text-v2-text-text-base": props.newLayoutDesigns,
|
||||
"text-14-regular": !props.newLayoutDesigns,
|
||||
"text-text-strong": !props.newLayoutDesigns,
|
||||
}}
|
||||
>
|
||||
@{item.name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.type === "resource") {
|
||||
return (
|
||||
<button
|
||||
class="w-full flex items-center gap-x-2 px-2 py-0.5"
|
||||
classList={{
|
||||
"rounded-[4px]": props.newLayoutDesigns,
|
||||
"rounded-md": !props.newLayoutDesigns,
|
||||
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
|
||||
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
|
||||
}}
|
||||
onClick={() => props.onAtSelect(item)}
|
||||
onPointerMove={() => props.setAtActive(key)}
|
||||
>
|
||||
<FileIcon node={{ path: item.uri, type: "file" }} class="shrink-0 size-4" />
|
||||
<div
|
||||
class="flex items-center min-w-0"
|
||||
classList={{
|
||||
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
|
||||
props.newLayoutDesigns,
|
||||
"text-14-regular": !props.newLayoutDesigns,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="text-text-strong whitespace-nowrap"
|
||||
classList={{ "text-v2-text-text-base": props.newLayoutDesigns }}
|
||||
>
|
||||
@{item.name}
|
||||
</span>
|
||||
<Show when={item.description}>
|
||||
{(description) => (
|
||||
<span
|
||||
class="whitespace-nowrap truncate min-w-0 ml-2"
|
||||
classList={{
|
||||
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||
"text-text-weak": !props.newLayoutDesigns,
|
||||
}}
|
||||
>
|
||||
{description()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.type === "reference") {
|
||||
return (
|
||||
<button
|
||||
class="w-full flex items-center gap-x-2 px-2 py-0.5"
|
||||
classList={{
|
||||
"rounded-[4px]": props.newLayoutDesigns,
|
||||
"rounded-md": !props.newLayoutDesigns,
|
||||
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
|
||||
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
|
||||
}}
|
||||
onClick={() => props.onAtSelect(item)}
|
||||
onPointerMove={() => props.setAtActive(key)}
|
||||
>
|
||||
<FileIcon node={{ path: item.path, type: "directory" }} class="shrink-0 size-4" />
|
||||
<div
|
||||
class="flex items-center min-w-0"
|
||||
classList={{
|
||||
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
|
||||
props.newLayoutDesigns,
|
||||
"text-14-regular": !props.newLayoutDesigns,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="text-text-strong whitespace-nowrap"
|
||||
classList={{ "text-v2-text-text-base": props.newLayoutDesigns }}
|
||||
>
|
||||
@{item.name}
|
||||
</span>
|
||||
<span
|
||||
class="whitespace-nowrap truncate min-w-0 ml-2"
|
||||
classList={{
|
||||
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||
"text-text-weak": !props.newLayoutDesigns,
|
||||
}}
|
||||
>
|
||||
{item.description}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -75,16 +207,44 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
|
||||
return (
|
||||
<button
|
||||
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
|
||||
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
|
||||
class="w-full flex items-center gap-x-2 px-2 py-0.5"
|
||||
classList={{
|
||||
"rounded-[4px]": props.newLayoutDesigns,
|
||||
"rounded-md": !props.newLayoutDesigns,
|
||||
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
|
||||
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
|
||||
}}
|
||||
onClick={() => props.onAtSelect(item)}
|
||||
onMouseEnter={() => props.setAtActive(key)}
|
||||
onPointerMove={() => props.setAtActive(key)}
|
||||
>
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular min-w-0">
|
||||
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span>
|
||||
<div
|
||||
class="flex items-center min-w-0"
|
||||
classList={{
|
||||
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
|
||||
props.newLayoutDesigns,
|
||||
"text-14-regular": !props.newLayoutDesigns,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="whitespace-nowrap truncate min-w-0"
|
||||
classList={{
|
||||
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||
"text-text-weak": !props.newLayoutDesigns,
|
||||
}}
|
||||
>
|
||||
{directory}
|
||||
</span>
|
||||
<Show when={!isDirectory}>
|
||||
<span class="text-text-strong whitespace-nowrap">{filename}</span>
|
||||
<span
|
||||
class="whitespace-nowrap"
|
||||
classList={{
|
||||
"text-v2-text-text-base": props.newLayoutDesigns,
|
||||
"text-text-strong": !props.newLayoutDesigns,
|
||||
}}
|
||||
>
|
||||
{filename}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
@@ -96,41 +256,98 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
<Match when={props.popover === "slash"}>
|
||||
<Show
|
||||
when={props.slashFlat.length > 0}
|
||||
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>}
|
||||
fallback={
|
||||
<div
|
||||
class="px-2 py-1"
|
||||
classList={{
|
||||
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||
"text-text-weak": !props.newLayoutDesigns,
|
||||
}}
|
||||
>
|
||||
{props.t("prompt.popover.emptyCommands")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={props.slashFlat}>
|
||||
{(cmd) => (
|
||||
<button
|
||||
data-slash-id={cmd.id}
|
||||
classList={{
|
||||
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
|
||||
"bg-surface-raised-base-hover": props.slashActive === cmd.id,
|
||||
}}
|
||||
onClick={() => props.onSlashSelect(cmd)}
|
||||
onMouseEnter={() => props.setSlashActive(cmd.id)}
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
|
||||
<Show when={cmd.description}>
|
||||
<span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
|
||||
<span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
|
||||
{cmd.source === "skill"
|
||||
? props.t("prompt.slash.badge.skill")
|
||||
: cmd.source === "mcp"
|
||||
? props.t("prompt.slash.badge.mcp")
|
||||
: props.t("prompt.slash.badge.custom")}
|
||||
{(cmd) => {
|
||||
const keybind = () => props.commandKeybind(cmd.id)
|
||||
const keybindParts = () => props.commandKeybindParts(cmd.id)
|
||||
return (
|
||||
<button
|
||||
data-slash-id={cmd.id}
|
||||
classList={{
|
||||
"w-full flex items-center justify-between gap-4 px-2 py-1": true,
|
||||
"rounded-[4px] scroll-my-2": props.newLayoutDesigns,
|
||||
"rounded-md": !props.newLayoutDesigns,
|
||||
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.slashActive === cmd.id,
|
||||
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.slashActive === cmd.id,
|
||||
}}
|
||||
onClick={() => props.onSlashSelect(cmd)}
|
||||
onPointerMove={() => props.setSlashActive(cmd.id)}
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="whitespace-nowrap"
|
||||
classList={{
|
||||
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
|
||||
props.newLayoutDesigns,
|
||||
"text-v2-text-text-base": props.newLayoutDesigns,
|
||||
"text-14-regular": !props.newLayoutDesigns,
|
||||
"text-text-strong": !props.newLayoutDesigns,
|
||||
}}
|
||||
>
|
||||
/{cmd.trigger}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.commandKeybind(cmd.id)}>
|
||||
<span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<Show when={cmd.description}>
|
||||
<span
|
||||
class="truncate"
|
||||
classList={{
|
||||
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
|
||||
props.newLayoutDesigns,
|
||||
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||
"text-14-regular": !props.newLayoutDesigns,
|
||||
"text-text-weak": !props.newLayoutDesigns,
|
||||
}}
|
||||
>
|
||||
{cmd.description}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
|
||||
<Show
|
||||
when={props.newLayoutDesigns}
|
||||
fallback={
|
||||
<span class="text-11-regular px-1.5 py-0.5 rounded bg-surface-base text-text-subtle">
|
||||
{cmd.source === "skill"
|
||||
? props.t("prompt.slash.badge.skill")
|
||||
: cmd.source === "mcp"
|
||||
? props.t("prompt.slash.badge.mcp")
|
||||
: props.t("prompt.slash.badge.custom")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Tag>
|
||||
{cmd.source === "skill"
|
||||
? props.t("prompt.slash.badge.skill")
|
||||
: cmd.source === "mcp"
|
||||
? props.t("prompt.slash.badge.mcp")
|
||||
: props.t("prompt.slash.badge.custom")}
|
||||
</Tag>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={props.newLayoutDesigns ? keybindParts().length > 0 : keybind()}>
|
||||
<Show
|
||||
when={props.newLayoutDesigns}
|
||||
fallback={<span class="text-12-regular text-text-subtle">{keybind()}</span>}
|
||||
>
|
||||
<KeybindV2 keys={keybindParts()} variant="neutral" />
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
@@ -69,12 +69,14 @@ export function createPromptProjectController(input: {
|
||||
if (servers().length <= 1) {
|
||||
return [...projects().map(projectKey), actionKey(servers()[0]?.key)]
|
||||
}
|
||||
return servers().flatMap((server) => [
|
||||
...projects()
|
||||
.filter((project) => project.server?.key === server!.key)
|
||||
.map(projectKey),
|
||||
actionKey(server!.key),
|
||||
])
|
||||
return [
|
||||
...servers().flatMap((server) =>
|
||||
projects()
|
||||
.filter((project) => project.server?.key === server!.key)
|
||||
.map(projectKey),
|
||||
),
|
||||
actionKey(),
|
||||
]
|
||||
}
|
||||
const initialActive = () => {
|
||||
const selectedKey = selected() ? projectKey(selected()!) : undefined
|
||||
@@ -130,7 +132,10 @@ export function createPromptProjectController(input: {
|
||||
const first = input
|
||||
.controls()
|
||||
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
|
||||
setStore({ search: value, active: first ? projectKey(first) : actionKey(servers()[0]?.key) })
|
||||
setStore({
|
||||
search: value,
|
||||
active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
|
||||
})
|
||||
},
|
||||
clearSearch() {
|
||||
setStore({ search: "", active: initialActive() })
|
||||
@@ -156,6 +161,9 @@ export function createPromptProjectController(input: {
|
||||
? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined
|
||||
: undefined
|
||||
},
|
||||
activeAction() {
|
||||
return store.active.startsWith(actionPrefix)
|
||||
},
|
||||
setSearchRef(el: HTMLInputElement) {
|
||||
searchRef = el
|
||||
},
|
||||
@@ -167,7 +175,10 @@ export function createPromptProjectController(input: {
|
||||
|
||||
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
|
||||
|
||||
export function PromptProjectSelector(props: { controller: PromptProjectController }) {
|
||||
export function PromptProjectSelector(props: {
|
||||
controller: PromptProjectController
|
||||
placement?: "bottom" | "bottom-start"
|
||||
}) {
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let restoreTrigger = true
|
||||
|
||||
@@ -201,6 +212,12 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
|
||||
selectProject(project)
|
||||
return
|
||||
}
|
||||
if (props.controller.activeAction() && props.controller.servers().length > 1) {
|
||||
const item = activeItem()
|
||||
item?.focus()
|
||||
item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }))
|
||||
return
|
||||
}
|
||||
selectAction(props.controller.activeServer())
|
||||
}
|
||||
const moveActive = (delta: number) => {
|
||||
@@ -229,9 +246,8 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={props.controller.open()}
|
||||
placement="bottom-start"
|
||||
placement={props.placement ?? "bottom"}
|
||||
gutter={4}
|
||||
shift={-6}
|
||||
modal={false}
|
||||
onOpenChange={(open) => props.controller.setOpen(open)}
|
||||
>
|
||||
@@ -318,7 +334,13 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
|
||||
</DropdownMenu.RadioGroup>
|
||||
}
|
||||
>
|
||||
<For each={props.controller.servers()}>
|
||||
<For
|
||||
each={props.controller
|
||||
.servers()
|
||||
.filter((server) =>
|
||||
props.controller.projects().some((project) => project.server?.key === server!.key),
|
||||
)}
|
||||
>
|
||||
{(server) => (
|
||||
<div>
|
||||
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
|
||||
@@ -331,22 +353,49 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
|
||||
)}
|
||||
</For>
|
||||
</DropdownMenu.RadioGroup>
|
||||
<ProjectAction server={server!.key} controller={props.controller} onSelect={selectAction} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.controller.servers().length <= 1}>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<div class="flex flex-col p-0.5">
|
||||
<ProjectAction
|
||||
server={props.controller.servers()[0]?.key}
|
||||
controller={props.controller}
|
||||
onSelect={selectAction}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<div class="flex flex-col p-0.5">
|
||||
<Show
|
||||
when={props.controller.servers().length > 1}
|
||||
fallback={
|
||||
<ProjectAction
|
||||
server={props.controller.servers()[0]?.key}
|
||||
controller={props.controller}
|
||||
onSelect={selectAction}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger
|
||||
id={props.controller.actionKey()}
|
||||
data-option-key={props.controller.actionKey()}
|
||||
class={projectActionClass}
|
||||
classList={{
|
||||
"!bg-v2-overlay-simple-overlay-hover": props.controller.active() === props.controller.actionKey(),
|
||||
}}
|
||||
onMouseEnter={() => props.controller.setActive(props.controller.actionKey())}
|
||||
>
|
||||
<Icon name="plus" size="small" />
|
||||
<span data-slot="dropdown-menu-item-label" class="min-w-0 flex-1 truncate leading-5">
|
||||
{props.controller.labels.add()}
|
||||
</span>
|
||||
<Icon name="chevron-right" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] focus:outline-none">
|
||||
<For each={props.controller.servers()}>
|
||||
{(server) => <ServerAction server={server!} onSelect={selectAction} />}
|
||||
</For>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Sub>
|
||||
</Show>
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
@@ -376,11 +425,12 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
|
||||
{...rest}
|
||||
data-action="prompt-project"
|
||||
type="button"
|
||||
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
classList={{
|
||||
...local.classList,
|
||||
"hover:bg-v2-overlay-simple-overlay-hover": !local.controller.open(),
|
||||
"bg-v2-overlay-simple-overlay-pressed": local.controller.open(),
|
||||
"text-v2-text-text-muted": local.controller.open(),
|
||||
}}
|
||||
onClick={local.onClick ?? (() => local.controller.setOpen(true))}
|
||||
onKeyDown={(event) => {
|
||||
@@ -454,6 +504,9 @@ function ProjectItem(props: {
|
||||
)
|
||||
}
|
||||
|
||||
const projectActionClass =
|
||||
"h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
|
||||
|
||||
function ProjectAction(props: {
|
||||
server?: string
|
||||
controller: PromptProjectController
|
||||
@@ -488,3 +541,11 @@ function ProjectAction(props: {
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerAction(props: { server: { key: string; name: string }; onSelect: (server: string) => void }) {
|
||||
return (
|
||||
<DropdownMenu.Item class={projectActionClass} onSelect={() => props.onSelect(props.server.key)}>
|
||||
<DropdownMenu.ItemLabel class="min-w-0 flex-1 truncate leading-5">{props.server.name}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export function PromptWorkspaceSelector(props: {
|
||||
value: string
|
||||
projectRoot: string
|
||||
workspaces: string[]
|
||||
branch?: string
|
||||
onChange: (value: string) => void
|
||||
onDone: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
let pending: string | undefined
|
||||
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
|
||||
const icon = () => {
|
||||
if (selected() === "main") return "monitor"
|
||||
if (selected() === "create") return "workspace-new"
|
||||
return "workspace"
|
||||
}
|
||||
const select = (value: string) => {
|
||||
pending = value
|
||||
}
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) return
|
||||
const value = pending
|
||||
pending = undefined
|
||||
if (value) props.onChange(value)
|
||||
props.onDone()
|
||||
}
|
||||
const label = () => {
|
||||
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
|
||||
if (props.value === "create") return language.t("workspace.new")
|
||||
return getFilename(props.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
|
||||
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{label()}</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content class="w-[180px]">
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||
<MenuV2.Item onSelect={() => select("main")}>
|
||||
<IconV2 name="monitor" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
|
||||
<Show when={selected() === "main"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => select("create")}>
|
||||
<IconV2 name="workspace-new" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
||||
<Show when={selected() === "create"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
<Show when={props.workspaces.length > 0}>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<MenuV2.SubTrigger>
|
||||
<IconV2 name="workspace" />
|
||||
{language.t("session.new.workspace.existing")}
|
||||
</MenuV2.SubTrigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.SubContent class="max-w-[200px]">
|
||||
<For each={props.workspaces}>
|
||||
{(workspace) => (
|
||||
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||
<IconV2 name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
<Show when={selected() === workspace}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
)}
|
||||
</For>
|
||||
</MenuV2.SubContent>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2.Sub>
|
||||
</Show>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<Show when={props.branch}>
|
||||
{(branch) => (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{branch()}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Match, Show, Switch, createMemo } from "solid-js"
|
||||
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
||||
import { Match, Show, Switch, createMemo, type ComponentProps, type JSX } from "solid-js"
|
||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||
import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLayout } from "@/context/layout"
|
||||
@@ -9,13 +11,23 @@ import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
||||
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
|
||||
interface SessionContextUsageProps {
|
||||
variant?: "button" | "indicator"
|
||||
placement?: TooltipProps["placement"]
|
||||
buttonAppearance?: "default" | "v2"
|
||||
placement?: ComponentProps<typeof TooltipV2>["placement"]
|
||||
}
|
||||
|
||||
function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
|
||||
return (
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
|
||||
<span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function openSessionContext(args: {
|
||||
@@ -23,7 +35,7 @@ function openSessionContext(args: {
|
||||
layout: ReturnType<typeof useLayout>
|
||||
tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||
}) {
|
||||
if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open()
|
||||
args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button")
|
||||
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
|
||||
void args.tabs.open("context")
|
||||
args.tabs.setActive("context")
|
||||
@@ -39,12 +51,14 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
|
||||
const variant = createMemo(() => props.variant ?? "button")
|
||||
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
})
|
||||
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
|
||||
const usd = createMemo(
|
||||
() =>
|
||||
@@ -54,21 +68,30 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
}),
|
||||
)
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
|
||||
const context = createMemo(() => metrics().context)
|
||||
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||
const tokens = createMemo(() => info()?.tokens)
|
||||
const cost = createMemo(() => {
|
||||
return usd().format(metrics().totalCost)
|
||||
return usd().format(info()?.cost ?? 0)
|
||||
})
|
||||
const contextVisible = createMemo(() => view().reviewPanel.opened() && tabState.activeTab() === "context")
|
||||
const hasOtherTabs = createMemo(() =>
|
||||
tabs()
|
||||
.all()
|
||||
.some((tab) => tab !== "context" && tab !== "review"),
|
||||
)
|
||||
|
||||
const openContext = () => {
|
||||
if (!params.id) return
|
||||
|
||||
if (tabState.activeTab() === "context") {
|
||||
const sessionView = view()
|
||||
if (contextVisible()) {
|
||||
tabs().close("context")
|
||||
if (sessionView.reviewPanel.source() === "context-button" && !hasOtherTabs()) sessionView.reviewPanel.close()
|
||||
return
|
||||
}
|
||||
|
||||
openSessionContext({
|
||||
view: view(),
|
||||
view: sessionView,
|
||||
layout,
|
||||
tabs: tabs(),
|
||||
})
|
||||
@@ -76,38 +99,54 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
|
||||
const circle = () => (
|
||||
<div class="flex items-center justify-center">
|
||||
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} />
|
||||
<ProgressCircle
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
percentage={context()?.usage ?? 0}
|
||||
style={
|
||||
variant() === "indicator"
|
||||
? {
|
||||
"--progress-circle-background": "var(--v2-background-bg-layer-04, var(--border-weak-base))",
|
||||
"--progress-circle-background-overlay": "var(--v2-overlay-simple-overlay-pressed, transparent)",
|
||||
"--progress-circle-progress": "var(--v2-icon-icon-base, var(--icon-base))",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
const circleV2 = () => (
|
||||
<div class="flex items-center justify-center">
|
||||
<ProgressCircleV2 percentage={context()?.usage ?? 0} />
|
||||
</div>
|
||||
)
|
||||
|
||||
const tooltipValue = () => (
|
||||
<div>
|
||||
<Show when={context()}>
|
||||
{(ctx) => (
|
||||
<>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span>
|
||||
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
|
||||
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">{cost()}</span>
|
||||
<span class="text-text-invert-base">{language.t("context.usage.cost")}</span>
|
||||
</div>
|
||||
<div class="flex w-[120px] flex-col gap-2">
|
||||
<ContextTooltipRow name={language.t("context.usage.cost")} value={cost()} />
|
||||
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
|
||||
<ContextTooltipRow
|
||||
name={language.t("context.usage.tokens")}
|
||||
value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={params.id}>
|
||||
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
|
||||
<TooltipV2 value={tooltipValue()} placement={props.placement ?? "top"} shift={-8}>
|
||||
<Switch>
|
||||
<Match when={variant() === "indicator"}>{circle()}</Match>
|
||||
<Match when={buttonAppearance() === "v2"}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={circleV2()}
|
||||
onClick={openContext}
|
||||
aria-label={language.t("context.usage.view")}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -120,7 +159,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
</Button>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tooltip>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
||||
|
||||
const assistant = (
|
||||
id: string,
|
||||
@@ -37,8 +37,8 @@ const user = (id: string) => {
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
describe("getSessionContextMetrics", () => {
|
||||
test("computes totals and usage from latest assistant with tokens", () => {
|
||||
describe("getSessionContext", () => {
|
||||
test("computes usage from latest assistant with tokens", () => {
|
||||
const messages = [
|
||||
user("u1"),
|
||||
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
|
||||
@@ -57,45 +57,52 @@ describe("getSessionContextMetrics", () => {
|
||||
},
|
||||
]
|
||||
|
||||
const metrics = getSessionContextMetrics(messages, providers)
|
||||
const ctx = getSessionContext(messages, providers)
|
||||
|
||||
expect(metrics.totalCost).toBe(1.75)
|
||||
expect(metrics.context?.message.id).toBe("a2")
|
||||
expect(metrics.context?.total).toBe(500)
|
||||
expect(metrics.context?.usage).toBe(50)
|
||||
expect(metrics.context?.providerLabel).toBe("OpenAI")
|
||||
expect(metrics.context?.modelLabel).toBe("GPT-4.1")
|
||||
expect(ctx?.message.id).toBe("a2")
|
||||
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 metrics = getSessionContextMetrics(messages, providers)
|
||||
const ctx = getSessionContext(messages, providers)
|
||||
|
||||
expect(metrics.context?.providerLabel).toBe("p-1")
|
||||
expect(metrics.context?.modelLabel).toBe("m-1")
|
||||
expect(metrics.context?.limit).toBeUndefined()
|
||||
expect(metrics.context?.usage).toBeNull()
|
||||
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 = getSessionContextMetrics(messages, providers)
|
||||
const one = getSessionContext(messages, providers)
|
||||
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
|
||||
const two = getSessionContextMetrics(messages, providers)
|
||||
const two = getSessionContext(messages, providers)
|
||||
|
||||
expect(one.context?.message.id).toBe("a1")
|
||||
expect(two.context?.message.id).toBe("a2")
|
||||
expect(two.totalCost).toBe(1)
|
||||
expect(one?.message.id).toBe("a1")
|
||||
expect(two?.message.id).toBe("a2")
|
||||
})
|
||||
|
||||
test("returns empty metrics when inputs are undefined", () => {
|
||||
const metrics = getSessionContextMetrics(undefined, undefined)
|
||||
test("returns undefined when inputs are undefined", () => {
|
||||
const ctx = getSessionContext(undefined, undefined)
|
||||
|
||||
expect(metrics.totalCost).toBe(0)
|
||||
expect(metrics.context).toBeUndefined()
|
||||
expect(ctx).toBeUndefined()
|
||||
})
|
||||
|
||||
test("computes stored session token totals", () => {
|
||||
expect(
|
||||
getSessionTokenTotal({
|
||||
input: 10,
|
||||
output: 20,
|
||||
reasoning: 30,
|
||||
cache: { read: 40, write: 50 },
|
||||
}),
|
||||
).toBe(150)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
type Provider = {
|
||||
id: string
|
||||
@@ -21,19 +21,9 @@ type Context = {
|
||||
modelLabel: string
|
||||
limit: number | undefined
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
cacheRead: number
|
||||
cacheWrite: number
|
||||
total: number
|
||||
usage: number | null
|
||||
}
|
||||
|
||||
type Metrics = {
|
||||
totalCost: number
|
||||
context: Context | undefined
|
||||
}
|
||||
|
||||
const tokenTotal = (msg: AssistantMessage) => {
|
||||
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
||||
}
|
||||
@@ -47,10 +37,9 @@ const lastAssistantWithTokens = (messages: Message[]) => {
|
||||
}
|
||||
}
|
||||
|
||||
const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => {
|
||||
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
|
||||
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
|
||||
const message = lastAssistantWithTokens(messages)
|
||||
if (!message) return { totalCost, context: undefined }
|
||||
if (!message) return undefined
|
||||
|
||||
const provider = providers.find((item) => item.id === message.providerID)
|
||||
const model = provider?.models[message.modelID]
|
||||
@@ -58,25 +47,22 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics =>
|
||||
const total = tokenTotal(message)
|
||||
|
||||
return {
|
||||
totalCost,
|
||||
context: {
|
||||
message,
|
||||
provider,
|
||||
model,
|
||||
providerLabel: provider?.name ?? message.providerID,
|
||||
modelLabel: model?.name ?? message.modelID,
|
||||
limit,
|
||||
input: message.tokens.input,
|
||||
output: message.tokens.output,
|
||||
reasoning: message.tokens.reasoning,
|
||||
cacheRead: message.tokens.cache.read,
|
||||
cacheWrite: message.tokens.cache.write,
|
||||
total,
|
||||
usage: limit ? Math.round((total / limit) * 100) : null,
|
||||
},
|
||||
message,
|
||||
provider,
|
||||
model,
|
||||
providerLabel: provider?.name ?? message.providerID,
|
||||
modelLabel: model?.name ?? message.modelID,
|
||||
limit,
|
||||
input: message.tokens.input,
|
||||
usage: limit ? Math.round((total / limit) * 100) : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) {
|
||||
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
|
||||
return build(messages, providers)
|
||||
}
|
||||
|
||||
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
|
||||
if (!tokens) return undefined
|
||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||
import { createSessionContextFormatter } from "./session-context-format"
|
||||
|
||||
@@ -134,12 +134,12 @@ export function SessionContextTab() {
|
||||
}),
|
||||
)
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
|
||||
const ctx = createMemo(() => metrics().context)
|
||||
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||
const tokens = createMemo(() => info()?.tokens)
|
||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||
|
||||
const cost = createMemo(() => {
|
||||
return usd().format(metrics().totalCost)
|
||||
return usd().format(info()?.cost ?? 0)
|
||||
})
|
||||
|
||||
const counts = createMemo(() => {
|
||||
@@ -204,14 +204,14 @@ export function SessionContextTab() {
|
||||
{ label: "context.stats.provider", value: providerLabel },
|
||||
{ label: "context.stats.model", value: modelLabel },
|
||||
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
||||
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
|
||||
{ label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
|
||||
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
|
||||
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) },
|
||||
{ label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
|
||||
{
|
||||
label: "context.stats.cacheTokens",
|
||||
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
|
||||
value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
|
||||
},
|
||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
||||
|
||||
@@ -541,6 +541,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||
</Show>
|
||||
<Show when={props.state.reviewVisible}>
|
||||
<TooltipV2
|
||||
class="shrink-0"
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
|
||||
@@ -226,7 +226,7 @@ const SettingsProvidersContent: Component = () => {
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</div>
|
||||
|
||||
@@ -223,7 +223,7 @@ export const SettingsProvidersV2: Component = () => {
|
||||
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
||||
<div class="settings-v2-provider-lead">
|
||||
<ProviderIcon
|
||||
id="synthetic"
|
||||
id="session.synthetic"
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-provider-icon shrink-0"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-slot]:not(:first-child)::before {
|
||||
[data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
@@ -32,6 +32,10 @@
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
[data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] [data-slot="tab-close"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
|
||||
@@ -5,19 +5,24 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { projectForSession } from "@/pages/layout/helpers"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
||||
import "./titlebar-tab-nav.css"
|
||||
|
||||
// MouseEvent.button uses 1 for the middle/wheel button.
|
||||
const MIDDLE_MOUSE_BUTTON = 1
|
||||
|
||||
export function TabNavItem(props: {
|
||||
ref?: Ref<HTMLDivElement>
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
session: () => Session | undefined
|
||||
fallbackTitle?: string
|
||||
onTitleChange?: (title: string) => void
|
||||
onTitleChangeFailed?: (title: string) => void
|
||||
onClose: () => void
|
||||
@@ -53,6 +58,33 @@ export function TabNavItem(props: {
|
||||
if (!session) return
|
||||
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||
})
|
||||
const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
|
||||
|
||||
const projectName = createMemo(() => {
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
return displayName(project() ?? { worktree: session.directory })
|
||||
})
|
||||
const previewPath = createMemo(() => {
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
const home = serverCtx()?.sync.data.path.home
|
||||
return home ? session.directory.replace(home, "~") : session.directory
|
||||
})
|
||||
const branch = createMemo(() => {
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
return serverCtx()?.sync.child(session.directory, { bootstrap: false })[0].vcs?.branch
|
||||
})
|
||||
// Only label the server when multiple servers are connected.
|
||||
const serverLabel = createMemo(() => {
|
||||
if (global.servers.list().length <= 1) return
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
||||
return conn ? serverName(conn) : undefined
|
||||
})
|
||||
|
||||
const [popoverOpen, setPopoverOpen] = createSignal(false)
|
||||
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session()
|
||||
|
||||
const measureTitleOverflow = () => {
|
||||
if (!titleEl || editing()) {
|
||||
@@ -71,7 +103,7 @@ export function TabNavItem(props: {
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
props.session()?.title
|
||||
title()
|
||||
props.forceTruncate
|
||||
editing()
|
||||
scheduleTitleOverflow()
|
||||
@@ -130,9 +162,9 @@ export function TabNavItem(props: {
|
||||
createEffect(() => {
|
||||
if (editing()) return
|
||||
if (!titleEl) return
|
||||
const title = props.session()?.title
|
||||
if (title === undefined) return
|
||||
titleEl.textContent = title
|
||||
const value = title()
|
||||
if (value === undefined) return
|
||||
titleEl.textContent = value
|
||||
})
|
||||
|
||||
const openRename = (event: MouseEvent) => {
|
||||
@@ -168,7 +200,7 @@ export function TabNavItem(props: {
|
||||
onCleanup(cleanup)
|
||||
})
|
||||
|
||||
return (
|
||||
const tab = (
|
||||
<div
|
||||
ref={(el) => {
|
||||
tabRoot = el
|
||||
@@ -184,30 +216,35 @@ export function TabNavItem(props: {
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onAuxClick={(event) => {
|
||||
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<Show when={props.session()}>
|
||||
{(session) => {
|
||||
return (
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<Show when={title() !== undefined}>
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<Show when={props.session()}>
|
||||
{(session) => (
|
||||
<span data-slot="project-avatar-slot">
|
||||
<SessionTabAvatar
|
||||
project={project()}
|
||||
@@ -216,45 +253,45 @@ export function TabNavItem(props: {
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
ref={(el) => {
|
||||
titleEl = el
|
||||
titleEl.textContent = session().title
|
||||
}}
|
||||
data-slot="tab-title"
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 outline-none leading-4"
|
||||
classList={{
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
"select-text": editing(),
|
||||
}}
|
||||
contenteditable={editing() ? true : undefined}
|
||||
onDblClick={openRename}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void closeRename(true)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
titleEl.textContent = session().title
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
onPointerDown={(event) => {
|
||||
if (!editing()) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!editing()) return
|
||||
event.preventDefault()
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}}
|
||||
)}
|
||||
</Show>
|
||||
<span
|
||||
ref={(el) => {
|
||||
titleEl = el
|
||||
titleEl.textContent = title() ?? ""
|
||||
}}
|
||||
data-slot="tab-title"
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 outline-none leading-4"
|
||||
classList={{
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
"select-text": editing(),
|
||||
}}
|
||||
contenteditable={editing() ? true : undefined}
|
||||
onDblClick={openRename}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void closeRename(true)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
titleEl.textContent = props.session()?.title ?? ""
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
onPointerDown={(event) => {
|
||||
if (!editing()) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!editing()) return
|
||||
event.preventDefault()
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
</Show>
|
||||
|
||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||
@@ -272,6 +309,24 @@ export function TabNavItem(props: {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<TabPreviewPopover
|
||||
trigger={tab}
|
||||
open={popoverOpen() && !previewBlocked()}
|
||||
onOpenChange={(value) => {
|
||||
if (value && previewBlocked()) return
|
||||
setPopoverOpen(value)
|
||||
}}
|
||||
data={{
|
||||
projectName: projectName(),
|
||||
title: props.session()?.title,
|
||||
path: previewPath(),
|
||||
branch: branch(),
|
||||
serverName: serverLabel(),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DraftTabItem(props: {
|
||||
@@ -302,7 +357,12 @@ export function DraftTabItem(props: {
|
||||
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
classList={{ invisible: props.hidden }}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onAuxClick={(event) => {
|
||||
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
[data-component="session-tab-popover-trigger"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="session-tab-popover"] {
|
||||
z-index: 50;
|
||||
box-sizing: border-box;
|
||||
width: 256px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
|
||||
background: var(--v2-background-bg-base);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--v2-elevation-floating);
|
||||
|
||||
color: var(--v2-text-text-base);
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
transform-origin: var(--kb-hovercard-content-transform-origin);
|
||||
/* Entrance: fade in while scaling up from 0.96 to 1.0. */
|
||||
animation: sessionTabPopoverIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
&:focus-visible,
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Exit: subtle fade + scale down, using the same ease-out curve as entry. */
|
||||
&[data-closed] {
|
||||
animation: sessionTabPopoverOut 100ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
[data-slot="header"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="project"] {
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="title"] {
|
||||
font-weight: 530;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-slot="row"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="icon"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
|
||||
[data-slot="detail"] {
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="server"] {
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sessionTabPopoverIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sessionTabPopoverOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="session-tab-popover"],
|
||||
[data-component="session-tab-popover"][data-closed] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { HoverCard as Kobalte } from "@kobalte/core/hover-card"
|
||||
import { Show, type JSXElement } from "solid-js"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import "./titlebar-tab-popover.css"
|
||||
|
||||
// Initial hover delay before the preview appears, per design.
|
||||
const OPEN_DELAY = 200
|
||||
// Mouse-out delay: begin closing immediately (a brief exit animation plays).
|
||||
const CLOSE_DELAY = 0
|
||||
// After a preview closes, hovering a neighbouring tab within this window skips
|
||||
// the open delay — mirrors the tooltip's skipDelayDuration so moving across
|
||||
// tabs doesn't re-wait the full delay each time.
|
||||
const SKIP_WINDOW = 300
|
||||
let lastClosedAt = 0
|
||||
|
||||
export interface TabPreviewData {
|
||||
projectName?: string
|
||||
title?: string
|
||||
path?: string
|
||||
branch?: string
|
||||
serverName?: string
|
||||
}
|
||||
|
||||
export function TabPreviewPopover(props: {
|
||||
trigger: JSXElement
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
data: TabPreviewData
|
||||
}) {
|
||||
let triggerEl: HTMLDivElement | undefined
|
||||
|
||||
// Kobalte reads openDelay lazily when the pointer enters the trigger, so this
|
||||
// resolves the skip window per-hover.
|
||||
const resolveOpenDelay = () => (Date.now() - lastClosedAt < SKIP_WINDOW ? 0 : OPEN_DELAY)
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) lastClosedAt = Date.now()
|
||||
props.onOpenChange(open)
|
||||
}
|
||||
|
||||
return (
|
||||
<Kobalte
|
||||
open={props.open}
|
||||
onOpenChange={handleOpenChange}
|
||||
openDelay={resolveOpenDelay()}
|
||||
closeDelay={CLOSE_DELAY}
|
||||
// The preview is non-interactive (pointer-events: none), so there is no
|
||||
// safe area to traverse — leaving the tab hides it immediately.
|
||||
ignoreSafeArea
|
||||
placement="bottom-start"
|
||||
gutter={6}
|
||||
>
|
||||
<Kobalte.Trigger ref={triggerEl} as="div" data-component="session-tab-popover-trigger" tabIndex={-1}>
|
||||
{props.trigger}
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Content
|
||||
ref={(el) => {
|
||||
// Portalled content lives outside the themed subtree, so mirror the
|
||||
// active theme like the v2 tooltip does.
|
||||
const theme = triggerEl?.closest("[data-theme]")?.getAttribute("data-theme")
|
||||
if (theme) el.setAttribute("data-theme", theme)
|
||||
}}
|
||||
data-component="session-tab-popover"
|
||||
>
|
||||
<div data-slot="header">
|
||||
<Show when={props.data.projectName}>
|
||||
<span data-slot="project">{props.data.projectName}</span>
|
||||
</Show>
|
||||
<Show when={props.data.title}>
|
||||
<span data-slot="title">{props.data.title}</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={props.data.path}>
|
||||
<div data-slot="row">
|
||||
<span data-slot="icon">
|
||||
<IconV2 name="folder" />
|
||||
</span>
|
||||
<span data-slot="detail">{props.data.path}</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.data.branch}>
|
||||
<div data-slot="row">
|
||||
<span data-slot="icon">
|
||||
<IconV2 name="branch" />
|
||||
</span>
|
||||
<span data-slot="detail">{props.data.branch}</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.data.serverName}>
|
||||
<div data-slot="server">{props.data.serverName}</div>
|
||||
</Show>
|
||||
</Kobalte.Content>
|
||||
</Kobalte.Portal>
|
||||
</Kobalte>
|
||||
)
|
||||
}
|
||||
@@ -31,6 +31,7 @@ function SessionTabSlot(props: {
|
||||
onClose: () => void
|
||||
}) {
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.id
|
||||
@@ -50,6 +51,7 @@ function SessionTabSlot(props: {
|
||||
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
||||
)
|
||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
|
||||
let prefetched = false
|
||||
|
||||
createEffect(() => {
|
||||
@@ -85,8 +87,9 @@ function SessionTabSlot(props: {
|
||||
ref={sortable.ref}
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.id}
|
||||
data-active={props.active()}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
classList={{ hidden: !session() }}
|
||||
classList={{ hidden: !session() && !missingSession() }}
|
||||
>
|
||||
<TabNavItem
|
||||
ref={(el) => {
|
||||
@@ -95,6 +98,7 @@ function SessionTabSlot(props: {
|
||||
href={tabHref(props.tab)}
|
||||
server={props.tab.server}
|
||||
session={session}
|
||||
fallbackTitle={missingSession() ? language.t("session.tab.unknown") : undefined}
|
||||
onTitleChange={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
@@ -140,6 +144,7 @@ function DraftTabSlot(props: {
|
||||
ref={sortable.ref}
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.id}
|
||||
data-active={props.active()}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
>
|
||||
<DraftTabItem
|
||||
|
||||
@@ -99,6 +99,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
|
||||
const path = () => `${location.pathname}${location.search}${location.hash}`
|
||||
const creating = createMemo(() => {
|
||||
const route = layout.route()
|
||||
if (route.type === "draft" || route.type === "dir-new-sesssion") return true
|
||||
if (!params.dir) return false
|
||||
if (params.id) return false
|
||||
const parts = location.pathname.replace(/\/+$/, "").split("/")
|
||||
@@ -227,7 +229,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
}}
|
||||
style={{
|
||||
"min-height": minHeight(),
|
||||
"padding-left": mac() && !mobile() ? `${84 / zoom()}px` : 0,
|
||||
// Keep native macOS traffic lights clear even when the desktop window is narrow.
|
||||
"padding-left": mac() ? `${84 / zoom()}px` : 0,
|
||||
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||
"max-width": electronWindows()
|
||||
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
|
||||
@@ -466,7 +469,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
<Show when={!(creating() && params.dir)}>
|
||||
<Show when={!creating()}>
|
||||
<TooltipV2
|
||||
placement="bottom"
|
||||
value={
|
||||
@@ -569,7 +572,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
placement="bottom"
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
openDelay={2000}
|
||||
openDelay={800}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -599,7 +602,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
>
|
||||
<Show when={hasProjects() && nav()}>
|
||||
<div class="flex items-center gap-0 transition-transform">
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={800}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-left"
|
||||
@@ -609,7 +612,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={800}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-right"
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
|
||||
const refresh: string[] = []
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/new.ts",
|
||||
event: "add",
|
||||
@@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/open.ts",
|
||||
event: "change",
|
||||
@@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src",
|
||||
event: "change",
|
||||
@@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/file.ts",
|
||||
event: "change",
|
||||
@@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: ".git/index.lock",
|
||||
event: "change",
|
||||
|
||||
@@ -16,7 +16,7 @@ type WatcherOps = {
|
||||
}
|
||||
|
||||
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||
if (event.type !== "file.watcher.updated") return
|
||||
if (event.type !== "filesystem.changed") return
|
||||
const props =
|
||||
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
||||
const rawPath = typeof props?.file === "string" ? props.file : undefined
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("bootstrapDirectory", () => {
|
||||
status: "loading",
|
||||
agent: [],
|
||||
command: [],
|
||||
reference: [],
|
||||
project: "",
|
||||
projectMeta: undefined,
|
||||
icon: undefined,
|
||||
@@ -35,6 +36,7 @@ describe("bootstrapDirectory", () => {
|
||||
question: {},
|
||||
mcp_ready: true,
|
||||
mcp: {},
|
||||
mcp_resource: {},
|
||||
lsp_ready: true,
|
||||
lsp: [],
|
||||
vcs: undefined,
|
||||
@@ -67,6 +69,7 @@ describe("bootstrapDirectory", () => {
|
||||
},
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
||||
mcp: {
|
||||
status: async () => {
|
||||
mcpReads.push("status")
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
QuestionRequest,
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@/utils/toast"
|
||||
@@ -18,7 +19,7 @@ import type { ServerSession } from "../server-session"
|
||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||
import { loadMcpQuery } from "../server-sync"
|
||||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
|
||||
@@ -195,6 +196,13 @@ export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk:
|
||||
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
|
||||
})
|
||||
|
||||
export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||
queryOptions<ReferenceInfo[]>({
|
||||
queryKey: [scope, directory, "references"] as const,
|
||||
queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []),
|
||||
placeholderData: [],
|
||||
})
|
||||
|
||||
export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
scope: ServerScope
|
||||
@@ -277,6 +285,7 @@ export async function bootstrapDirectory(input: {
|
||||
}),
|
||||
),
|
||||
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
|
||||
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.permission.list().then((x) => {
|
||||
@@ -339,6 +348,7 @@ export async function bootstrapDirectory(input: {
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
|
||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
|
||||
@@ -34,7 +34,9 @@ const queryOptionsApi = {
|
||||
}),
|
||||
agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }),
|
||||
mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }),
|
||||
mcpResources: (directory: string) => ({ queryKey: [directory, "mcpResources"], queryFn: async () => ({}) }),
|
||||
lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }),
|
||||
references: (directory: string) => ({ queryKey: [directory, "references"], queryFn: async () => [] }),
|
||||
sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }),
|
||||
} as unknown as QueryOptionsApi
|
||||
|
||||
@@ -197,14 +199,18 @@ describe("createChildStoreManager", () => {
|
||||
try {
|
||||
if (!manager) throw new Error("manager required")
|
||||
const [store, setStore] = manager.child("/project", { bootstrap: false })
|
||||
expect(querySingles.length - offset).toBe(4)
|
||||
expect(querySingles.length - offset).toBe(6)
|
||||
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")
|
||||
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"])
|
||||
|
||||
|
||||
@@ -185,8 +185,10 @@ export function createChildStoreManager(input: {
|
||||
|
||||
const pathQuery = useQuery(() => input.queryOptions.path(key))
|
||||
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
||||
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
|
||||
const lspQuery = useQuery(() => input.queryOptions.lsp(key))
|
||||
const providerQuery = useQuery(() => input.queryOptions.providers(key))
|
||||
const referenceQuery = useQuery(() => input.queryOptions.references(key))
|
||||
|
||||
const child = createStore<State>({
|
||||
project: "",
|
||||
@@ -210,6 +212,9 @@ export function createChildStoreManager(input: {
|
||||
status: "loading" as const,
|
||||
agent: [],
|
||||
command: [],
|
||||
get reference() {
|
||||
return referenceQuery.isLoading ? [] : (referenceQuery.data ?? [])
|
||||
},
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
@@ -227,6 +232,9 @@ export function createChildStoreManager(input: {
|
||||
get mcp() {
|
||||
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
|
||||
},
|
||||
get mcp_resource() {
|
||||
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
|
||||
},
|
||||
get lsp_ready() {
|
||||
return !lspQuery.isLoading
|
||||
},
|
||||
|
||||
@@ -112,6 +112,7 @@ export function applyDirectoryEvent(input: {
|
||||
push: (directory: string) => void
|
||||
directory: string
|
||||
loadLsp: () => void
|
||||
loadReferences?: () => void
|
||||
vcsCache?: VcsCache
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
|
||||
retainedLimit?: number
|
||||
@@ -404,5 +405,9 @@ export function applyDirectoryEvent(input: {
|
||||
input.loadLsp()
|
||||
break
|
||||
}
|
||||
case "reference.updated": {
|
||||
input.loadReferences?.()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ import type {
|
||||
Command,
|
||||
Config,
|
||||
LspStatus,
|
||||
McpResource,
|
||||
McpStatus,
|
||||
Message,
|
||||
Part,
|
||||
Path,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
@@ -34,6 +36,7 @@ export type State = {
|
||||
status: "loading" | "partial" | "complete"
|
||||
agent: Agent[]
|
||||
command: Command[]
|
||||
reference: ReferenceInfo[]
|
||||
project: string
|
||||
projectMeta: ProjectMeta | undefined
|
||||
icon: string | undefined
|
||||
@@ -63,6 +66,9 @@ export type State = {
|
||||
mcp: {
|
||||
[name: string]: McpStatus
|
||||
}
|
||||
mcp_resource: {
|
||||
[key: string]: McpResource
|
||||
}
|
||||
lsp_ready: boolean
|
||||
lsp: LspStatus[]
|
||||
vcs: VcsInfo | undefined
|
||||
|
||||
@@ -28,6 +28,7 @@ const DEFAULT_SIDEBAR_WIDTH = 344
|
||||
const DEFAULT_FILE_TREE_WIDTH = 200
|
||||
const DEFAULT_SESSION_WIDTH = 600
|
||||
const DEFAULT_TERMINAL_HEIGHT = 280
|
||||
const DEFAULT_REVIEW_PANEL_OPENED = false
|
||||
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
||||
|
||||
export function getAvatarColors(key?: string) {
|
||||
@@ -77,6 +78,7 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
|
||||
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
|
||||
|
||||
export type ReviewDiffStyle = "unified" | "split"
|
||||
export type ReviewPanelSource = "context-button" | "other"
|
||||
|
||||
export type LayoutRoute =
|
||||
| { type: "home" }
|
||||
@@ -210,7 +212,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
if (!isRecord(review)) return review
|
||||
if (typeof review.panelOpened === "boolean") return review
|
||||
|
||||
const opened = isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : true
|
||||
const opened =
|
||||
isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : DEFAULT_REVIEW_PANEL_OPENED
|
||||
return {
|
||||
...review,
|
||||
panelOpened: opened,
|
||||
@@ -279,7 +282,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
review: {
|
||||
diffStyle: "split" as ReviewDiffStyle,
|
||||
panelOpened: true,
|
||||
panelOpened: DEFAULT_REVIEW_PANEL_OPENED,
|
||||
},
|
||||
fileTree: {
|
||||
opened: false,
|
||||
@@ -302,6 +305,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
}),
|
||||
)
|
||||
const [ephemeral, setEphemeral] = createStore({
|
||||
reviewPanelSource: "other" as ReviewPanelSource,
|
||||
})
|
||||
|
||||
const MAX_SESSION_KEYS = 50
|
||||
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
|
||||
@@ -662,7 +668,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
|
||||
setDiffStyle(diffStyle: ReviewDiffStyle) {
|
||||
if (!store.review) {
|
||||
setStore("review", { diffStyle, panelOpened: true })
|
||||
setStore("review", { diffStyle, panelOpened: DEFAULT_REVIEW_PANEL_OPENED })
|
||||
return
|
||||
}
|
||||
setStore("review", "diffStyle", diffStyle)
|
||||
@@ -777,7 +783,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const key = createSessionKeyReader(sessionKey, ensureKey)
|
||||
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
|
||||
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
|
||||
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
|
||||
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
|
||||
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
|
||||
|
||||
function setTerminalOpened(next: boolean) {
|
||||
const current = store.terminal
|
||||
@@ -791,16 +798,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
setStore("terminal", "opened", next)
|
||||
}
|
||||
|
||||
function setReviewPanelOpened(next: boolean) {
|
||||
function setReviewPanelOpened(next: boolean, source: ReviewPanelSource) {
|
||||
const nextSource = next ? source : "other"
|
||||
const current = store.review
|
||||
if (!current) {
|
||||
setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
|
||||
batch(() => {
|
||||
setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
|
||||
setEphemeral("reviewPanelSource", nextSource)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const value = current.panelOpened ?? true
|
||||
if (value === next) return
|
||||
setStore("review", "panelOpened", next)
|
||||
const value = current.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED
|
||||
if (value === next) {
|
||||
if (ephemeral.reviewPanelSource !== nextSource) setEphemeral("reviewPanelSource", nextSource)
|
||||
return
|
||||
}
|
||||
batch(() => {
|
||||
setStore("review", "panelOpened", next)
|
||||
setEphemeral("reviewPanelSource", nextSource)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -836,14 +853,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
reviewPanel: {
|
||||
opened: reviewPanelOpened,
|
||||
open() {
|
||||
setReviewPanelOpened(true)
|
||||
source: reviewPanelSource,
|
||||
open(source: ReviewPanelSource = "other") {
|
||||
setReviewPanelOpened(true, source)
|
||||
},
|
||||
close() {
|
||||
setReviewPanelOpened(false)
|
||||
setReviewPanelOpened(false, "other")
|
||||
},
|
||||
toggle() {
|
||||
setReviewPanelOpened(!reviewPanelOpened())
|
||||
setReviewPanelOpened(!reviewPanelOpened(), "other")
|
||||
},
|
||||
},
|
||||
review: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { batch, createEffect, createMemo } from "solid-js"
|
||||
import { batch, createEffect, createMemo, startTransition } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useModels } from "@/context/models"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
@@ -80,6 +80,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const [store, setStore] = createStore<{
|
||||
current?: string
|
||||
draft?: State
|
||||
promoting?: State
|
||||
last?: {
|
||||
type: "agent" | "model" | "variant"
|
||||
agent?: string
|
||||
@@ -123,7 +124,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const scope = createMemo<State | undefined>(() => {
|
||||
const session = id()
|
||||
if (!session) return store.draft
|
||||
if (!session) return store.draft ?? store.promoting
|
||||
return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
|
||||
})
|
||||
|
||||
@@ -136,11 +137,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
if (!next) return
|
||||
if (saved.session[session] !== undefined) {
|
||||
handoff.delete(key)
|
||||
setStore("promoting", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
setSaved("session", session, clone(next))
|
||||
handoff.delete(key)
|
||||
setStore("promoting", undefined)
|
||||
})
|
||||
|
||||
const configuredModel = () => {
|
||||
@@ -294,19 +297,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
model.set({ providerID: entry.provider.id, modelID: entry.id })
|
||||
},
|
||||
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
setStore("last", {
|
||||
type: "model",
|
||||
agent: agent.current()?.name,
|
||||
model: item ?? null,
|
||||
variant: selected(),
|
||||
})
|
||||
write({ model: item })
|
||||
if (!item) return
|
||||
models.setVisibility(item, true)
|
||||
if (!options?.recent) return
|
||||
models.recent.push(item)
|
||||
})
|
||||
startTransition(() =>
|
||||
batch(() => {
|
||||
setStore("last", {
|
||||
type: "model",
|
||||
agent: agent.current()?.name,
|
||||
model: item ?? null,
|
||||
variant: selected(),
|
||||
})
|
||||
write({ model: item })
|
||||
if (!item) return
|
||||
models.setVisibility(item, true)
|
||||
if (!options?.recent) return
|
||||
models.recent.push(item)
|
||||
}),
|
||||
)
|
||||
},
|
||||
visible(item: ModelKey) {
|
||||
return models.visible(item)
|
||||
@@ -335,19 +340,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return Object.keys(item.variants)
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
batch(() => {
|
||||
const model = current()
|
||||
setStore("last", {
|
||||
type: "variant",
|
||||
agent: agent.current()?.name,
|
||||
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
|
||||
variant: value ?? null,
|
||||
})
|
||||
write({ variant: value ?? null })
|
||||
if (model) {
|
||||
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
|
||||
}
|
||||
})
|
||||
startTransition(() =>
|
||||
batch(() => {
|
||||
const model = current()
|
||||
setStore("last", {
|
||||
type: "variant",
|
||||
agent: agent.current()?.name,
|
||||
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
|
||||
variant: value ?? null,
|
||||
})
|
||||
write({ variant: value ?? null })
|
||||
if (model) {
|
||||
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
cycle() {
|
||||
const items = this.list()
|
||||
@@ -369,19 +376,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
agent,
|
||||
session: {
|
||||
reset() {
|
||||
setStore("draft", undefined)
|
||||
setStore({ draft: undefined, promoting: undefined })
|
||||
},
|
||||
promote(dir: string, session: string) {
|
||||
const next = clone(snapshot())
|
||||
if (!next) return
|
||||
const key = handoffKey(serverSDK().scope, dir, session)
|
||||
handoff.set(key, next)
|
||||
|
||||
if (dir === sdk().directory) {
|
||||
setSaved("session", session, next)
|
||||
setStore("draft", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
handoff.set(handoffKey(serverSDK().scope, dir, session), next)
|
||||
setStore("promoting", next)
|
||||
setStore("draft", undefined)
|
||||
},
|
||||
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import type { ServerSDK } from "./server-sdk"
|
||||
import type { ServerSync } from "./server-sync"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
@@ -12,6 +12,11 @@ import { decode64 } from "@/utils/base64"
|
||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { playSoundById } from "@/utils/sound"
|
||||
import { useGlobal } from "./global"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { type DraftTab, useTabs } from "./tabs"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
type NotificationBase = {
|
||||
directory?: string
|
||||
@@ -107,267 +112,360 @@ function buildNotificationIndex(list: Notification[]) {
|
||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||
name: "Notification",
|
||||
gate: false,
|
||||
init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
|
||||
const params = useParams()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
init: () => {
|
||||
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const global = useGlobal()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
const owner = getOwner()
|
||||
const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
|
||||
|
||||
const empty: Notification[] = []
|
||||
|
||||
const currentDirectory = createMemo(() => {
|
||||
return props.directory?.() ?? decode64(params.dir)
|
||||
const activeServer = createMemo(() => {
|
||||
if (params.serverKey) return requireServerKey(params.serverKey)
|
||||
if (search.draftId) {
|
||||
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||
if (draft) return draft.server
|
||||
}
|
||||
return server.key
|
||||
})
|
||||
const activeDirectory = createMemo(() => decode64(params.dir))
|
||||
const activeSession = createMemo(() => params.id)
|
||||
|
||||
const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
||||
createStore({
|
||||
list: [] as Notification[],
|
||||
}),
|
||||
)
|
||||
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
|
||||
|
||||
const meta = { pruned: false, disposed: false }
|
||||
|
||||
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
|
||||
setIndex(scope, "unseen", key, unseen)
|
||||
setIndex(scope, "unseenCount", key, unseen.length)
|
||||
setIndex(
|
||||
scope,
|
||||
"unseenHasError",
|
||||
key,
|
||||
unseen.some((notification) => notification.type === "error"),
|
||||
const ensure = (key: ServerConnection.Key) => {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||
if (!conn) throw new Error(`Notification server not found: ${key}`)
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
const existing = states.get(ctx.sdk.scope)
|
||||
if (existing) return existing.state
|
||||
const root = createRoot(
|
||||
(dispose) => ({
|
||||
dispose,
|
||||
state: createServerNotificationState({
|
||||
sdk: ctx.sdk,
|
||||
sync: ctx.sync,
|
||||
active: () => server.scope(activeServer()) === ctx.sdk.scope,
|
||||
directory: activeDirectory,
|
||||
sessionID: activeSession,
|
||||
platform,
|
||||
settings,
|
||||
language,
|
||||
}),
|
||||
}),
|
||||
owner ?? undefined,
|
||||
)
|
||||
}
|
||||
|
||||
const appendToIndex = (notification: Notification) => {
|
||||
if (notification.session) {
|
||||
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
|
||||
if (!notification.viewed) {
|
||||
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
|
||||
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
|
||||
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.directory) {
|
||||
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
|
||||
if (!notification.viewed) {
|
||||
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
|
||||
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
|
||||
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeFromIndex = (notification: Notification) => {
|
||||
if (notification.session) {
|
||||
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
|
||||
if (!notification.viewed) {
|
||||
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
|
||||
updateUnseen("session", notification.session, unseen)
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.directory) {
|
||||
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
|
||||
if (!notification.viewed) {
|
||||
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
|
||||
updateUnseen("project", notification.directory, unseen)
|
||||
}
|
||||
}
|
||||
states.set(ctx.sdk.scope, root)
|
||||
return root.state
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (meta.pruned) return
|
||||
meta.pruned = true
|
||||
const list = pruneNotifications(store.list)
|
||||
batch(() => {
|
||||
setStore("list", list)
|
||||
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
|
||||
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn))))
|
||||
states.forEach((value, scope) => {
|
||||
if (scopes.has(scope)) return
|
||||
value.dispose()
|
||||
states.delete(scope)
|
||||
})
|
||||
})
|
||||
|
||||
const append = (notification: Notification) => {
|
||||
const list = pruneNotifications([...store.list, notification])
|
||||
const keep = new Set(list)
|
||||
const removed = store.list.filter((n) => !keep.has(n))
|
||||
onCleanup(() => states.forEach((value) => value.dispose()))
|
||||
|
||||
batch(() => {
|
||||
if (keep.has(notification)) appendToIndex(notification)
|
||||
removed.forEach((n) => removeFromIndex(n))
|
||||
setStore("list", list)
|
||||
})
|
||||
}
|
||||
|
||||
const lookup = async (directory: string, sessionID?: string) => {
|
||||
if (!sessionID) return undefined
|
||||
const sync = serverSync().ensureDirSyncContext(directory)
|
||||
const session = sync.session.get(sessionID)
|
||||
if (session) return session
|
||||
return sync.session
|
||||
.sync(sessionID)
|
||||
.then(() => sync.session.get(sessionID))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
|
||||
const activeDirectory = currentDirectory()
|
||||
const activeSession = currentSession()
|
||||
if (!activeDirectory) return false
|
||||
if (!activeSession) return false
|
||||
if (!sessionID) return false
|
||||
if (directory !== activeDirectory) return false
|
||||
return sessionID === activeSession
|
||||
}
|
||||
|
||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (!session) return
|
||||
if (session.parentID) return
|
||||
|
||||
if (settings.sounds.agentEnabled()) {
|
||||
void playSoundById(settings.sounds.agent())
|
||||
}
|
||||
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewedInCurrentSession(directory, sessionID),
|
||||
type: "turn-complete",
|
||||
session: sessionID,
|
||||
})
|
||||
|
||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSessionError = (
|
||||
directory: string,
|
||||
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
|
||||
time: number,
|
||||
) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (session?.parentID) return
|
||||
|
||||
if (settings.sounds.errorsEnabled()) {
|
||||
void playSoundById(settings.sounds.errors())
|
||||
}
|
||||
|
||||
const error = "error" in event.properties ? event.properties.error : undefined
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewedInCurrentSession(directory, sessionID),
|
||||
type: "error",
|
||||
session: sessionID ?? "global",
|
||||
error,
|
||||
})
|
||||
const description =
|
||||
session?.title ??
|
||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||
if (settings.notifications.errors()) {
|
||||
void platform.notify(language.t("notification.session.error.title"), description, href)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = serverSDK().event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
||||
|
||||
const directory = e.name
|
||||
const time = Date.now()
|
||||
if (event.type === "session.idle") {
|
||||
handleSessionIdle(directory, event, time)
|
||||
return
|
||||
}
|
||||
handleSessionError(directory, event, time)
|
||||
})
|
||||
onCleanup(() => {
|
||||
meta.disposed = true
|
||||
unsub()
|
||||
})
|
||||
const selected = () => ensure(activeServer())
|
||||
|
||||
return {
|
||||
ready,
|
||||
ready: () => selected().ready(),
|
||||
ensureServerState: ensure,
|
||||
session: {
|
||||
all(session: string) {
|
||||
return index.session.all[session] ?? empty
|
||||
},
|
||||
unseen(session: string) {
|
||||
return index.session.unseen[session] ?? empty
|
||||
},
|
||||
unseenCount(session: string) {
|
||||
return index.session.unseenCount[session] ?? 0
|
||||
},
|
||||
unseenHasError(session: string) {
|
||||
return index.session.unseenHasError[session] ?? false
|
||||
},
|
||||
markViewed(session: string) {
|
||||
const unseen = index.session.unseen[session] ?? empty
|
||||
if (!unseen.length) return
|
||||
|
||||
const projects = [
|
||||
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
|
||||
]
|
||||
batch(() => {
|
||||
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
|
||||
updateUnseen("session", session, [])
|
||||
projects.forEach((directory) => {
|
||||
const next = (index.project.unseen[directory] ?? empty).filter(
|
||||
(notification) => notification.session !== session,
|
||||
)
|
||||
updateUnseen("project", directory, next)
|
||||
})
|
||||
})
|
||||
},
|
||||
all: (session: string) => selected().session.all(session),
|
||||
unseen: (session: string) => selected().session.unseen(session),
|
||||
unseenCount: (session: string) => selected().session.unseenCount(session),
|
||||
unseenHasError: (session: string) => selected().session.unseenHasError(session),
|
||||
markViewed: (session: string) => selected().session.markViewed(session),
|
||||
},
|
||||
project: {
|
||||
all(directory: string) {
|
||||
return index.project.all[directory] ?? empty
|
||||
},
|
||||
unseen(directory: string) {
|
||||
return index.project.unseen[directory] ?? empty
|
||||
},
|
||||
unseenCount(directory: string) {
|
||||
return index.project.unseenCount[directory] ?? 0
|
||||
},
|
||||
unseenHasError(directory: string) {
|
||||
return index.project.unseenHasError[directory] ?? false
|
||||
},
|
||||
markViewed(directory: string) {
|
||||
const unseen = index.project.unseen[directory] ?? empty
|
||||
if (!unseen.length) return
|
||||
|
||||
const sessions = [
|
||||
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
|
||||
]
|
||||
batch(() => {
|
||||
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
|
||||
updateUnseen("project", directory, [])
|
||||
sessions.forEach((session) => {
|
||||
const next = (index.session.unseen[session] ?? empty).filter(
|
||||
(notification) => notification.directory !== directory,
|
||||
)
|
||||
updateUnseen("session", session, next)
|
||||
})
|
||||
})
|
||||
},
|
||||
all: (directory: string) => selected().project.all(directory),
|
||||
unseen: (directory: string) => selected().project.unseen(directory),
|
||||
unseenCount: (directory: string) => selected().project.unseenCount(directory),
|
||||
unseenHasError: (directory: string) => selected().project.unseenHasError(directory),
|
||||
markViewed: (directory: string) => selected().project.markViewed(directory),
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type NotificationState = ReturnType<typeof createServerNotificationState>
|
||||
|
||||
function createServerNotificationState(input: {
|
||||
sdk: ServerSDK
|
||||
sync: ServerSync
|
||||
active: Accessor<boolean>
|
||||
directory: Accessor<string | undefined>
|
||||
sessionID: Accessor<string | undefined>
|
||||
platform: ReturnType<typeof usePlatform>
|
||||
settings: ReturnType<typeof useSettings>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const serverSDK = () => input.sdk
|
||||
const serverSync = () => input.sync
|
||||
const platform = input.platform
|
||||
const settings = input.settings
|
||||
const language = input.language
|
||||
|
||||
const empty: Notification[] = []
|
||||
|
||||
const currentDirectory = input.directory
|
||||
const currentSession = input.sessionID
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
||||
createStore({
|
||||
list: [] as Notification[],
|
||||
}),
|
||||
)
|
||||
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
|
||||
|
||||
const meta = { pruned: false, disposed: false }
|
||||
|
||||
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
|
||||
setIndex(scope, "unseen", key, unseen)
|
||||
setIndex(scope, "unseenCount", key, unseen.length)
|
||||
setIndex(
|
||||
scope,
|
||||
"unseenHasError",
|
||||
key,
|
||||
unseen.some((notification) => notification.type === "error"),
|
||||
)
|
||||
}
|
||||
|
||||
const appendToIndex = (notification: Notification) => {
|
||||
if (notification.session) {
|
||||
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
|
||||
if (!notification.viewed) {
|
||||
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
|
||||
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
|
||||
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.directory) {
|
||||
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
|
||||
if (!notification.viewed) {
|
||||
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
|
||||
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
|
||||
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeFromIndex = (notification: Notification) => {
|
||||
if (notification.session) {
|
||||
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
|
||||
if (!notification.viewed) {
|
||||
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
|
||||
updateUnseen("session", notification.session, unseen)
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.directory) {
|
||||
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
|
||||
if (!notification.viewed) {
|
||||
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
|
||||
updateUnseen("project", notification.directory, unseen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (meta.pruned) return
|
||||
meta.pruned = true
|
||||
const list = pruneNotifications(store.list)
|
||||
batch(() => {
|
||||
setStore("list", list)
|
||||
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
|
||||
})
|
||||
})
|
||||
|
||||
const append = (notification: Notification) => {
|
||||
const list = pruneNotifications([...store.list, notification])
|
||||
const keep = new Set(list)
|
||||
const removed = store.list.filter((n) => !keep.has(n))
|
||||
|
||||
batch(() => {
|
||||
if (keep.has(notification)) appendToIndex(notification)
|
||||
removed.forEach((n) => removeFromIndex(n))
|
||||
setStore("list", list)
|
||||
})
|
||||
}
|
||||
|
||||
const lookup = async (directory: string, sessionID?: string) => {
|
||||
if (!sessionID) return undefined
|
||||
const sync = serverSync().ensureDirSyncContext(directory)
|
||||
const session = sync.session.get(sessionID)
|
||||
if (session) return session
|
||||
return sync.session
|
||||
.sync(sessionID)
|
||||
.then(() => sync.session.get(sessionID))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
|
||||
if (!input.active()) return false
|
||||
const activeDirectory = currentDirectory()
|
||||
const activeSession = currentSession()
|
||||
if (!activeSession) return false
|
||||
if (!sessionID) return false
|
||||
if (activeDirectory && directory !== activeDirectory) return false
|
||||
return sessionID === activeSession
|
||||
}
|
||||
|
||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (!session) return
|
||||
if (session.parentID) return
|
||||
|
||||
if (settings.sounds.agentEnabled()) {
|
||||
void playSoundById(settings.sounds.agent())
|
||||
}
|
||||
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewedInCurrentSession(directory, sessionID),
|
||||
type: "turn-complete",
|
||||
session: sessionID,
|
||||
})
|
||||
|
||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSessionError = (
|
||||
directory: string,
|
||||
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
|
||||
time: number,
|
||||
) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (session?.parentID) return
|
||||
|
||||
if (settings.sounds.errorsEnabled()) {
|
||||
void playSoundById(settings.sounds.errors())
|
||||
}
|
||||
|
||||
const error = "error" in event.properties ? event.properties.error : undefined
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewedInCurrentSession(directory, sessionID),
|
||||
type: "error",
|
||||
session: sessionID ?? "global",
|
||||
error,
|
||||
})
|
||||
const description =
|
||||
session?.title ??
|
||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||
if (settings.notifications.errors()) {
|
||||
void platform.notify(language.t("notification.session.error.title"), description, href)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = serverSDK().event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
||||
|
||||
const directory = e.name
|
||||
const time = Date.now()
|
||||
if (event.type === "session.idle") {
|
||||
handleSessionIdle(directory, event, time)
|
||||
return
|
||||
}
|
||||
handleSessionError(directory, event, time)
|
||||
})
|
||||
onCleanup(() => {
|
||||
meta.disposed = true
|
||||
unsub()
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
session: {
|
||||
all(session: string) {
|
||||
return index.session.all[session] ?? empty
|
||||
},
|
||||
unseen(session: string) {
|
||||
return index.session.unseen[session] ?? empty
|
||||
},
|
||||
unseenCount(session: string) {
|
||||
return index.session.unseenCount[session] ?? 0
|
||||
},
|
||||
unseenHasError(session: string) {
|
||||
return index.session.unseenHasError[session] ?? false
|
||||
},
|
||||
markViewed(session: string) {
|
||||
const unseen = index.session.unseen[session] ?? empty
|
||||
if (!unseen.length) return
|
||||
|
||||
const projects = [
|
||||
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
|
||||
]
|
||||
batch(() => {
|
||||
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
|
||||
updateUnseen("session", session, [])
|
||||
projects.forEach((directory) => {
|
||||
const next = (index.project.unseen[directory] ?? empty).filter(
|
||||
(notification) => notification.session !== session,
|
||||
)
|
||||
updateUnseen("project", directory, next)
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
project: {
|
||||
all(directory: string) {
|
||||
return index.project.all[directory] ?? empty
|
||||
},
|
||||
unseen(directory: string) {
|
||||
return index.project.unseen[directory] ?? empty
|
||||
},
|
||||
unseenCount(directory: string) {
|
||||
return index.project.unseenCount[directory] ?? 0
|
||||
},
|
||||
unseenHasError(directory: string) {
|
||||
return index.project.unseenHasError[directory] ?? false
|
||||
},
|
||||
markViewed(directory: string) {
|
||||
const unseen = index.project.unseen[directory] ?? empty
|
||||
if (!unseen.length) return
|
||||
|
||||
const sessions = [
|
||||
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
|
||||
]
|
||||
batch(() => {
|
||||
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
|
||||
updateUnseen("project", directory, [])
|
||||
sessions.forEach((session) => {
|
||||
const next = (index.session.unseen[session] ?? empty).filter(
|
||||
(notification) => notification.directory !== directory,
|
||||
)
|
||||
updateUnseen("session", session, next)
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,9 @@ type PlatformBase = {
|
||||
/** Storage mechanism, defaults to localStorage */
|
||||
storage?: (name?: string) => SyncStorage | AsyncStorage
|
||||
|
||||
/** Stable platform window identity for window-scoped persistence */
|
||||
windowID?: string
|
||||
|
||||
/** Application-global desktop updater */
|
||||
updater?: UpdaterPlatform
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useTabs, type Tab } from "./tabs"
|
||||
import { ServerConnection } from "./server"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { useSettings } from "./settings"
|
||||
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
@@ -27,6 +28,10 @@ export interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
mime?: string
|
||||
filename?: string
|
||||
url?: string
|
||||
source?: FilePartSource
|
||||
}
|
||||
|
||||
export interface AgentPart extends PartBase {
|
||||
@@ -73,7 +78,13 @@ function isPartEqual(partA: ContentPart, partB: ContentPart) {
|
||||
case "text":
|
||||
return partB.type === "text" && partA.content === partB.content
|
||||
case "file":
|
||||
return partB.type === "file" && partA.path === partB.path && isSelectionEqual(partA.selection, partB.selection)
|
||||
return (
|
||||
partB.type === "file" &&
|
||||
partA.path === partB.path &&
|
||||
partA.mime === partB.mime &&
|
||||
partA.filename === partB.filename &&
|
||||
isSelectionEqual(partA.selection, partB.selection)
|
||||
)
|
||||
case "agent":
|
||||
return partB.type === "agent" && partA.name === partB.name
|
||||
case "image":
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
@@ -15,19 +15,23 @@ describe("resumeStreamAfterPageShow", () => {
|
||||
})
|
||||
|
||||
describe("coalesceServerEvents", () => {
|
||||
const delta = (value: string, field = "text") => ({
|
||||
const delta = (value: string, field = "text", partID = "part") => ({
|
||||
directory: "/repo",
|
||||
payload: {
|
||||
type: "message.part.delta",
|
||||
properties: { messageID: "msg", partID: "part", field, delta: value },
|
||||
properties: { messageID: "msg", partID, field, delta: value },
|
||||
} as Event,
|
||||
})
|
||||
|
||||
test("merges adjacent deltas for the same field", () => {
|
||||
const result = coalesceServerEvents([delta("hello "), delta("world")])
|
||||
const first = delta("hello ")
|
||||
const second = delta("world")
|
||||
first.payload.id = "first"
|
||||
second.payload.id = "second"
|
||||
const result = coalesceServerEvents([first, second])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.payload).toMatchObject({ properties: { delta: "hello world" } })
|
||||
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
|
||||
})
|
||||
|
||||
test("preserves event boundaries and distinct fields", () => {
|
||||
@@ -45,9 +49,112 @@ describe("coalesceServerEvents", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("drops stale deltas", () => {
|
||||
const result = coalesceServerEvents([delta("stale")], new Set(["/repo:msg:part"]))
|
||||
test("preserves event ID order across interleaved deltas", () => {
|
||||
const first = delta("a")
|
||||
const other = delta("b", "text", "other")
|
||||
const last = delta("c")
|
||||
first.payload.id = "1"
|
||||
other.payload.id = "2"
|
||||
last.payload.id = "3"
|
||||
|
||||
expect(result).toEqual([])
|
||||
const result = coalesceServerEvents([first, other, last])
|
||||
|
||||
expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("enqueueServerEvent", () => {
|
||||
const partUpdated = (text: string) =>
|
||||
({
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: "session",
|
||||
part: { id: "part", sessionID: "session", messageID: "message", type: "text", text },
|
||||
},
|
||||
}) as Event
|
||||
|
||||
test("preserves part updates across message remove and re-add barriers", () => {
|
||||
const events: Array<{ directory: string; payload: Event }> = []
|
||||
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||
|
||||
enqueue(partUpdated("old"))
|
||||
enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event)
|
||||
enqueue({
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID: "session",
|
||||
info: {
|
||||
id: "message",
|
||||
sessionID: "session",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
enqueue(partUpdated("new"))
|
||||
|
||||
expect(events.map((event) => event.payload.type)).toEqual([
|
||||
"message.part.updated",
|
||||
"message.removed",
|
||||
"message.updated",
|
||||
"message.part.updated",
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves deltas after a replacement snapshot", () => {
|
||||
const events: Array<{ directory: string; payload: Event }> = []
|
||||
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||
|
||||
enqueue(partUpdated("a"))
|
||||
enqueue(partUpdated("ab"))
|
||||
enqueue({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" },
|
||||
} as Event)
|
||||
|
||||
const result = coalesceServerEvents(events)
|
||||
expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"])
|
||||
expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } })
|
||||
expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } })
|
||||
})
|
||||
|
||||
test("preserves updates after session deletion", () => {
|
||||
const events: Array<{ directory: string; payload: Event }> = []
|
||||
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||
|
||||
enqueue(partUpdated("old"))
|
||||
enqueue({
|
||||
type: "session.deleted",
|
||||
properties: { sessionID: "session", info: { id: "session" } },
|
||||
} as Event)
|
||||
enqueue(partUpdated("new"))
|
||||
|
||||
expect(events.map((event) => event.payload.type)).toEqual([
|
||||
"message.part.updated",
|
||||
"session.deleted",
|
||||
"message.part.updated",
|
||||
])
|
||||
})
|
||||
|
||||
test("does not coalesce edge-triggered session statuses", () => {
|
||||
const events: Array<{ directory: string; payload: Event }> = []
|
||||
const enqueue = (status: "retry" | "busy") =>
|
||||
enqueueServerEvent(events, {
|
||||
directory: "/repo",
|
||||
payload: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: "session",
|
||||
status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" },
|
||||
},
|
||||
} as Event,
|
||||
})
|
||||
|
||||
enqueue("retry")
|
||||
enqueue("busy")
|
||||
|
||||
expect(events).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,34 +17,56 @@ const isAbortError = (error: unknown) =>
|
||||
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||
type QueuedServerEvent = { directory: string; payload: Event }
|
||||
|
||||
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
|
||||
const coalescedKey = (event: QueuedServerEvent) => {
|
||||
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
||||
if (event.payload.type === "message.part.updated") {
|
||||
const part = event.payload.properties.part
|
||||
return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function coalesceServerEvents(events: QueuedServerEvent[], stale?: Set<string>) {
|
||||
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
|
||||
const key = coalescedKey(event)
|
||||
const previous = queue[queue.length - 1]
|
||||
if (key && previous && coalescedKey(previous) === key) {
|
||||
queue[queue.length - 1] = event
|
||||
return false
|
||||
}
|
||||
queue.push(event)
|
||||
return true
|
||||
}
|
||||
|
||||
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
const output: QueuedServerEvent[] = []
|
||||
const deltas = new Map<string, number>()
|
||||
events.forEach((event) => {
|
||||
if (stale && event.payload.type === "message.part.delta") {
|
||||
const props = event.payload.properties
|
||||
if (stale.has(deltaKey(event.directory, props.messageID, props.partID))) return
|
||||
}
|
||||
if (event.payload.type !== "message.part.delta") {
|
||||
deltas.clear()
|
||||
output.push(event)
|
||||
return
|
||||
}
|
||||
const props = event.payload.properties
|
||||
const id = `${deltaKey(event.directory, props.messageID, props.partID)}:${props.field}`
|
||||
const index = deltas.get(id)
|
||||
const existing = index === undefined ? undefined : output[index]
|
||||
if (!existing || existing.payload.type !== "message.part.delta") {
|
||||
deltas.set(id, output.length)
|
||||
const previous = output[output.length - 1]
|
||||
if (
|
||||
!previous ||
|
||||
previous.payload.type !== "message.part.delta" ||
|
||||
previous.directory !== event.directory ||
|
||||
previous.payload.properties.messageID !== props.messageID ||
|
||||
previous.payload.properties.partID !== props.partID ||
|
||||
previous.payload.properties.field !== props.field
|
||||
) {
|
||||
output.push({
|
||||
directory: event.directory,
|
||||
payload: { ...event.payload, properties: { ...props } },
|
||||
})
|
||||
return
|
||||
}
|
||||
existing.payload.properties.delta += props.delta
|
||||
output[output.length - 1] = {
|
||||
directory: event.directory,
|
||||
payload: {
|
||||
...event.payload,
|
||||
properties: { ...props, delta: previous.payload.properties.delta + props.delta },
|
||||
},
|
||||
}
|
||||
})
|
||||
return output
|
||||
}
|
||||
@@ -85,20 +107,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
|
||||
let queue: Queued[] = []
|
||||
let buffer: Queued[] = []
|
||||
const coalesced = new Map<string, number>()
|
||||
const staleDeltas = new Set<string>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
const key = (directory: string, payload: Event) => {
|
||||
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
||||
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = payload.properties.part
|
||||
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
|
||||
}
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
@@ -106,15 +117,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
if (queue.length === 0) return
|
||||
|
||||
const events = queue
|
||||
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
|
||||
queue = buffer
|
||||
buffer = events
|
||||
queue.length = 0
|
||||
coalesced.clear()
|
||||
staleDeltas.clear()
|
||||
|
||||
last = Date.now()
|
||||
const output = coalesceServerEvents(events, skip)
|
||||
const output = coalesceServerEvents(events)
|
||||
batch(() => {
|
||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
||||
})
|
||||
@@ -184,29 +192,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
for await (const event of events.stream) {
|
||||
resetHeartbeat()
|
||||
streamErrorLogged = false
|
||||
const directory = event.directory ?? "global"
|
||||
if (event.payload.type === "sync") {
|
||||
continue
|
||||
if (event.payload.type !== "sync") {
|
||||
const directory = event.directory ?? "global"
|
||||
const payload = event.payload as Event
|
||||
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||
}
|
||||
|
||||
const payload = event.payload as Event
|
||||
|
||||
const k = key(directory, payload)
|
||||
if (k) {
|
||||
const i = coalesced.get(k)
|
||||
if (i !== undefined) {
|
||||
queue[i] = { directory, payload }
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = payload.properties.part
|
||||
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
||||
}
|
||||
continue
|
||||
}
|
||||
coalesced.set(k, queue.length)
|
||||
}
|
||||
queue.push({ directory, payload })
|
||||
schedule()
|
||||
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
yielded = Date.now()
|
||||
await wait(0)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { createServerSession } from "./server-session"
|
||||
|
||||
const session = (id: string, parentID?: string): Session => ({
|
||||
@@ -13,6 +14,74 @@ const session = (id: string, parentID?: string): Session => ({
|
||||
time: { created: 1, updated: 1 },
|
||||
})
|
||||
|
||||
type UserMessage = Extract<Message, { role: "user" }>
|
||||
type TextPart = Extract<Part, { type: "text" }>
|
||||
type MessageResponse = {
|
||||
data: { info: Message; parts: Part[] }[]
|
||||
response: { headers: Headers }
|
||||
}
|
||||
|
||||
const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage => ({
|
||||
id,
|
||||
sessionID: "child",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
...input,
|
||||
})
|
||||
|
||||
const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({
|
||||
id: "part",
|
||||
sessionID: "child",
|
||||
messageID,
|
||||
type: "text",
|
||||
text: "text",
|
||||
...input,
|
||||
})
|
||||
|
||||
const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({
|
||||
data,
|
||||
response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
|
||||
})
|
||||
|
||||
const deferredResponse = () => Promise.withResolvers<MessageResponse>()
|
||||
|
||||
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
|
||||
let index = 0
|
||||
const requests: unknown[] = []
|
||||
const waiting = new Map<number, () => void>()
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: session("child", "root") }),
|
||||
messages: (input: unknown) => {
|
||||
requests.push(input)
|
||||
waiting.get(requests.length)?.()
|
||||
waiting.delete(requests.length)
|
||||
return responses[index++]
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
return Object.assign(client, {
|
||||
requests,
|
||||
requested(count: number) {
|
||||
if (requests.length >= count) return Promise.resolve()
|
||||
return new Promise<void>((resolve) => waiting.set(count, resolve))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const retryImmediately: typeof retry = async (task, options = {}) => {
|
||||
const attempts = options.attempts ?? 3
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await task()
|
||||
} catch (error) {
|
||||
if (attempt === attempts - 1) throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setup(sessions: Record<string, Session>) {
|
||||
const get: unknown[] = []
|
||||
const messages: unknown[] = []
|
||||
@@ -25,7 +94,7 @@ function setup(sessions: Record<string, Session>) {
|
||||
},
|
||||
messages: async (input: unknown) => {
|
||||
messages.push(input)
|
||||
return { data: [], response: { headers: new Headers() } }
|
||||
return response()
|
||||
},
|
||||
diff: async () => ({ data: [] }),
|
||||
todo: async () => ({ data: [] }),
|
||||
@@ -55,13 +124,971 @@ describe("server session", () => {
|
||||
expect(ctx.store.data.message.root).toEqual([])
|
||||
})
|
||||
|
||||
test("merges live events into the initial page", async () => {
|
||||
const pending = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const live = userMessage("message-2", { time: { created: 2 } })
|
||||
const livePart = textPart(live.id, { text: "live" })
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: livePart, time: 2 } })
|
||||
pending.resolve(response([{ info: user, parts: [] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([user, live])
|
||||
expect(store.data.part[live.id]).toEqual([livePart])
|
||||
})
|
||||
|
||||
test("preserves same-ID live updates over the initial page", async () => {
|
||||
const pending = deferredResponse()
|
||||
const fetched = userMessage("message")
|
||||
const fetchedPart = textPart(fetched.id, { text: "fetched" })
|
||||
const live = { ...fetched, time: { created: 2 } }
|
||||
const livePart = { ...fetchedPart, text: "live" }
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: livePart, time: 2 } })
|
||||
pending.resolve(response([{ info: fetched, parts: [fetchedPart] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([live])
|
||||
expect(store.data.part[live.id]).toEqual([livePart])
|
||||
})
|
||||
|
||||
test("preserves removals received during the initial load", async () => {
|
||||
const pending = deferredResponse()
|
||||
const removed = userMessage("message-1")
|
||||
const kept = { ...removed, id: "message-2" }
|
||||
const part = textPart(kept.id, { text: "removed" })
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: removed.id } })
|
||||
store.apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: "child", messageID: kept.id, partID: part.id },
|
||||
})
|
||||
pending.resolve(
|
||||
response([
|
||||
{ info: removed, parts: [] },
|
||||
{ info: kept, parts: [part] },
|
||||
]),
|
||||
)
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([kept])
|
||||
expect(store.data.part[kept.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("keeps removal tracking isolated across load generations", async () => {
|
||||
const firstResponse = deferredResponse()
|
||||
const secondResponse = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const store = createServerSession(messageClient(firstResponse.promise, secondResponse.promise))
|
||||
const first = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.apply({
|
||||
type: "session.deleted",
|
||||
properties: { sessionID: "child", info: session("child", "root") },
|
||||
})
|
||||
const second = store.sync("child")
|
||||
|
||||
firstResponse.resolve(response())
|
||||
await first
|
||||
secondResponse.resolve(response([{ info: message, parts: [] }]))
|
||||
await second
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
})
|
||||
|
||||
test("tracks removals in a replacement load generation", async () => {
|
||||
const firstResponse = deferredResponse()
|
||||
const secondResponse = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const store = createServerSession(messageClient(firstResponse.promise, secondResponse.promise))
|
||||
const first = store.sync("child")
|
||||
store.apply({
|
||||
type: "session.deleted",
|
||||
properties: { sessionID: "child", info: session("child", "root") },
|
||||
})
|
||||
const second = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
firstResponse.resolve(response())
|
||||
await first
|
||||
secondResponse.resolve(response([{ info: message, parts: [] }]))
|
||||
await second
|
||||
|
||||
expect(store.data.message.child).toEqual([])
|
||||
})
|
||||
|
||||
test("preserves remove then re-add when a refresh omits the message", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const store = createServerSession(messageClient(response([{ info: message, parts: [] }]), pending.promise))
|
||||
await store.sync("child")
|
||||
const refreshing = store.sync("child", { force: true })
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.apply({ type: "message.updated", properties: { info: message } })
|
||||
pending.resolve(response())
|
||||
await refreshing
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
})
|
||||
|
||||
test("preserves a re-added message without restoring removed parts", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "stale" })
|
||||
const store = createServerSession(messageClient(response([{ info: message, parts: [] }]), pending.promise))
|
||||
await store.sync("child")
|
||||
const refreshing = store.sync("child", { force: true })
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.apply({ type: "message.updated", properties: { info: message } })
|
||||
pending.resolve(response([{ info: message, parts: [part] }]))
|
||||
await refreshing
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves optimistic parts re-added after removal during a refresh", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const stale = textPart(message.id, { id: "stale", text: "stale" })
|
||||
const part = textPart(message.id, { id: "optimistic", text: "optimistic" })
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [] }]), pending.promise, response()),
|
||||
)
|
||||
await store.sync("child")
|
||||
const refreshing = store.sync("child", { force: true })
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
pending.resolve(response([{ info: message, parts: [stale] }]))
|
||||
await refreshing
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([part])
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([part])
|
||||
})
|
||||
|
||||
test("drops stale event content omitted by a complete initial page", async () => {
|
||||
const stale = userMessage("stale")
|
||||
const store = createServerSession(messageClient(response()))
|
||||
store.apply({ type: "message.updated", properties: { info: stale } })
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(store.data.message.child).toEqual([])
|
||||
})
|
||||
|
||||
test("preserves event content outside an incomplete initial page", async () => {
|
||||
const live = userMessage("message-1")
|
||||
const fetched = userMessage("message-2", { time: { created: 2 } })
|
||||
const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }], "older")))
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(store.data.message.child).toEqual([live, fetched])
|
||||
})
|
||||
|
||||
test("does not restore removed optimistic content on refresh", async () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "removed" })
|
||||
const kept = { ...message, id: "kept" }
|
||||
const keptPart = { ...part, id: "kept-part", messageID: kept.id }
|
||||
const store = createServerSession(messageClient(response([{ info: kept, parts: [] }])))
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.optimistic.add({ sessionID: "child", message: kept, parts: [keptPart] })
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: "child", messageID: kept.id, partID: keptPart.id },
|
||||
})
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.message.child).toEqual([kept])
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
expect(store.data.part[kept.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("replaces confirmed optimistic content with the initial page", async () => {
|
||||
const optimistic = userMessage("message")
|
||||
const fetched = { ...optimistic, time: { created: 2 } }
|
||||
const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }])))
|
||||
store.optimistic.add({ sessionID: "child", message: optimistic, parts: [] })
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(store.data.message.child).toEqual([fetched])
|
||||
})
|
||||
|
||||
test("replaces a confirmed optimistic part with fetched content", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const optimistic = textPart(message.id, { text: "optimistic" })
|
||||
const fetched = { ...optimistic, text: "fetched" }
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
|
||||
pending.resolve(response([{ info: message, parts: [fetched] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([fetched])
|
||||
})
|
||||
|
||||
test("rolls back only unconfirmed optimistic parts", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
|
||||
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
const loading = store.sync("child")
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
|
||||
|
||||
pending.resolve(response([{ info: message, parts: [confirmed] }]))
|
||||
await loading
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([confirmed])
|
||||
})
|
||||
|
||||
test("updates confirmed optimistic parts from later pages", async () => {
|
||||
const message = userMessage("message")
|
||||
const confirmed = textPart(message.id, { id: "confirmed", text: "first" })
|
||||
const updated = { ...confirmed, text: "updated" }
|
||||
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [updated] }])),
|
||||
)
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([updated])
|
||||
})
|
||||
|
||||
test("does not restore a confirmed optimistic part after its removal event", async () => {
|
||||
const message = userMessage("message")
|
||||
const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
|
||||
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [] }])),
|
||||
)
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
|
||||
await store.sync("child")
|
||||
store.apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: confirmed.id },
|
||||
})
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([pendingPart])
|
||||
})
|
||||
|
||||
test("clears delta buffers when removing optimistic content", () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "optimistic" })
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
|
||||
})
|
||||
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not remove content confirmed by a message event", () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
|
||||
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not remove parts confirmed by part events", () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
|
||||
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([part])
|
||||
})
|
||||
|
||||
test("treats a part event as confirmation when it precedes the message event", () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
|
||||
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([part])
|
||||
})
|
||||
|
||||
test("clears stale parts when the initial page has none", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "stale" })
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
store.apply({ type: "message.updated", properties: { info: message } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 1 } })
|
||||
const loading = store.sync("child")
|
||||
|
||||
pending.resolve(response([{ info: message, parts: [] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("clears delta buffers for parts omitted by the initial page", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const kept = textPart(message.id, { id: "part-1", text: "kept" })
|
||||
const removed: Part = { ...kept, id: "part-2", text: "removed" }
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
store.apply({ type: "message.updated", properties: { info: message } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: kept, time: 1 } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: removed, time: 1 } })
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: removed.id, field: "text", delta: " delta" },
|
||||
})
|
||||
const loading = store.sync("child")
|
||||
|
||||
pending.resolve(response([{ info: message, parts: [kept] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([kept])
|
||||
expect(store.data.part_text_accum_delta[removed.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("clears a stale delta buffer when a refresh replaces its part", async () => {
|
||||
const message = userMessage("message")
|
||||
const stale = textPart(message.id, { text: "stale" })
|
||||
const fetched = { ...stale, text: "fetched" }
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [stale] }]), response([{ info: message, parts: [fetched] }])),
|
||||
)
|
||||
await store.sync("child")
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" },
|
||||
})
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([fetched])
|
||||
expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves a non-durable delta received before refresh", async () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "stale" })
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [{ ...part }] }])),
|
||||
)
|
||||
await store.sync("child")
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
|
||||
})
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([{ ...part, text: "stale delta" }])
|
||||
expect(store.data.part_text_accum_delta[part.id]).toBe("stale delta")
|
||||
})
|
||||
|
||||
test("accepts fetched text that intentionally replaces an accumulated prefix", async () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "abc" })
|
||||
const fetched = { ...part, text: "ab" }
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])),
|
||||
)
|
||||
await store.sync("child")
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "def" },
|
||||
})
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([fetched])
|
||||
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves an unpersisted delta suffix after partial server catch-up", async () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "a" })
|
||||
const fetched = { ...part, text: "ab" }
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])),
|
||||
)
|
||||
await store.sync("child")
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "bc" },
|
||||
})
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([{ ...part, text: "abc" }])
|
||||
expect(store.data.part_text_accum_delta[part.id]).toBe("abc")
|
||||
})
|
||||
|
||||
test("clears delta state after exact server catch-up", async () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "a" })
|
||||
const fetched = { ...part, text: "ab" }
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])),
|
||||
)
|
||||
await store.sync("child")
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "b" },
|
||||
})
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([fetched])
|
||||
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("uses the successful retry response over events from a failed attempt", async () => {
|
||||
const failed = Promise.withResolvers<MessageResponse>()
|
||||
const retried = Promise.withResolvers<MessageResponse>()
|
||||
const message = userMessage("message")
|
||||
const stale = textPart(message.id, { text: "stale" })
|
||||
const intermediate = { ...stale, text: "intermediate" }
|
||||
const fetched = { ...stale, text: "fetched" }
|
||||
const client = messageClient(failed.promise, retried.promise)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
store.apply({ type: "message.updated", properties: { info: message } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: stale, time: 1 } })
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: intermediate, time: 2 } })
|
||||
failed.reject(new Error("failed to fetch"))
|
||||
await client.requested(2)
|
||||
retried.resolve(response([{ info: message, parts: [fetched] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([fetched])
|
||||
})
|
||||
|
||||
test("preserves non-durable deltas across message retries", async () => {
|
||||
const failed = Promise.withResolvers<MessageResponse>()
|
||||
const retried = Promise.withResolvers<MessageResponse>()
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "stale" })
|
||||
const client = messageClient(failed.promise, retried.promise)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
store.apply({ type: "message.updated", properties: { info: message } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 1 } })
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
|
||||
})
|
||||
failed.reject(new Error("failed to fetch"))
|
||||
await client.requested(2)
|
||||
retried.resolve(response([{ info: message, parts: [part] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([{ ...part, text: "stale delta" }])
|
||||
})
|
||||
|
||||
test("preserves part removals across message retries", async () => {
|
||||
const failed = Promise.withResolvers<MessageResponse>()
|
||||
const retried = Promise.withResolvers<MessageResponse>()
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
await store.sync("child")
|
||||
const loading = store.sync("child", { force: true })
|
||||
|
||||
store.apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id },
|
||||
})
|
||||
failed.reject(new Error("failed to fetch"))
|
||||
await client.requested(3)
|
||||
retried.resolve(response([{ info: message, parts: [part] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves message removals across message retries", async () => {
|
||||
const failed = Promise.withResolvers<MessageResponse>()
|
||||
const retried = Promise.withResolvers<MessageResponse>()
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
await store.sync("child")
|
||||
const loading = store.sync("child", { force: true })
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
failed.reject(new Error("failed to fetch"))
|
||||
await client.requested(3)
|
||||
retried.resolve(response([{ info: message, parts: [part] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([])
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves optimistic re-adds across message retries", async () => {
|
||||
const failed = Promise.withResolvers<MessageResponse>()
|
||||
const retried = Promise.withResolvers<MessageResponse>()
|
||||
const message = userMessage("message")
|
||||
const stale = textPart(message.id, { id: "stale", text: "stale" })
|
||||
const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" })
|
||||
const client = messageClient(response([{ info: message, parts: [stale] }]), failed.promise, retried.promise)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
await store.sync("child")
|
||||
const loading = store.sync("child", { force: true })
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
|
||||
failed.reject(new Error("failed to fetch"))
|
||||
await client.requested(3)
|
||||
retried.resolve(response([{ info: message, parts: [stale] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([optimistic])
|
||||
})
|
||||
|
||||
test("accepts part omission from a successful retry after an earlier delta", async () => {
|
||||
const failed = Promise.withResolvers<MessageResponse>()
|
||||
const retried = Promise.withResolvers<MessageResponse>()
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
await store.sync("child")
|
||||
const loading = store.sync("child", { force: true })
|
||||
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
|
||||
})
|
||||
failed.reject(new Error("failed to fetch"))
|
||||
await client.requested(3)
|
||||
retried.resolve(response([{ info: message, parts: [] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("clears load-owned orphan parts when all retries fail", async () => {
|
||||
const first = Promise.withResolvers<MessageResponse>()
|
||||
const second = Promise.withResolvers<MessageResponse>()
|
||||
const third = Promise.withResolvers<MessageResponse>()
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const client = messageClient(first.promise, second.promise, third.promise)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child").catch((error) => error)
|
||||
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
|
||||
first.reject(new Error("failed to fetch"))
|
||||
await client.requested(2)
|
||||
second.reject(new Error("failed to fetch"))
|
||||
await client.requested(3)
|
||||
third.reject(new Error("failed to fetch"))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves live updates during a forced refresh", async () => {
|
||||
const pending = deferredResponse()
|
||||
const stale = userMessage("message")
|
||||
const stalePart = textPart(stale.id, { text: "stale" })
|
||||
const store = createServerSession(messageClient(response([{ info: stale, parts: [stalePart] }]), pending.promise))
|
||||
await store.sync("child")
|
||||
const refreshing = store.sync("child", { force: true })
|
||||
const live = { ...stale, time: { created: 2 } }
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: stale.id, partID: stalePart.id, field: "text", delta: " live" },
|
||||
})
|
||||
pending.resolve(response([{ info: stale, parts: [stalePart] }]))
|
||||
await refreshing
|
||||
|
||||
expect(store.data.message.child).toEqual([live])
|
||||
expect(store.data.part[stale.id]).toEqual([{ ...stalePart, text: "stale live" }])
|
||||
})
|
||||
|
||||
test("keeps fetched message metadata when only a part changes", async () => {
|
||||
const pending = deferredResponse()
|
||||
const stale = userMessage("message")
|
||||
const fetched = { ...stale, time: { created: 2 } }
|
||||
const part = textPart(stale.id, { text: "stale" })
|
||||
const store = createServerSession(messageClient(response([{ info: stale, parts: [part] }]), pending.promise))
|
||||
await store.sync("child")
|
||||
const refreshing = store.sync("child", { force: true })
|
||||
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: stale.id, partID: part.id, field: "text", delta: " live" },
|
||||
})
|
||||
pending.resolve(response([{ info: fetched, parts: [part] }]))
|
||||
await refreshing
|
||||
|
||||
expect(store.data.message.child).toEqual([fetched])
|
||||
expect(store.data.part[stale.id]).toEqual([{ ...part, text: "stale live" }])
|
||||
})
|
||||
|
||||
test("preserves a part update when a forced refresh omits its message", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const stale = textPart(message.id, { text: "stale" })
|
||||
const live = { ...stale, text: "live" }
|
||||
const store = createServerSession(messageClient(response([{ info: message, parts: [stale] }]), pending.promise))
|
||||
await store.sync("child")
|
||||
const refreshing = store.sync("child", { force: true })
|
||||
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } })
|
||||
pending.resolve(response())
|
||||
await refreshing
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([live])
|
||||
})
|
||||
|
||||
test("ignores a late part update after its message is removed", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: message } })
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
|
||||
pending.resolve(response([{ info: message, parts: [part] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([])
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("ignores a late part update after a completed message removal", () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.apply({ type: "message.updated", properties: { info: message } })
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
|
||||
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not restore a completed message removal from a stale refresh", async () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [part] }])),
|
||||
)
|
||||
await store.sync("child")
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.message.child).toEqual([])
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not restore a completed part removal from a stale refresh", async () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [part] }])),
|
||||
)
|
||||
await store.sync("child")
|
||||
store.apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id },
|
||||
})
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not cache skipped optimistic parts", () => {
|
||||
const message = userMessage("message")
|
||||
const part = { id: "part", sessionID: "child", messageID: message.id, type: "step-start" as const }
|
||||
const store = setup({ child: session("child") }).store
|
||||
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([])
|
||||
})
|
||||
|
||||
test("clears stale delta buffers when replacing optimistic parts", () => {
|
||||
const message = userMessage("message")
|
||||
const stale = textPart(message.id, { id: "stale", text: "stale" })
|
||||
const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" })
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [stale] })
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" },
|
||||
})
|
||||
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
|
||||
|
||||
expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined()
|
||||
expect(store.data.part_text_accum_delta[optimistic.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves removals during history prepend", async () => {
|
||||
const pending = deferredResponse()
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
const older = { ...latest, id: "message-1", time: { created: 1 } }
|
||||
const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise))
|
||||
await store.sync("child")
|
||||
const loading = store.history.loadMore("child")
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: older.id } })
|
||||
pending.resolve(response([{ info: older, parts: [] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([latest])
|
||||
})
|
||||
|
||||
test("preserves loaded history during an incomplete refresh", async () => {
|
||||
const older = userMessage("message-1")
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
const fresh = userMessage("message-3", { time: { created: 3 } })
|
||||
const store = createServerSession(
|
||||
messageClient(
|
||||
response(
|
||||
[
|
||||
{ info: older, parts: [] },
|
||||
{ info: latest, parts: [] },
|
||||
],
|
||||
"older",
|
||||
),
|
||||
response(
|
||||
[
|
||||
{ info: latest, parts: [] },
|
||||
{ info: fresh, parts: [] },
|
||||
],
|
||||
"older",
|
||||
),
|
||||
),
|
||||
)
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.message.child).toEqual([older, latest, fresh])
|
||||
})
|
||||
|
||||
test("drops stale recent messages omitted by an incomplete refresh", async () => {
|
||||
const third = userMessage("message-3", { time: { created: 3 } })
|
||||
const fourth = userMessage("message-4", { time: { created: 4 } })
|
||||
const stale = userMessage("message-5", { time: { created: 5 } })
|
||||
const store = createServerSession(
|
||||
messageClient(
|
||||
response(
|
||||
[
|
||||
{ info: fourth, parts: [] },
|
||||
{ info: stale, parts: [] },
|
||||
],
|
||||
"older",
|
||||
),
|
||||
response(
|
||||
[
|
||||
{ info: third, parts: [] },
|
||||
{ info: fourth, parts: [] },
|
||||
],
|
||||
"older",
|
||||
),
|
||||
),
|
||||
)
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.message.child).toEqual([third, fourth])
|
||||
})
|
||||
|
||||
test("uses message creation time for incomplete refresh boundaries", async () => {
|
||||
const older = userMessage("msg_z", { time: { created: 1 } })
|
||||
const boundary = userMessage("msg_m", { time: { created: 2 } })
|
||||
const stale = userMessage("msg_a", { time: { created: 3 } })
|
||||
const store = createServerSession(
|
||||
messageClient(
|
||||
response(
|
||||
[
|
||||
{ info: older, parts: [] },
|
||||
{ info: stale, parts: [] },
|
||||
],
|
||||
"older",
|
||||
),
|
||||
response([{ info: boundary, parts: [] }], "older"),
|
||||
),
|
||||
)
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.message.child).toEqual([boundary, older])
|
||||
})
|
||||
|
||||
test("preserves a part update for a message being loaded from history", async () => {
|
||||
const pending = deferredResponse()
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
const older = userMessage("message-1")
|
||||
const stale = textPart(older.id, { text: "stale" })
|
||||
const live = { ...stale, text: "live" }
|
||||
const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise))
|
||||
await store.sync("child")
|
||||
const loading = store.history.loadMore("child")
|
||||
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } })
|
||||
pending.resolve(response([{ info: older, parts: [stale] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[older.id]).toEqual([live])
|
||||
})
|
||||
|
||||
test("does not clear newer orphan parts after terminal history prepend", async () => {
|
||||
const pending = deferredResponse()
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
const older = userMessage("message-1")
|
||||
const newer = userMessage("message-3", { time: { created: 3 } })
|
||||
const part = textPart(newer.id, { text: "live" })
|
||||
const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise))
|
||||
await store.sync("child")
|
||||
const loading = store.history.loadMore("child")
|
||||
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 3 } })
|
||||
pending.resolve(response([{ info: older, parts: [] }]))
|
||||
await loading
|
||||
store.apply({ type: "message.updated", properties: { sessionID: "child", info: newer } })
|
||||
|
||||
expect(store.data.part[newer.id]).toEqual([part])
|
||||
})
|
||||
|
||||
test("accepts an authoritative history part after an earlier unknown-parent update", async () => {
|
||||
const pending = deferredResponse()
|
||||
const history = deferredResponse()
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
const older = userMessage("message-1")
|
||||
const part = textPart(older.id, { text: "live" })
|
||||
const store = createServerSession(messageClient(pending.promise, history.promise))
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
|
||||
pending.resolve(response([{ info: latest, parts: [] }], "older"))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[older.id]).toEqual([part])
|
||||
|
||||
const loadingHistory = store.history.loadMore("child")
|
||||
history.resolve(response([{ info: older, parts: [{ ...part, text: "stale" }] }]))
|
||||
await loadingHistory
|
||||
|
||||
expect(store.data.part[older.id]).toEqual([{ ...part, text: "stale" }])
|
||||
})
|
||||
|
||||
test("preserves an unknown-parent part removal across pages", async () => {
|
||||
const initial = deferredResponse()
|
||||
const history = deferredResponse()
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
const older = userMessage("message-1")
|
||||
const part = textPart(older.id)
|
||||
const store = createServerSession(messageClient(initial.promise, history.promise))
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: "child", messageID: older.id, partID: part.id },
|
||||
})
|
||||
initial.resolve(response([{ info: latest, parts: [] }], "older"))
|
||||
await loading
|
||||
const loadingHistory = store.history.loadMore("child")
|
||||
history.resolve(response([{ info: older, parts: [part] }]))
|
||||
await loadingHistory
|
||||
|
||||
expect(store.data.part[older.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("clears orphaned parts when a refresh drops a message", async () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "stale" })
|
||||
const store = createServerSession(messageClient(response([{ info: message, parts: [part] }]), response()))
|
||||
await store.sync("child")
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
|
||||
})
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.message.child).toEqual([])
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("applies events without a directory store", () => {
|
||||
const ctx = setup({})
|
||||
ctx.store.apply({ type: "session.created", properties: { info: session("root") } })
|
||||
ctx.store.apply({ type: "session.created", properties: { sessionID: "root", info: session("root") } })
|
||||
ctx.store.apply({ type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } })
|
||||
|
||||
expect(ctx.store.get("root")?.directory).toBe("/repo")
|
||||
expect(ctx.store.data.session_working("root")).toBe(true)
|
||||
expect(ctx.get).toEqual([])
|
||||
})
|
||||
|
||||
test("preserves pinned session content under server-wide cache pressure", () => {
|
||||
@@ -87,12 +1114,14 @@ describe("server session", () => {
|
||||
})
|
||||
|
||||
for (let index = 0; index < 50; index++) {
|
||||
ctx.store.remember(session(`session-${index}`))
|
||||
ctx.store.apply({
|
||||
type: "session.status",
|
||||
properties: { sessionID: `session-${index}`, status: { type: "busy" } },
|
||||
properties: { sessionID: `session-${index}`, status: { type: "idle" } },
|
||||
})
|
||||
}
|
||||
|
||||
expect(ctx.store.data.message.active?.map((message) => message.id)).toEqual(["message"])
|
||||
expect(ctx.store.data.session_status["session-0"]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,44 +18,67 @@ import { rootSession } from "@/utils/session-route"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const initialMessagePageSize = 2
|
||||
const historyMessagePageSize = 200
|
||||
const sessionInfoLimit = 2_048
|
||||
const emptyIDs: ReadonlySet<string> = new Set()
|
||||
|
||||
type OptimisticItem = {
|
||||
message: Message
|
||||
parts: Part[]
|
||||
confirmedParts?: Part[]
|
||||
confirmedMessage?: boolean
|
||||
}
|
||||
|
||||
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
if (!parts) return want.length === 0
|
||||
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
|
||||
type MessagePage = {
|
||||
session: Message[]
|
||||
part: { id: string; part: Part[] }[]
|
||||
cursor?: string
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
function mergeOptimisticPage(
|
||||
page: { session: Message[]; part: { id: string; part: Part[] }[]; cursor?: string; complete: boolean },
|
||||
items: OptimisticItem[],
|
||||
) {
|
||||
if (items.length === 0) return { ...page, confirmed: [] as string[] }
|
||||
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
|
||||
type MessageLoadState = {
|
||||
touchedMessages: Set<string>
|
||||
removedMessages: Set<string>
|
||||
retainedMessages: Set<string>
|
||||
touchedParts: Map<string, Set<string>>
|
||||
deltaParts: Map<string, Set<string>>
|
||||
carriedDeltaParts: Map<string, Set<string>>
|
||||
removedParts: Map<string, Set<string>>
|
||||
optimisticParts: Map<string, Set<string>>
|
||||
orphanParents: Set<string>
|
||||
clearedMessageParts: Set<string>
|
||||
}
|
||||
|
||||
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
|
||||
const session = [...page.session]
|
||||
const part = new Map(page.part.map((item) => [item.id, item.part]))
|
||||
const confirmed: string[] = []
|
||||
const observed: { messageID: string; parts: Part[] }[] = []
|
||||
for (const item of items) {
|
||||
const result = Binary.search(session, item.message.id, (message) => message.id)
|
||||
if (!result.found) session.splice(result.index, 0, item.message)
|
||||
const current = part.get(item.message.id)
|
||||
if (result.found && hasParts(current, item.parts)) {
|
||||
confirmed.push(item.message.id)
|
||||
continue
|
||||
}
|
||||
part.set(item.message.id, merge(current ?? [], item.parts))
|
||||
const confirmed = result.found
|
||||
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found)
|
||||
: []
|
||||
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
|
||||
part.set(
|
||||
item.message.id,
|
||||
merge(
|
||||
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
|
||||
item.parts.filter((part) => !confirmed.includes(part)),
|
||||
),
|
||||
)
|
||||
}
|
||||
return {
|
||||
...page,
|
||||
session,
|
||||
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
|
||||
confirmed,
|
||||
observed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +98,38 @@ function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
|
||||
return [...items.values()].sort((x, y) => cmp(x.id, y.id))
|
||||
}
|
||||
|
||||
export function createServerSession(client: OpencodeClient) {
|
||||
function reconcileFetched<T extends { id: string }>(
|
||||
fetched: T[],
|
||||
current: readonly T[],
|
||||
options: {
|
||||
touched?: ReadonlySet<string>
|
||||
retained?: ReadonlySet<string>
|
||||
preserveUnfetched?: boolean | ((item: T) => boolean)
|
||||
} = {},
|
||||
) {
|
||||
const result = new Map(fetched.map((item) => [item.id, item]))
|
||||
const live = new Map(current.map((item) => [item.id, item]))
|
||||
if (options.preserveUnfetched) {
|
||||
for (const item of current) {
|
||||
if (!result.has(item.id) && (options.preserveUnfetched === true || options.preserveUnfetched(item)))
|
||||
result.set(item.id, item)
|
||||
}
|
||||
}
|
||||
for (const id of options.retained ?? emptyIDs) {
|
||||
if (result.has(id)) continue
|
||||
const item = live.get(id)
|
||||
if (item) result.set(id, item)
|
||||
}
|
||||
// Events observed while the request is pending are the freshest client state for those identities.
|
||||
for (const id of options.touched ?? emptyIDs) {
|
||||
const item = live.get(id)
|
||||
if (item) result.set(id, item)
|
||||
if (!item) result.delete(id)
|
||||
}
|
||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
|
||||
const [data, setData] = createStore({
|
||||
info: {} as Record<string, Session | undefined>,
|
||||
session_status: {} as Record<string, SessionStatus>,
|
||||
@@ -95,10 +149,32 @@ export function createServerSession(client: OpencodeClient) {
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||
const messageLoads = new Map<string, MessageLoadState>()
|
||||
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
||||
const orphanParts = new Map<string, Set<string>>()
|
||||
const removedMessages = new Map<string, Set<string>>()
|
||||
const deltaBases = new Map<string, { base: string; sessionID: string }>()
|
||||
const deleteMessageParts = (
|
||||
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
|
||||
messageID: string,
|
||||
) => {
|
||||
for (const part of cache.part[messageID] ?? []) {
|
||||
delete cache.part_text_accum_delta[part.id]
|
||||
deltaBases.delete(part.id)
|
||||
}
|
||||
delete cache.part[messageID]
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const infoSeen = new Set<string>()
|
||||
const pinned = new Map<string, number>()
|
||||
const generations = new Map<string, number>()
|
||||
const generations = new Map<string, object>()
|
||||
const generation = (sessionID: string) => {
|
||||
const current = generations.get(sessionID)
|
||||
if (current) return current
|
||||
const created = {}
|
||||
generations.set(sessionID, created)
|
||||
return created
|
||||
}
|
||||
const [meta, setMeta] = createStore({
|
||||
limit: {} as Record<string, number | undefined>,
|
||||
cursor: {} as Record<string, string | undefined>,
|
||||
@@ -115,6 +191,11 @@ export function createServerSession(client: OpencodeClient) {
|
||||
const preserve = new Set([
|
||||
...pinned.keys(),
|
||||
...requests.keys(),
|
||||
...inflight.keys(),
|
||||
...inflightDiff.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...optimistic.keys(),
|
||||
...Object.entries(data.permission)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
@@ -138,6 +219,7 @@ export function createServerSession(client: OpencodeClient) {
|
||||
if (!preserve.has(sessionID)) stale.push(sessionID)
|
||||
}
|
||||
stale.forEach((sessionID) => infoSeen.delete(sessionID))
|
||||
stale.forEach((sessionID) => generations.delete(sessionID))
|
||||
setData(
|
||||
"info",
|
||||
produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])),
|
||||
@@ -151,21 +233,27 @@ export function createServerSession(client: OpencodeClient) {
|
||||
if (cached && !options?.force) return Promise.resolve(cached)
|
||||
const pending = requests.get(sessionID)
|
||||
if (pending) return pending
|
||||
const generation = generations.get(sessionID) ?? 0
|
||||
const active = generation(sessionID)
|
||||
const request = client.session.get({ sessionID }).then((result) => {
|
||||
if (!result.data) throw new Error(`Session not found: ${sessionID}`)
|
||||
if ((generations.get(sessionID) ?? 0) !== generation) return result.data
|
||||
if (generations.get(sessionID) !== active) return result.data
|
||||
return remember(result.data)
|
||||
})
|
||||
requests.set(sessionID, request)
|
||||
void request.then(
|
||||
() => {
|
||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
||||
},
|
||||
() => {
|
||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
||||
},
|
||||
)
|
||||
const cleanup = () => {
|
||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
||||
if (
|
||||
generations.get(sessionID) === active &&
|
||||
!data.info[sessionID] &&
|
||||
!requests.has(sessionID) &&
|
||||
!messageLoads.has(sessionID) &&
|
||||
!inflight.has(sessionID) &&
|
||||
!inflightDiff.has(sessionID) &&
|
||||
!inflightTodo.has(sessionID)
|
||||
)
|
||||
generations.delete(sessionID)
|
||||
}
|
||||
void request.then(cleanup, cleanup)
|
||||
return request
|
||||
}
|
||||
|
||||
@@ -195,15 +283,121 @@ export function createServerSession(client: OpencodeClient) {
|
||||
if (items.size === 0) optimistic.delete(sessionID)
|
||||
}
|
||||
|
||||
const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => {
|
||||
const items = optimistic.get(sessionID)
|
||||
const item = items?.get(messageID)
|
||||
if (!items || !item) return
|
||||
const parts = item.parts.filter((part) => part.id !== partID)
|
||||
const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID)
|
||||
if (parts.length === 0) {
|
||||
clearOptimistic(sessionID, messageID)
|
||||
return
|
||||
}
|
||||
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
|
||||
}
|
||||
|
||||
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
|
||||
const items = optimistic.get(sessionID)
|
||||
const item = items?.get(messageID)
|
||||
if (!items || !item) return
|
||||
const parts = item.parts.filter((value) => value.id !== part.id)
|
||||
if (parts.length === 0) {
|
||||
clearOptimistic(sessionID, messageID)
|
||||
return
|
||||
}
|
||||
items.set(messageID, {
|
||||
...item,
|
||||
parts,
|
||||
confirmedParts: merge(item.confirmedParts ?? [], [part]),
|
||||
confirmedMessage: true,
|
||||
})
|
||||
}
|
||||
|
||||
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
|
||||
const items = optimistic.get(sessionID)
|
||||
const item = items?.get(messageID)
|
||||
if (!items || !item) return
|
||||
const confirmed = new Set(confirmedParts.map((part) => part.id))
|
||||
const parts = item.parts.filter((part) => !confirmed.has(part.id))
|
||||
if (parts.length === 0) {
|
||||
clearOptimistic(sessionID, messageID)
|
||||
return
|
||||
}
|
||||
items.set(messageID, {
|
||||
...item,
|
||||
parts,
|
||||
confirmedParts: merge(item.confirmedParts ?? [], confirmedParts),
|
||||
confirmedMessage: true,
|
||||
})
|
||||
}
|
||||
|
||||
const trackPartChange = (sessionID: string, messageID: string, partID: string) => {
|
||||
const load = messageLoads.get(sessionID)
|
||||
if (!load) return
|
||||
// A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata.
|
||||
const messages = data.message[sessionID]
|
||||
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
||||
load.retainedMessages.add(messageID)
|
||||
const parts = load.touchedParts.get(messageID)
|
||||
if (parts) {
|
||||
parts.add(partID)
|
||||
return
|
||||
}
|
||||
load.touchedParts.set(messageID, new Set([partID]))
|
||||
}
|
||||
|
||||
const resetMessageLoad = (sessionID: string, load: MessageLoadState) => {
|
||||
load.touchedMessages.clear()
|
||||
load.retainedMessages.clear()
|
||||
load.touchedParts.clear()
|
||||
load.carriedDeltaParts.clear()
|
||||
load.clearedMessageParts.clear()
|
||||
for (const messageID of load.removedMessages) {
|
||||
load.touchedMessages.add(messageID)
|
||||
load.clearedMessageParts.add(messageID)
|
||||
}
|
||||
for (const [messageID, parts] of load.deltaParts) {
|
||||
load.touchedParts.set(messageID, new Set(parts))
|
||||
load.carriedDeltaParts.set(messageID, new Set(parts))
|
||||
const messages = data.message[sessionID]
|
||||
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
||||
load.retainedMessages.add(messageID)
|
||||
}
|
||||
for (const [messageID, parts] of load.removedParts) {
|
||||
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
||||
parts.forEach((partID) => touched.add(partID))
|
||||
load.touchedParts.set(messageID, touched)
|
||||
const messages = data.message[sessionID]
|
||||
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
||||
load.retainedMessages.add(messageID)
|
||||
}
|
||||
for (const [messageID, parts] of load.optimisticParts) {
|
||||
load.removedMessages.delete(messageID)
|
||||
load.clearedMessageParts.add(messageID)
|
||||
load.touchedMessages.add(messageID)
|
||||
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
||||
parts.forEach((partID) => touched.add(partID))
|
||||
load.touchedParts.set(messageID, touched)
|
||||
}
|
||||
}
|
||||
|
||||
const evict = (sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
const evicted = new Set(sessionIDs)
|
||||
for (const [partID, item] of deltaBases) {
|
||||
if (evicted.has(item.sessionID)) deltaBases.delete(partID)
|
||||
}
|
||||
sessionIDs.forEach((sessionID) => {
|
||||
generations.set(sessionID, (generations.get(sessionID) ?? 0) + 1)
|
||||
generations.delete(sessionID)
|
||||
clearOptimistic(sessionID)
|
||||
requests.delete(sessionID)
|
||||
inflight.delete(sessionID)
|
||||
inflightDiff.delete(sessionID)
|
||||
inflightTodo.delete(sessionID)
|
||||
messageLoads.delete(sessionID)
|
||||
pendingParts.delete(sessionID)
|
||||
orphanParts.delete(sessionID)
|
||||
removedMessages.delete(sessionID)
|
||||
})
|
||||
setData(
|
||||
produce((draft) => {
|
||||
@@ -230,6 +424,7 @@ export function createServerSession(client: OpencodeClient) {
|
||||
...inflight.keys(),
|
||||
...inflightDiff.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...optimistic.keys(),
|
||||
...Object.entries(data.permission)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
@@ -247,8 +442,11 @@ export function createServerSession(client: OpencodeClient) {
|
||||
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
|
||||
)
|
||||
|
||||
const fetchMessages = async (sessionID: string, limit: number, before?: string) => {
|
||||
const response = await retry(() => client.session.messages({ sessionID, limit, before }))
|
||||
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return client.session.messages({ sessionID, limit, before })
|
||||
})
|
||||
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
||||
return {
|
||||
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
||||
@@ -261,30 +459,153 @@ export function createServerSession(client: OpencodeClient) {
|
||||
}
|
||||
}
|
||||
|
||||
const replaceMessages = (sessionID: string, messages: Message[]) => {
|
||||
const messageIDs = new Set(messages.map((message) => message.id))
|
||||
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
|
||||
setData("message", sessionID, reconcile(messages, { key: "id" }))
|
||||
setData(
|
||||
produce((draft) => {
|
||||
for (const message of dropped) deleteMessageParts(draft, message.id)
|
||||
}),
|
||||
)
|
||||
return messageIDs
|
||||
}
|
||||
|
||||
const replaceParts = (
|
||||
sessionID: string,
|
||||
items: MessagePage["part"],
|
||||
messageIDs: Set<string>,
|
||||
load?: MessageLoadState,
|
||||
) => {
|
||||
for (const item of items) {
|
||||
if (!messageIDs.has(item.id)) continue
|
||||
const fetched = load?.clearedMessageParts.has(item.id)
|
||||
? []
|
||||
: item.part.filter((part) => !SKIP_PARTS.has(part.type))
|
||||
const fetchedIDs = new Set(fetched.map((part) => part.id))
|
||||
const pending = pendingParts.get(sessionID)?.get(item.id)
|
||||
const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])])
|
||||
for (const part of fetched) {
|
||||
const accumulated = data.part_text_accum_delta[part.id]
|
||||
const base = deltaBases.get(part.id)?.base
|
||||
const preserveDelta =
|
||||
base !== undefined &&
|
||||
accumulated !== undefined &&
|
||||
"text" in part &&
|
||||
typeof part.text === "string" &&
|
||||
part.text.startsWith(base) &&
|
||||
accumulated.startsWith(part.text) &&
|
||||
accumulated !== part.text
|
||||
if (preserveDelta) touched.add(part.id)
|
||||
if (load?.carriedDeltaParts.get(item.id)?.has(part.id) && !preserveDelta) touched.delete(part.id)
|
||||
}
|
||||
for (const partID of load?.carriedDeltaParts.get(item.id) ?? []) {
|
||||
if (!fetchedIDs.has(partID)) touched.delete(partID)
|
||||
}
|
||||
const parts = reconcileFetched(fetched, data.part[item.id] ?? [], { touched })
|
||||
if (!parts.length) {
|
||||
orphanParts.get(sessionID)?.delete(item.id)
|
||||
setData(produce((draft) => deleteMessageParts(draft, item.id)))
|
||||
continue
|
||||
}
|
||||
const partIDs = new Set(parts.map((part) => part.id))
|
||||
setData(
|
||||
"part_text_accum_delta",
|
||||
produce((draft) => {
|
||||
for (const part of data.part[item.id] ?? []) {
|
||||
if (!partIDs.has(part.id) || !touched.has(part.id)) {
|
||||
delete draft[part.id]
|
||||
deltaBases.delete(part.id)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
setData("part", item.id, reconcile(parts, { key: "id" }))
|
||||
orphanParts.get(sessionID)?.delete(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
const applyMessagePage = (
|
||||
sessionID: string,
|
||||
page: MessagePage,
|
||||
load: MessageLoadState | undefined,
|
||||
preserveUnfetched: boolean | ((message: Message) => boolean),
|
||||
cleanupOrphans: boolean,
|
||||
) => {
|
||||
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||
merged.observed.forEach((item) => {
|
||||
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
|
||||
})
|
||||
const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])])
|
||||
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
|
||||
touched: touchedMessages,
|
||||
retained: load?.retainedMessages,
|
||||
preserveUnfetched,
|
||||
})
|
||||
batch(() => {
|
||||
const messageIDs = replaceMessages(sessionID, messages)
|
||||
replaceParts(sessionID, merged.part, messageIDs, load)
|
||||
const orphans = orphanParts.get(sessionID)
|
||||
if (cleanupOrphans && page.complete && orphans) {
|
||||
for (const messageID of orphans) {
|
||||
if (!messageIDs.has(messageID)) setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||
}
|
||||
orphanParts.delete(sessionID)
|
||||
}
|
||||
setMeta("limit", sessionID, messages.length)
|
||||
setMeta("cursor", sessionID, merged.cursor)
|
||||
setMeta("complete", sessionID, merged.complete)
|
||||
setMeta("at", sessionID, Date.now())
|
||||
})
|
||||
}
|
||||
|
||||
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
|
||||
if (meta.loading[sessionID]) return
|
||||
const generation = generations.get(sessionID) ?? 0
|
||||
const active = generation(sessionID)
|
||||
const load: MessageLoadState = {
|
||||
touchedMessages: new Set(),
|
||||
removedMessages: new Set(),
|
||||
retainedMessages: new Set(),
|
||||
touchedParts: new Map(),
|
||||
deltaParts: new Map(),
|
||||
carriedDeltaParts: new Map(),
|
||||
removedParts: new Map(),
|
||||
optimisticParts: new Map(),
|
||||
orphanParents: new Set(),
|
||||
clearedMessageParts: new Set(),
|
||||
}
|
||||
messageLoads.set(sessionID, load)
|
||||
setMeta("loading", sessionID, true)
|
||||
await fetchMessages(sessionID, limit, before)
|
||||
let applied = false
|
||||
await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
||||
.then((page) => {
|
||||
if ((generations.get(sessionID) ?? 0) !== generation) return
|
||||
const next = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||
next.confirmed.forEach((messageID) => clearOptimistic(sessionID, messageID))
|
||||
const messages = mode === "prepend" ? merge(data.message[sessionID] ?? [], next.session) : next.session
|
||||
batch(() => {
|
||||
setData("message", sessionID, reconcile(messages, { key: "id" }))
|
||||
for (const item of next.part) {
|
||||
const parts = item.part.filter((part) => !SKIP_PARTS.has(part.type))
|
||||
if (parts.length) setData("part", item.id, reconcile(parts, { key: "id" }))
|
||||
}
|
||||
setMeta("limit", sessionID, messages.length)
|
||||
setMeta("cursor", sessionID, next.cursor)
|
||||
setMeta("complete", sessionID, next.complete)
|
||||
setMeta("at", sessionID, Date.now())
|
||||
})
|
||||
if (generations.get(sessionID) !== active) return
|
||||
const first = page.session.reduce<Message | undefined>(
|
||||
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
|
||||
undefined,
|
||||
)
|
||||
const preserveUnfetched =
|
||||
mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
|
||||
applyMessagePage(
|
||||
sessionID,
|
||||
page,
|
||||
messageLoads.get(sessionID) === load ? load : undefined,
|
||||
preserveUnfetched,
|
||||
mode !== "prepend",
|
||||
)
|
||||
applied = true
|
||||
})
|
||||
.finally(() => {
|
||||
if ((generations.get(sessionID) ?? 0) === generation) setMeta("loading", sessionID, false)
|
||||
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
|
||||
for (const messageID of load.orphanParents) {
|
||||
if (!orphanParts.get(sessionID)?.has(messageID)) continue
|
||||
setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||
orphanParts.get(sessionID)?.delete(messageID)
|
||||
}
|
||||
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
|
||||
}
|
||||
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
|
||||
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -339,7 +660,13 @@ export function createServerSession(client: OpencodeClient) {
|
||||
const eventID = eventSessionID(event)
|
||||
if (eventID) {
|
||||
touch(eventID)
|
||||
if (!data.info[eventID]) void resolve(eventID).catch(() => {})
|
||||
if (
|
||||
!data.info[eventID] &&
|
||||
event.type !== "session.created" &&
|
||||
event.type !== "session.updated" &&
|
||||
event.type !== "session.deleted"
|
||||
)
|
||||
void resolve(eventID).catch(() => {})
|
||||
}
|
||||
switch (event.type) {
|
||||
case "session.created":
|
||||
@@ -378,6 +705,21 @@ export function createServerSession(client: OpencodeClient) {
|
||||
}
|
||||
case "message.updated": {
|
||||
const info = cleanMessage((event.properties as { info: Message }).info)
|
||||
const load = messageLoads.get(info.sessionID)
|
||||
load?.touchedMessages.add(info.id)
|
||||
load?.removedMessages.delete(info.id)
|
||||
const items = optimistic.get(info.sessionID)
|
||||
const item = items?.get(info.id)
|
||||
if (items && item) {
|
||||
if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id)
|
||||
if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true })
|
||||
}
|
||||
const orphans = orphanParts.get(info.sessionID)
|
||||
orphans?.delete(info.id)
|
||||
if (orphans?.size === 0) orphanParts.delete(info.sessionID)
|
||||
const removedMessagesForSession = removedMessages.get(info.sessionID)
|
||||
removedMessagesForSession?.delete(info.id)
|
||||
if (removedMessagesForSession?.size === 0) removedMessages.delete(info.sessionID)
|
||||
const messages = data.message[info.sessionID]
|
||||
if (!messages) {
|
||||
setData("message", info.sessionID, [info])
|
||||
@@ -395,6 +737,20 @@ export function createServerSession(client: OpencodeClient) {
|
||||
}
|
||||
case "message.removed": {
|
||||
const props = event.properties as { sessionID: string; messageID: string }
|
||||
const load = messageLoads.get(props.sessionID)
|
||||
load?.touchedMessages.add(props.messageID)
|
||||
load?.removedMessages.add(props.messageID)
|
||||
load?.clearedMessageParts.add(props.messageID)
|
||||
load?.deltaParts.delete(props.messageID)
|
||||
load?.carriedDeltaParts.delete(props.messageID)
|
||||
load?.removedParts.delete(props.messageID)
|
||||
load?.optimisticParts.delete(props.messageID)
|
||||
pendingParts.get(props.sessionID)?.delete(props.messageID)
|
||||
if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID)
|
||||
const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>()
|
||||
removedMessagesForSession.add(props.messageID)
|
||||
removedMessages.set(props.sessionID, removedMessagesForSession)
|
||||
clearOptimistic(props.sessionID, props.messageID)
|
||||
setData(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[props.sessionID]
|
||||
@@ -402,8 +758,7 @@ export function createServerSession(client: OpencodeClient) {
|
||||
const result = Binary.search(messages, props.messageID, (message) => message.id)
|
||||
if (result.found) messages.splice(result.index, 1)
|
||||
}
|
||||
for (const part of draft.part[props.messageID] ?? []) delete draft.part_text_accum_delta[part.id]
|
||||
delete draft.part[props.messageID]
|
||||
deleteMessageParts(draft, props.messageID)
|
||||
}),
|
||||
)
|
||||
return
|
||||
@@ -411,6 +766,42 @@ export function createServerSession(client: OpencodeClient) {
|
||||
case "message.part.updated": {
|
||||
const part = (event.properties as { part: Part }).part
|
||||
if (SKIP_PARTS.has(part.type)) return
|
||||
const messages = data.message[part.sessionID]
|
||||
const load = messageLoads.get(part.sessionID)
|
||||
const missing = !messages || !Binary.search(messages, part.messageID, (message) => message.id).found
|
||||
// Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan.
|
||||
if (
|
||||
missing &&
|
||||
(!load ||
|
||||
load.clearedMessageParts.has(part.messageID) ||
|
||||
removedMessages.get(part.sessionID)?.has(part.messageID))
|
||||
)
|
||||
return
|
||||
if (missing) {
|
||||
const orphans = orphanParts.get(part.sessionID) ?? new Set<string>()
|
||||
orphans.add(part.messageID)
|
||||
orphanParts.set(part.sessionID, orphans)
|
||||
load?.orphanParents.add(part.messageID)
|
||||
}
|
||||
const deltas = load?.deltaParts.get(part.messageID)
|
||||
deltas?.delete(part.id)
|
||||
if (deltas?.size === 0) load?.deltaParts.delete(part.messageID)
|
||||
const carried = load?.carriedDeltaParts.get(part.messageID)
|
||||
carried?.delete(part.id)
|
||||
if (carried?.size === 0) load?.carriedDeltaParts.delete(part.messageID)
|
||||
const removed = load?.removedParts.get(part.messageID)
|
||||
removed?.delete(part.id)
|
||||
if (removed?.size === 0) load?.removedParts.delete(part.messageID)
|
||||
const pending = pendingParts.get(part.sessionID)?.get(part.messageID)
|
||||
pending?.delete(part.id)
|
||||
if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID)
|
||||
if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID)
|
||||
const optimistic = load?.optimisticParts.get(part.messageID)
|
||||
optimistic?.delete(part.id)
|
||||
if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID)
|
||||
deltaBases.delete(part.id)
|
||||
trackPartChange(part.sessionID, part.messageID, part.id)
|
||||
confirmOptimisticPart(part.sessionID, part.messageID, part)
|
||||
setData(
|
||||
"part_text_accum_delta",
|
||||
produce((draft) => void delete draft[part.id]),
|
||||
@@ -431,10 +822,34 @@ export function createServerSession(client: OpencodeClient) {
|
||||
return
|
||||
}
|
||||
case "message.part.removed": {
|
||||
const props = event.properties as { messageID: string; partID: string }
|
||||
const props = event.properties as { sessionID: string; messageID: string; partID: string }
|
||||
// Part removal is event-only on the server, so its tombstone lasts until a later update or eviction.
|
||||
const pending = pendingParts.get(props.sessionID) ?? new Map<string, Set<string>>()
|
||||
const parts = pending.get(props.messageID) ?? new Set<string>()
|
||||
parts.add(props.partID)
|
||||
pending.set(props.messageID, parts)
|
||||
pendingParts.set(props.sessionID, pending)
|
||||
const deltas = messageLoads.get(props.sessionID)?.deltaParts.get(props.messageID)
|
||||
deltas?.delete(props.partID)
|
||||
if (deltas?.size === 0) messageLoads.get(props.sessionID)?.deltaParts.delete(props.messageID)
|
||||
const load = messageLoads.get(props.sessionID)
|
||||
const carried = load?.carriedDeltaParts.get(props.messageID)
|
||||
carried?.delete(props.partID)
|
||||
if (carried?.size === 0) load?.carriedDeltaParts.delete(props.messageID)
|
||||
if (load) {
|
||||
const parts = load.removedParts.get(props.messageID) ?? new Set<string>()
|
||||
parts.add(props.partID)
|
||||
load.removedParts.set(props.messageID, parts)
|
||||
const optimistic = load.optimisticParts.get(props.messageID)
|
||||
optimistic?.delete(props.partID)
|
||||
if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID)
|
||||
}
|
||||
trackPartChange(props.sessionID, props.messageID, props.partID)
|
||||
clearOptimisticPart(props.sessionID, props.messageID, props.partID)
|
||||
setData(
|
||||
produce((draft) => {
|
||||
delete draft.part_text_accum_delta[props.partID]
|
||||
deltaBases.delete(props.partID)
|
||||
const parts = draft.part[props.messageID]
|
||||
if (!parts) return
|
||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||
@@ -445,13 +860,31 @@ export function createServerSession(client: OpencodeClient) {
|
||||
return
|
||||
}
|
||||
case "message.part.delta": {
|
||||
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
|
||||
const props = event.properties as {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
partID: string
|
||||
field: string
|
||||
delta: string
|
||||
}
|
||||
const parts = data.part[props.messageID]
|
||||
if (!parts) return
|
||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||
if (!result.found) return
|
||||
trackPartChange(props.sessionID, props.messageID, props.partID)
|
||||
const load = messageLoads.get(props.sessionID)
|
||||
if (load) {
|
||||
const parts = load.deltaParts.get(props.messageID) ?? new Set<string>()
|
||||
parts.add(props.partID)
|
||||
load.deltaParts.set(props.messageID, parts)
|
||||
const carried = load.carriedDeltaParts.get(props.messageID)
|
||||
carried?.delete(props.partID)
|
||||
if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID)
|
||||
}
|
||||
const field = props.field as keyof (typeof parts)[number]
|
||||
const current = parts[result.index]?.[field]
|
||||
if (!deltaBases.has(props.partID) && typeof current === "string")
|
||||
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
|
||||
setData(
|
||||
"part_text_accum_delta",
|
||||
props.partID,
|
||||
@@ -559,32 +992,70 @@ export function createServerSession(client: OpencodeClient) {
|
||||
},
|
||||
optimistic: {
|
||||
add(input: { sessionID: string; message: Message; parts: Part[] }) {
|
||||
const parts = input.parts
|
||||
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
const load = messageLoads.get(input.sessionID)
|
||||
if (load?.clearedMessageParts.has(input.message.id)) {
|
||||
const touched = load.touchedParts.get(input.message.id) ?? new Set<string>()
|
||||
parts.forEach((part) => touched.add(part.id))
|
||||
load.touchedParts.set(input.message.id, touched)
|
||||
}
|
||||
if (load) {
|
||||
load.removedMessages.delete(input.message.id)
|
||||
load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id)))
|
||||
}
|
||||
const items = optimistic.get(input.sessionID)
|
||||
if (items) items.set(input.message.id, input)
|
||||
if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, input]]))
|
||||
const removedMessagesForSession = removedMessages.get(input.sessionID)
|
||||
removedMessagesForSession?.delete(input.message.id)
|
||||
if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID)
|
||||
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
||||
if (!items)
|
||||
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
|
||||
setData(
|
||||
"part",
|
||||
input.message.id,
|
||||
input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
"part_text_accum_delta",
|
||||
produce((draft) => {
|
||||
for (const part of [...(data.part[input.message.id] ?? []), ...parts]) {
|
||||
delete draft[part.id]
|
||||
deltaBases.delete(part.id)
|
||||
}
|
||||
}),
|
||||
)
|
||||
setData("part", input.message.id, parts)
|
||||
},
|
||||
remove(input: { sessionID: string; messageID: string }) {
|
||||
const item = optimistic.get(input.sessionID)?.get(input.messageID)
|
||||
if (!item) return
|
||||
messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID)
|
||||
clearOptimistic(input.sessionID, input.messageID)
|
||||
if (item.confirmedMessage) {
|
||||
const partIDs = new Set(item.parts.map((part) => part.id))
|
||||
setData(
|
||||
produce((draft) => {
|
||||
for (const part of item.parts) {
|
||||
delete draft.part_text_accum_delta[part.id]
|
||||
deltaBases.delete(part.id)
|
||||
}
|
||||
const parts = draft.part[input.messageID]
|
||||
if (!parts) return
|
||||
draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id))
|
||||
if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID]
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
||||
setData(
|
||||
"part",
|
||||
produce((draft) => void delete draft[input.messageID]),
|
||||
)
|
||||
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||
},
|
||||
},
|
||||
diff(sessionID: string, options?: { force?: boolean }) {
|
||||
touch(sessionID)
|
||||
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||
return runInflight(inflightDiff, sessionID, () => {
|
||||
const generation = generations.get(sessionID) ?? 0
|
||||
const active = generation(sessionID)
|
||||
return retry(() => client.session.diff({ sessionID })).then((result) => {
|
||||
if ((generations.get(sessionID) ?? 0) !== generation) return
|
||||
if (generations.get(sessionID) !== active) return
|
||||
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
|
||||
})
|
||||
})
|
||||
@@ -593,9 +1064,9 @@ export function createServerSession(client: OpencodeClient) {
|
||||
touch(sessionID)
|
||||
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||
return runInflight(inflightTodo, sessionID, () => {
|
||||
const generation = generations.get(sessionID) ?? 0
|
||||
const active = generation(sessionID)
|
||||
return retry(() => client.session.todo({ sessionID })).then((result) => {
|
||||
if ((generations.get(sessionID) ?? 0) !== generation) return
|
||||
if (generations.get(sessionID) !== active) return
|
||||
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
Config,
|
||||
McpResource,
|
||||
OpencodeClient,
|
||||
Path,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||
@@ -15,6 +22,7 @@ import {
|
||||
loadPathQuery,
|
||||
loadProjectsQuery,
|
||||
loadProvidersQuery,
|
||||
loadReferencesQuery,
|
||||
} from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
@@ -56,6 +64,13 @@ export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: Opencod
|
||||
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
|
||||
})
|
||||
|
||||
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||
queryOptions<Record<string, McpResource>>({
|
||||
queryKey: [scope, directory, "mcpResources"] as const,
|
||||
queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
|
||||
placeholderData: {},
|
||||
})
|
||||
|
||||
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "lsp"] as const,
|
||||
@@ -75,7 +90,9 @@ function makeQueryOptionsApi(
|
||||
path: (directory: PathKey | null) =>
|
||||
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
|
||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
|
||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
|
||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
|
||||
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
|
||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
|
||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||
}
|
||||
@@ -396,6 +413,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
loadLsp: () => {
|
||||
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
|
||||
},
|
||||
loadReferences: () => {
|
||||
void queryClient.fetchQuery(queryOptionsApi.references(key))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -484,6 +504,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
},
|
||||
refresh: async () => {
|
||||
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
|
||||
await queryClient.refetchQueries(queryOptionsApi.mcpResources(key))
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
@@ -51,7 +51,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const fallback = server.key
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.global("tabs"),
|
||||
...Persist.window("tabs"),
|
||||
migrate: (value: unknown) => {
|
||||
if (!Array.isArray(value)) return value
|
||||
return value.map((tab) => {
|
||||
@@ -62,7 +62,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
},
|
||||
createStore<Tab[]>([]),
|
||||
)
|
||||
const [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), createStore<RecentTab>({}))
|
||||
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
|
||||
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
@@ -138,12 +138,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const next = { type: "session" as const, ...tab }
|
||||
const existing = store.find((item) => tabKey(item) === tabKey(next))
|
||||
if (existing) return existing
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
|
||||
tabs.push(next)
|
||||
}),
|
||||
)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
|
||||
tabs.push(next)
|
||||
}),
|
||||
)
|
||||
})
|
||||
return next
|
||||
},
|
||||
reorder(keys: string[]) {
|
||||
@@ -163,27 +165,29 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
},
|
||||
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
|
||||
const draftID = uuid()
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs.push({ type: "draft", draftID, ...draft })
|
||||
}),
|
||||
)
|
||||
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs.push({ type: "draft", draftID, ...draft })
|
||||
}),
|
||||
)
|
||||
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
|
||||
})
|
||||
},
|
||||
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
||||
setStore(
|
||||
(tab) => tab.type === "draft" && tab.draftID === draftID,
|
||||
produce((tab) => Object.assign(tab, draft)),
|
||||
)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
(tab) => tab.type === "draft" && tab.draftID === draftID,
|
||||
produce((tab) => Object.assign(tab, draft)),
|
||||
)
|
||||
})
|
||||
},
|
||||
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
|
||||
// We're viewing this draft when /new-session?draftId=… points at it. Promoting
|
||||
// replaces the draft tab with a session tab, so the draft route would stop resolving
|
||||
// and fall back home. Navigate to the new session first so we leave /new-session
|
||||
// before the draft is removed from the store.
|
||||
// Keep the replacement and navigation atomic so /new-session never renders
|
||||
// after its backing draft tab has been removed from the store.
|
||||
const active = location.pathname === "/new-session" && location.query.draftId === draftID
|
||||
const next = { type: "session" as const, ...session }
|
||||
startTransition(() => {
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { DESKTOP_MENU } from "./desktop-menu"
|
||||
|
||||
describe("desktop menu", () => {
|
||||
test("exports logs through the desktop command registry", () => {
|
||||
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||
(item) => item.type === "item" && item.label === "Export Logs...",
|
||||
)
|
||||
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -388,6 +388,20 @@ export const dict = {
|
||||
"wsl.onboarding.chooseDistroFirst": "Choose a distro first.",
|
||||
"wsl.onboarding.loadFailed": "Failed to load WSL state.",
|
||||
"wsl.onboarding.loading": "Loading...",
|
||||
"wsl.onboarding.installedDistros": "Installed distros",
|
||||
"wsl.onboarding.checkAgain": "Check again",
|
||||
"wsl.onboarding.distroStatus.ready": "Ready",
|
||||
"wsl.onboarding.distroStatus.checking": "Checking...",
|
||||
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode not installed",
|
||||
"wsl.onboarding.distroStatus.missingTools": "Missing bash, curl",
|
||||
"wsl.onboarding.distroStatus.unsupported": "Unsupported · Use WSL 2",
|
||||
"wsl.onboarding.needAnotherDistro": "Need another distro?",
|
||||
"wsl.onboarding.needAnotherDistroHint": "Install a Linux distribution from the WSL catalog",
|
||||
"wsl.onboarding.wslNotInstalled.title": "WSL not installed",
|
||||
"wsl.onboarding.wslNotInstalled.description":
|
||||
"WSL (Windows Subsystem for Linux) is required before OpenCode can add a WSL server",
|
||||
"wsl.onboarding.wslUnavailable.title": "WSL unavailable",
|
||||
"wsl.onboarding.wslUnavailable.description": "OpenCode could not verify WSL on this machine.",
|
||||
"wsl.onboarding.installWsl": "Install WSL",
|
||||
"wsl.onboarding.windowsRestartRequired": "Restart Windows to finish installing WSL, then reopen OpenCode.",
|
||||
"wsl.onboarding.next": "Next",
|
||||
@@ -397,6 +411,7 @@ export const dict = {
|
||||
"wsl.onboarding.install": "Install",
|
||||
"wsl.onboarding.installing": "Installing...",
|
||||
"wsl.onboarding.installDistro": "Install distro",
|
||||
"wsl.onboarding.searchDistros": "Search distros",
|
||||
"wsl.onboarding.wsl2Required": "WSL 2 is required.",
|
||||
"wsl.onboarding.toolsRequired": "This distro needs bash and curl.",
|
||||
"wsl.onboarding.openTerminal": "Open terminal",
|
||||
@@ -604,7 +619,12 @@ export const dict = {
|
||||
"session.tab.session": "Session",
|
||||
"session.tab.review": "Review",
|
||||
"session.tab.context": "Context",
|
||||
"session.tab.unknown": "Unknown Session",
|
||||
"session.panel.reviewAndFiles": "Review and files",
|
||||
"session.error.notFound": "This session cannot be found",
|
||||
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
|
||||
"session.error.notFound.closeTab": "Close Tab",
|
||||
"session.error.serverConnection": "Can't connect to this server",
|
||||
"session.review.filesChanged": "{{count}} Files Changed",
|
||||
"session.review.change.one": "Change",
|
||||
"session.review.change.other": "Changes",
|
||||
@@ -637,6 +657,10 @@ export const dict = {
|
||||
"session.todo.expand": "Expand",
|
||||
"session.todo.progress": "{{done}} of {{total}} todos completed",
|
||||
"session.question.progress": "{{current}} of {{total}} questions",
|
||||
"session.question.minimize": "Minimize question",
|
||||
"session.question.restore": "Restore question",
|
||||
"session.question.pending.one": "{{count}} pending question",
|
||||
"session.question.pending.other": "{{count}} pending questions",
|
||||
"session.followupDock.summary.one": "{{count}} queued message",
|
||||
"session.followupDock.summary.other": "{{count}} queued messages",
|
||||
"session.followupDock.sendNow": "Send now",
|
||||
@@ -656,6 +680,10 @@ export const dict = {
|
||||
"session.new.worktree.main": "Main branch",
|
||||
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
||||
"session.new.worktree.create": "Create new worktree",
|
||||
"session.new.workspace.runIn": "Run session in",
|
||||
"session.new.workspace.triggerLocal": "Local",
|
||||
"session.new.workspace.local": "Local repository",
|
||||
"session.new.workspace.existing": "Workspace…",
|
||||
"session.new.lastModified": "Last modified",
|
||||
|
||||
"session.header.search.placeholder": "Search {{project}}",
|
||||
|
||||
+152
-3
@@ -1,6 +1,7 @@
|
||||
@import "@opencode-ai/ui/styles/tailwind";
|
||||
@import "@opencode-ai/session-ui/styles";
|
||||
@import "@opencode-ai/ui/v2/styles/tailwind.css";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrainsMono Nerd Font Mono";
|
||||
@@ -107,6 +108,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
.home-session-group-header::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
background: var(--v2-background-bg-base);
|
||||
}
|
||||
|
||||
.home-session-group-header::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--v2-background-bg-base) 0%,
|
||||
color-mix(in srgb, var(--v2-background-bg-base) 92.0456%, transparent) 7.93%,
|
||||
color-mix(in srgb, var(--v2-background-bg-base) 84.9947%, transparent) 14.14%,
|
||||
color-mix(in srgb, var(--v2-background-bg-base) 78.6813%, transparent) 19%,
|
||||
color-mix(in srgb, var(--v2-background-bg-base) 72.9394%, transparent) 22.85%,
|
||||
color-mix(in srgb, var(--v2-background-bg-base) 67.6028%, transparent) 26.05%,
|
||||
color-mix(in srgb, var(--v2-background-bg-base) 62.5055%, transparent) 28.95%,
|
||||
color-mix(in srgb, var(--v2-background-bg-base) 57.4815%, transparent) 31.91%,
|
||||
color-mix(in srgb, var(--v2-background-bg-base) 52.3647%, transparent) 35.27%,
|
||||
color-mix(in srgb, var(--v2-background-bg-base) 46.989%, transparent) 39.4%,
|
||||
color-mix(in srgb, var(--v2-background-bg-base) 41.1884%, transparent) 44.65%,
|
||||
color-mix(in srgb, var(--v2-background-bg-base) 34.7969%, transparent) 51.36%,
|
||||
color-mix(in srgb, var(--v2-background-bg-base) 27.6484%, transparent) 59.9%,
|
||||
color-mix(in srgb, var(--v2-background-bg-base) 19.5767%, transparent) 70.62%,
|
||||
color-mix(in srgb, var(--v2-background-bg-base) 10.416%, transparent) 83.87%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-slot="titlebar-update-loader"] {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
@@ -132,12 +172,121 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
@keyframes home-projects-fade-top {
|
||||
from {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes home-projects-fade-bottom {
|
||||
from {
|
||||
visibility: visible;
|
||||
}
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="home-projects-scroll"] {
|
||||
timeline-scope: --home-projects-scroll;
|
||||
}
|
||||
|
||||
[data-slot="model-selector-scroll"] {
|
||||
timeline-scope: --model-selector-scroll;
|
||||
}
|
||||
|
||||
[data-slot="manage-models-scroll"] {
|
||||
timeline-scope: --manage-models-scroll;
|
||||
}
|
||||
|
||||
[data-slot="home-projects-scroll"]::before,
|
||||
[data-slot="home-projects-scroll"]::after,
|
||||
[data-slot="model-selector-scroll"]::before,
|
||||
[data-slot="model-selector-scroll"]::after,
|
||||
[data-slot="manage-models-scroll"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
height: 16px;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
[data-slot="home-projects-scroll"]::before {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
|
||||
}
|
||||
|
||||
[data-slot="model-selector-scroll"]::before {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, var(--v2-background-bg-layer-01), transparent);
|
||||
}
|
||||
|
||||
[data-slot="manage-models-scroll"]::before {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
|
||||
}
|
||||
|
||||
[data-slot="home-projects-scroll"]::after {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, var(--v2-background-bg-base), transparent);
|
||||
}
|
||||
|
||||
[data-slot="model-selector-scroll"]::after {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, var(--v2-background-bg-layer-01), transparent);
|
||||
}
|
||||
|
||||
@supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) {
|
||||
[data-slot="home-projects-scroll"] .scroll-view__viewport {
|
||||
scroll-timeline: --home-projects-scroll y;
|
||||
}
|
||||
|
||||
[data-slot="home-projects-scroll"]::before {
|
||||
animation: home-projects-fade-top linear both;
|
||||
animation-timeline: --home-projects-scroll;
|
||||
animation-range: 0 0.1px;
|
||||
}
|
||||
|
||||
[data-slot="home-projects-scroll"]::after {
|
||||
animation: home-projects-fade-bottom linear both;
|
||||
animation-timeline: --home-projects-scroll;
|
||||
animation-range: calc(100% - 1.1px) calc(100% - 1px);
|
||||
}
|
||||
}
|
||||
|
||||
@supports (animation-timeline: --model-selector-scroll) and (timeline-scope: --model-selector-scroll) {
|
||||
[data-slot="model-selector-scroll"] .scroll-view__viewport {
|
||||
scroll-timeline: --model-selector-scroll y;
|
||||
}
|
||||
|
||||
[data-slot="model-selector-scroll"]::before {
|
||||
animation: home-projects-fade-top linear both;
|
||||
animation-timeline: --model-selector-scroll;
|
||||
animation-range: 0 0.1px;
|
||||
}
|
||||
|
||||
[data-slot="model-selector-scroll"]::after {
|
||||
animation: home-projects-fade-bottom linear both;
|
||||
animation-timeline: --model-selector-scroll;
|
||||
animation-range: calc(100% - 1.1px) calc(100% - 1px);
|
||||
}
|
||||
}
|
||||
|
||||
@supports (animation-timeline: --manage-models-scroll) and (timeline-scope: --manage-models-scroll) {
|
||||
[data-slot="manage-models-scroll"] .settings-v2-panel {
|
||||
scroll-timeline: --manage-models-scroll y;
|
||||
}
|
||||
|
||||
[data-slot="manage-models-scroll"]::before {
|
||||
animation: home-projects-fade-top linear both;
|
||||
animation-timeline: --manage-models-scroll;
|
||||
animation-range: 0 0.1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +276,10 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
|
||||
<div
|
||||
class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center font-sans"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
|
||||
<Logo class="w-58.5 opacity-12 shrink-0" />
|
||||
<div class="flex flex-col items-center gap-2 text-center">
|
||||
|
||||
+277
-123
@@ -1,7 +1,9 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
type ComponentProps,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createRoot,
|
||||
For,
|
||||
Match,
|
||||
@@ -67,6 +69,10 @@ import { archiveHomeSession } from "./home-session-archive"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
const HOME_SESSION_HEADER_STICKY_TOP = 12
|
||||
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
|
||||
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
|
||||
const SHOW_HOME_SESSION_ARCHIVE = false
|
||||
const HOME_ROW_LAYOUT =
|
||||
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
|
||||
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
|
||||
@@ -131,6 +137,107 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
|
||||
return `${pathKey(record.session.directory)}:${record.session.id}`
|
||||
}
|
||||
|
||||
function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
|
||||
let viewport: HTMLDivElement | undefined
|
||||
let content: HTMLDivElement | undefined
|
||||
let positionFrame: number | undefined
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
|
||||
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
|
||||
const [state, setState] = createStore({
|
||||
titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const items = groups()
|
||||
const ids = new Set(items.map((group) => group.id))
|
||||
headerRefs.forEach((_, id) => {
|
||||
if (!ids.has(id)) headerRefs.delete(id)
|
||||
})
|
||||
headerOffsets.forEach((_, id) => {
|
||||
if (!ids.has(id)) headerOffsets.delete(id)
|
||||
})
|
||||
if (items.length === 0) {
|
||||
content = undefined
|
||||
bindResizeObserver()
|
||||
}
|
||||
queuePositionUpdate()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
function setViewport(el: HTMLDivElement) {
|
||||
viewport = el
|
||||
bindResizeObserver()
|
||||
queuePositionUpdate()
|
||||
}
|
||||
|
||||
function setContentRef(el: HTMLDivElement) {
|
||||
content = el
|
||||
bindResizeObserver()
|
||||
queuePositionUpdate()
|
||||
}
|
||||
|
||||
function setHeaderRef(id: HomeSessionGroup["id"], el: HTMLDivElement) {
|
||||
headerRefs.set(id, el)
|
||||
queuePositionUpdate()
|
||||
}
|
||||
|
||||
function queuePositionUpdate() {
|
||||
if (typeof requestAnimationFrame === "undefined") {
|
||||
updatePositionCache()
|
||||
return
|
||||
}
|
||||
if (positionFrame !== undefined) return
|
||||
positionFrame = requestAnimationFrame(() => {
|
||||
positionFrame = undefined
|
||||
updatePositionCache()
|
||||
})
|
||||
}
|
||||
|
||||
function updatePositionCache() {
|
||||
if (!viewport) return
|
||||
groups().forEach((group) => {
|
||||
const el = headerRefs.get(group.id)
|
||||
if (!el) return
|
||||
headerOffsets.set(group.id, el.offsetTop)
|
||||
})
|
||||
update(viewport.scrollTop)
|
||||
}
|
||||
|
||||
function update(scrollTop: number) {
|
||||
const items = groups()
|
||||
items.forEach((group, index) => {
|
||||
const nextOffset = items
|
||||
.slice(index + 1)
|
||||
.map((item) => headerOffsets.get(item.id))
|
||||
.find((offset) => offset !== undefined)
|
||||
const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT
|
||||
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
|
||||
const opacity =
|
||||
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
|
||||
setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
|
||||
})
|
||||
}
|
||||
|
||||
function titleOpacity(id: HomeSessionGroup["id"]) {
|
||||
return state.titleOpacity[id] ?? 1
|
||||
}
|
||||
|
||||
function bindResizeObserver() {
|
||||
resizeObserver?.disconnect()
|
||||
if (typeof ResizeObserver === "undefined") return
|
||||
resizeObserver = new ResizeObserver(() => queuePositionUpdate())
|
||||
if (viewport) resizeObserver.observe(viewport)
|
||||
if (content) resizeObserver.observe(content)
|
||||
}
|
||||
|
||||
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
|
||||
}
|
||||
|
||||
export function NewHome() {
|
||||
const sync = useServerSync()
|
||||
const layout = useLayout()
|
||||
@@ -221,6 +328,7 @@ export function NewHome() {
|
||||
})
|
||||
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
|
||||
const groups = createMemo(() => groupSessions(records(), language))
|
||||
const sessionHeaderOpacity = useHomeSessionHeaderOpacity(groups)
|
||||
const prefetched = new Set<string>()
|
||||
|
||||
createEffect(() => {
|
||||
@@ -300,6 +408,7 @@ export function NewHome() {
|
||||
|
||||
function selectProject(conn: ServerConnection.Any, directory: string) {
|
||||
const key = ServerConnection.key(conn)
|
||||
if (global.servers.health[key]?.healthy === false) return
|
||||
if (
|
||||
!global
|
||||
.ensureServerCtx(conn)
|
||||
@@ -340,15 +449,15 @@ export function NewHome() {
|
||||
}
|
||||
|
||||
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
|
||||
if (ServerConnection.key(conn) !== server.key) return 0
|
||||
return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
|
||||
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
|
||||
}
|
||||
|
||||
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
|
||||
if (ServerConnection.key(conn) !== server.key) return
|
||||
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||
directories(project)
|
||||
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
||||
.forEach((directory) => notification.project.markViewed(directory))
|
||||
.filter((directory) => state.project.unseenCount(directory) > 0)
|
||||
.forEach((directory) => state.project.markViewed(directory))
|
||||
}
|
||||
|
||||
function openSession(session: Session) {
|
||||
@@ -406,7 +515,7 @@ export function NewHome() {
|
||||
|
||||
return (
|
||||
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
|
||||
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 pb-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6 lg:pb-16">
|
||||
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
|
||||
<HomeProjectColumn
|
||||
projects={projects()}
|
||||
selected={selection()}
|
||||
@@ -432,7 +541,7 @@ export function NewHome() {
|
||||
/>
|
||||
|
||||
<section
|
||||
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12"
|
||||
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative"
|
||||
aria-label={language.t("sidebar.project.recentSessions")}
|
||||
>
|
||||
<HomeSessionSearch
|
||||
@@ -453,7 +562,25 @@ export function NewHome() {
|
||||
onClose={closeSearch}
|
||||
onSelect={selectSearchSession}
|
||||
/>
|
||||
<ScrollView class="mt-3 min-h-0 flex-1">
|
||||
<ScrollView
|
||||
class="mt-3 -mr-3 min-h-0 flex-1 relative"
|
||||
viewportRef={sessionHeaderOpacity.setViewport}
|
||||
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
|
||||
>
|
||||
<Show when={groups().length > 0 && newSessionProject()}>
|
||||
<div class="pointer-events-none absolute top-3 right-3 z-20 flex">
|
||||
<ButtonV2
|
||||
data-action="home-new-session"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
icon="edit"
|
||||
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
||||
onClick={openNewSession}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!sessionLoad.isLoading}
|
||||
fallback={
|
||||
@@ -466,15 +593,19 @@ export function NewHome() {
|
||||
when={groups().length > 0}
|
||||
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
||||
>
|
||||
<div class="pt-3 flex flex-col gap-6">
|
||||
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
|
||||
<For each={groups()}>
|
||||
{(group, index) => (
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<>
|
||||
<HomeSessionGroupHeader
|
||||
title={group.title}
|
||||
onNewSession={index() === 0 && newSessionProject() ? openNewSession : undefined}
|
||||
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
|
||||
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
|
||||
elevated={index() === 0}
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-px">
|
||||
<div
|
||||
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
|
||||
>
|
||||
<For each={group.sessions}>
|
||||
{(record) => (
|
||||
<HomeSessionRow
|
||||
@@ -488,7 +619,7 @@ export function NewHome() {
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
@@ -525,16 +656,22 @@ function HomeProjectColumn(props: {
|
||||
const global = useGlobal()
|
||||
const dialog = useDialog()
|
||||
const controller = useServerManagementController({ navigateOnAdd: false })
|
||||
const [state, setState] = persisted(
|
||||
const [_state, setState, _, ready] = persisted(
|
||||
Persist.global("home.servers", ["home.servers.v1"]),
|
||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||
)
|
||||
const [state] = createResource(
|
||||
() => ready.promise ?? Promise.resolve(),
|
||||
(p) => p.then(() => _state),
|
||||
{ initialValue: _state },
|
||||
)
|
||||
|
||||
return (
|
||||
<aside
|
||||
class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]"
|
||||
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]"
|
||||
aria-label={props.language.t("home.projects")}
|
||||
>
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
|
||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
||||
<Show when={global.servers.list().length === 1}>
|
||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||
@@ -551,42 +688,51 @@ function HomeProjectColumn(props: {
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={global.servers.list().length > 1}
|
||||
fallback={<HomeProjectList {...props} server={global.servers.list()[0]!} />}
|
||||
>
|
||||
<For each={global.servers.list()}>
|
||||
{(item) => {
|
||||
const key = ServerConnection.key(item)
|
||||
const healthy = () => !!global.servers.health[key]?.healthy
|
||||
const serverCtx = global.ensureServerCtx(item)
|
||||
const collapsed = () => !!state.collapsed[key]
|
||||
return (
|
||||
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<HomeServerRow
|
||||
server={item}
|
||||
selected={props.selected.server === key && !props.selected.directory}
|
||||
healthy={healthy()}
|
||||
collapsed={collapsed()}
|
||||
health={global.servers.health[key]}
|
||||
controller={controller}
|
||||
focusServer={props.focusServer}
|
||||
chooseProject={props.chooseProject}
|
||||
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
|
||||
toggleCollapsed={() => setState("collapsed", key, !state.collapsed[key])}
|
||||
language={props.language}
|
||||
/>
|
||||
<Show when={healthy() && !collapsed()}>
|
||||
<div class="mx-3 h-px bg-v2-border-border-base" />
|
||||
<HomeProjectList {...props} server={item} projects={serverCtx.projects.list()} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
|
||||
<Show
|
||||
when={global.servers.list().length > 1}
|
||||
fallback={
|
||||
<div class="pr-3">
|
||||
<HomeProjectList {...props} server={global.servers.list()[0]!} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-4 pr-3">
|
||||
<For each={global.servers.list()}>
|
||||
{(item) => {
|
||||
const key = ServerConnection.key(item)
|
||||
const healthy = () => !!global.servers.health[key]?.healthy
|
||||
const serverCtx = global.ensureServerCtx(item)
|
||||
const projects = () => serverCtx.projects.list()
|
||||
const hasProjects = () => projects().length > 0
|
||||
const collapsed = () => !!state().collapsed[key]
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<HomeServerRow
|
||||
server={item}
|
||||
selected={props.selected.server === key && !props.selected.directory}
|
||||
collapsed={collapsed()}
|
||||
health={global.servers.health[key]}
|
||||
controller={controller}
|
||||
focusServer={props.focusServer}
|
||||
chooseProject={props.chooseProject}
|
||||
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
|
||||
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
|
||||
language={props.language}
|
||||
/>
|
||||
<Show when={healthy() && hasProjects() && !collapsed()}>
|
||||
<div class="mx-3 h-px bg-v2-border-border-base" />
|
||||
<HomeProjectList {...props} server={item} projects={projects()} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</ScrollView>
|
||||
<HomeUtilityNav
|
||||
class="mt-4 hidden lg:flex"
|
||||
class="mb-8 mt-4 hidden shrink-0 lg:flex"
|
||||
openSettings={props.openSettings}
|
||||
openHelp={props.openHelp}
|
||||
language={props.language}
|
||||
@@ -626,7 +772,6 @@ function HomeUtilityNav(props: {
|
||||
function HomeServerRow(props: {
|
||||
server: ServerConnection.Any
|
||||
selected: boolean
|
||||
healthy: boolean
|
||||
collapsed: boolean
|
||||
health: ServerHealth | undefined
|
||||
controller: ReturnType<typeof useServerManagementController>
|
||||
@@ -636,39 +781,46 @@ function HomeServerRow(props: {
|
||||
toggleCollapsed: () => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const [state, setState] = createStore({ menuOpen: false })
|
||||
const healthy = () => !!props.health?.healthy
|
||||
const canToggle = () => healthy() && global.ensureServerCtx(props.server).projects.list().length > 0
|
||||
return (
|
||||
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
disabled={!props.healthy}
|
||||
disabled={!healthy()}
|
||||
onClick={() => props.focusServer(props.server)}
|
||||
>
|
||||
<Show when={props.healthy}>
|
||||
<span
|
||||
data-action="home-server-collapse"
|
||||
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||
aria-label={
|
||||
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
|
||||
}
|
||||
aria-expanded={!props.collapsed}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.toggleCollapsed()
|
||||
}}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<IconV2
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
class="transition-transform duration-150 ease-in-out"
|
||||
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
|
||||
/>
|
||||
</span>
|
||||
</Show>
|
||||
<span
|
||||
data-action="home-server-collapse"
|
||||
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted"
|
||||
classList={{
|
||||
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
|
||||
"cursor-default opacity-40": !canToggle(),
|
||||
}}
|
||||
aria-label={
|
||||
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
|
||||
}
|
||||
aria-disabled={!canToggle()}
|
||||
aria-expanded={canToggle() ? !props.collapsed : undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canToggle()) return
|
||||
props.toggleCollapsed()
|
||||
}}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<IconV2
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
class="transition-transform duration-150 ease-in-out"
|
||||
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
|
||||
/>
|
||||
</span>
|
||||
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
|
||||
<ServerHealthIndicator health={props.health} />
|
||||
</div>
|
||||
@@ -759,15 +911,18 @@ function HomeProjectRow(props: {
|
||||
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
|
||||
const [state, setState] = createStore({ menuOpen: false })
|
||||
return (
|
||||
<div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-project-row"
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
aria-current={props.selected ? "page" : undefined}
|
||||
disabled={serverUnreachable()}
|
||||
onClick={() => props.selectProject(props.server, props.project.worktree)}
|
||||
>
|
||||
<HomeProjectAvatar project={props.project} />
|
||||
@@ -842,6 +997,7 @@ function HomeSessionLeading(props: {
|
||||
session: Session
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
revealProjectOnHover: boolean
|
||||
}) {
|
||||
const tabs = useTabs()
|
||||
const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
|
||||
@@ -850,8 +1006,8 @@ function HomeSessionLeading(props: {
|
||||
<Show when={hasOpenTab()}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute top-1/2 h-[7px] w-[3px] -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
|
||||
style={{ right: "calc(100% + 5px)" }}
|
||||
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
|
||||
style={{ right: "calc(100% + 4px)" }}
|
||||
/>
|
||||
</Show>
|
||||
<SessionTabAvatar
|
||||
@@ -859,6 +1015,7 @@ function HomeSessionLeading(props: {
|
||||
directory={props.session.directory}
|
||||
sessionId={props.session.id}
|
||||
activeServer={props.activeServer}
|
||||
revealProjectOnHover={props.revealProjectOnHover}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -949,7 +1106,7 @@ function HomeSessionSearch(props: {
|
||||
|
||||
return (
|
||||
<div class="w-full">
|
||||
<div ref={root} data-component="home-session-search" class="relative z-10 w-full">
|
||||
<div ref={root} data-component="home-session-search" class="relative z-30 w-full">
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
data-component="home-session-search-panel"
|
||||
@@ -957,7 +1114,7 @@ function HomeSessionSearch(props: {
|
||||
style={{
|
||||
top: "-6px",
|
||||
left: "-6px",
|
||||
width: "calc(100% + 14px)",
|
||||
width: "calc(100% + 12px)",
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col pt-9">
|
||||
@@ -1006,13 +1163,7 @@ function HomeSessionSearch(props: {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<label
|
||||
class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out"
|
||||
classList={{
|
||||
"focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]": !props.open,
|
||||
"shadow-[0_0_0_0.5px_var(--v2-border-border-focus)]": props.open,
|
||||
}}
|
||||
>
|
||||
<label class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02/60 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02">
|
||||
<IconV2 name="magnifying-glass" />
|
||||
<input
|
||||
ref={input}
|
||||
@@ -1098,6 +1249,7 @@ function HomeSessionSearchResultRow(props: {
|
||||
classList={{
|
||||
[HOME_SEARCH_RESULT_ROW]: true,
|
||||
"bg-v2-overlay-simple-overlay-hover": props.selected,
|
||||
group: !!showProjectName(),
|
||||
}}
|
||||
onMouseEnter={() => props.onHighlight()}
|
||||
onClick={() => props.onSelect(props.record.session)}
|
||||
@@ -1107,6 +1259,7 @@ function HomeSessionSearchResultRow(props: {
|
||||
session={props.record.session}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<span
|
||||
@@ -1122,25 +1275,20 @@ function HomeSessionSearchResultRow(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
|
||||
const language = useLanguage()
|
||||
function HomeSessionGroupHeader(props: {
|
||||
title: string
|
||||
titleOpacity: number
|
||||
ref: ComponentProps<"div">["ref"]
|
||||
elevated?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-[18px]">
|
||||
<div class={HOME_SECTION_LABEL}>{props.title}</div>
|
||||
<Show when={props.onNewSession}>
|
||||
{(onNewSession) => (
|
||||
<ButtonV2
|
||||
data-action="home-new-session"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
icon="edit"
|
||||
class="h-7 px-2 [font-weight:530]"
|
||||
onClick={onNewSession()}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
</ButtonV2>
|
||||
)}
|
||||
</Show>
|
||||
<div
|
||||
ref={props.ref}
|
||||
class={`pointer-events-none sticky top-3 flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
|
||||
>
|
||||
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
|
||||
{props.title}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1158,7 +1306,10 @@ function HomeSessionRow(props: {
|
||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||
|
||||
return (
|
||||
<div class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]">
|
||||
<div
|
||||
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
|
||||
classList={{ group: !!showProjectName() }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
@@ -1170,6 +1321,7 @@ function HomeSessionRow(props: {
|
||||
session={props.record.session}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<span
|
||||
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||
@@ -1182,22 +1334,24 @@ function HomeSessionRow(props: {
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
||||
<IconButtonV2
|
||||
data-action="home-session-archive"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="archive" />}
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.record.session)
|
||||
}}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
|
||||
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
||||
<IconButtonV2
|
||||
data-action="home-session-archive"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="archive" />}
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.record.session)
|
||||
}}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { HelpButton } from "@/components/help-button"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { setNavigate } from "@/utils/notification-click"
|
||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||
|
||||
export default function NewLayout(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
const notification = useNotification()
|
||||
const navigate = useNavigate()
|
||||
const params = useParams<{ id?: string }>()
|
||||
setNavigate(navigate)
|
||||
|
||||
createEffect(() => setV2Toast(true))
|
||||
createEffect(() => {
|
||||
if (!notification.ready() || !params.id) return
|
||||
if (notification.session.unseenCount(params.id) === 0) return
|
||||
notification.session.markViewed(params.id)
|
||||
})
|
||||
|
||||
const update: TitlebarUpdate = {
|
||||
version: () => {
|
||||
@@ -44,7 +36,7 @@ export default function NewLayout(props: ParentProps) {
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
{import.meta.env.DEV && <DebugBar inline />}
|
||||
<HelpButton />
|
||||
<ToastRegion v2 />
|
||||
</div>
|
||||
|
||||
@@ -943,18 +943,6 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
keybind: "mod+comma",
|
||||
onSelect: () => openSettings(),
|
||||
},
|
||||
...(platform.platform === "desktop" && platform.exportDebugLogs
|
||||
? [
|
||||
{
|
||||
id: "logs.export",
|
||||
title: "Export logs",
|
||||
category: language.t("command.category.settings"),
|
||||
onSelect: () => {
|
||||
void platform.exportDebugLogs?.()
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "session.previous",
|
||||
title: language.t("command.session.previous"),
|
||||
|
||||
@@ -11,32 +11,28 @@ export function SessionTabAvatar(props: {
|
||||
directory: string
|
||||
sessionId: string
|
||||
activeServer: boolean
|
||||
revealProjectOnHover?: boolean
|
||||
}) {
|
||||
const directory = () => props.directory
|
||||
const sessionId = () => props.sessionId
|
||||
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
|
||||
const projectAvatar = () => (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
unread={state.unread()}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<Show
|
||||
when={state.loading()}
|
||||
fallback={
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
unread={state.unread()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Show when={state.loading()} fallback={projectAvatar()}>
|
||||
<span class="relative block size-4 shrink-0">
|
||||
<SessionProgressIndicatorV2 class="absolute inset-0 group-hover:invisible" />
|
||||
<span class="invisible absolute inset-0 group-hover:visible">
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
unread={state.unread()}
|
||||
/>
|
||||
</span>
|
||||
<SessionProgressIndicatorV2
|
||||
class={`absolute inset-0 ${props.revealProjectOnHover === false ? "" : "group-hover:invisible"}`}
|
||||
/>
|
||||
<Show when={props.revealProjectOnHover !== false}>
|
||||
<span class="invisible absolute inset-0 group-hover:visible">{projectAvatar()}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -19,6 +19,9 @@ import { createPromptInputController, createPromptProjectControls } from "@/page
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||
|
||||
const showWorkspaceBar = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
/**
|
||||
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
|
||||
@@ -51,16 +54,21 @@ export default function NewSessionPage() {
|
||||
onDone: () => inputRef?.focus(),
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
worktree: "main",
|
||||
})
|
||||
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.worktree === "create") return "create"
|
||||
if (store.worktree) return store.worktree
|
||||
const project = sync().project
|
||||
if (project && sdk().directory !== project.worktree) return sdk().directory
|
||||
return "main"
|
||||
})
|
||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||
const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
|
||||
const selectedBranch = createMemo(() => {
|
||||
const worktree = newSessionWorktree()
|
||||
if (worktree === "main" || worktree === "create") return localBranch()
|
||||
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
@@ -97,7 +105,7 @@ export default function NewSessionPage() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar, "gap-3": !showWorkspaceBar }}>
|
||||
<PromptInput
|
||||
controls={inputController()}
|
||||
variant="new-session"
|
||||
@@ -105,7 +113,7 @@ export default function NewSessionPage() {
|
||||
inputRef = el
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
|
||||
onNewSessionWorktreeReset={() => setStore("worktree", undefined)}
|
||||
onSubmit={() => comments.clear()}
|
||||
toolbar={
|
||||
<Show when={!projectController.selected()}>
|
||||
@@ -114,8 +122,34 @@ export default function NewSessionPage() {
|
||||
}
|
||||
/>
|
||||
<Show when={projectController.selected()}>
|
||||
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
|
||||
<PromptProjectSelector controller={projectController} />
|
||||
<div
|
||||
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
|
||||
classList={{
|
||||
"flex-col justify-center sm:flex-row": showWorkspaceBar,
|
||||
"justify-start": !showWorkspaceBar,
|
||||
}}
|
||||
>
|
||||
<PromptProjectSelector
|
||||
controller={projectController}
|
||||
placement={showWorkspaceBar ? "bottom" : "bottom-start"}
|
||||
/>
|
||||
<Show when={showWorkspaceBar}>
|
||||
<PromptWorkspaceSelector
|
||||
value={newSessionWorktree()}
|
||||
projectRoot={projectRoot()}
|
||||
workspaces={sync().project?.sandboxes ?? []}
|
||||
branch={selectedBranch()}
|
||||
onChange={(value) =>
|
||||
setStore(
|
||||
"worktree",
|
||||
value === "main" && sync().project?.worktree !== sdk().directory
|
||||
? sync().project?.worktree
|
||||
: value,
|
||||
)
|
||||
}
|
||||
onDone={() => inputRef?.focus()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
import {
|
||||
batch,
|
||||
ErrorBoundary,
|
||||
onCleanup,
|
||||
Show,
|
||||
Match,
|
||||
@@ -10,8 +11,10 @@ import {
|
||||
createMemo,
|
||||
createEffect,
|
||||
createComputed,
|
||||
createResource,
|
||||
on,
|
||||
onMount,
|
||||
type ParentProps,
|
||||
untrack,
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
@@ -19,29 +22,37 @@ import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { debounce } from "@solid-primitives/scheduled"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { FileProvider, selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { Navigate, useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { ErrorPage } from "@/pages/error"
|
||||
import { CommentsProvider, useComments } from "@/context/comments"
|
||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { PromptProvider, usePrompt } from "@/context/prompt"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { TerminalProvider, useTerminal } from "@/context/terminal"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
||||
@@ -73,8 +84,8 @@ import { Identifier } from "@/utils/id"
|
||||
import { diffs as list } from "@/utils/diffs"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { formatServerError, isSessionNotFoundError } from "@/utils/server-errors"
|
||||
import { legacySessionHref, requireServerKey, selectSessionLineage, sessionHref } from "@/utils/session-route"
|
||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||
import { createSessionOwnership } from "./session/session-ownership"
|
||||
|
||||
@@ -91,6 +102,15 @@ const sessionViewState = () => ({
|
||||
changes: "git" as ChangeMode,
|
||||
})
|
||||
|
||||
function isLocalSessionNotFoundError(error: unknown, sessionID: string) {
|
||||
return error instanceof Error && error.message === `Session not found: ${sessionID}`
|
||||
}
|
||||
|
||||
function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) {
|
||||
if (!sessionID) return false
|
||||
return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID)
|
||||
}
|
||||
|
||||
async function runPromptRollbackMutation<T, R>(input: {
|
||||
capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void }
|
||||
optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void
|
||||
@@ -114,6 +134,204 @@ async function runPromptRollbackMutation<T, R>(input: {
|
||||
})
|
||||
}
|
||||
|
||||
export function SessionPage() {
|
||||
return (
|
||||
<SessionProviders>
|
||||
<Page />
|
||||
</SessionProviders>
|
||||
)
|
||||
}
|
||||
|
||||
export function TargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
return (
|
||||
<Show when={`${params.serverKey}\0${params.id}`} keyed>
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
||||
<ResolvedTargetSessionRoute />
|
||||
</SessionRouteErrorBoundary>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionRouteErrorBoundary(
|
||||
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
|
||||
) {
|
||||
const settings = useSettings()
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error) =>
|
||||
settings.general.newLayoutDesigns() ? (
|
||||
<SessionRouteFrame padded={props.padded}>
|
||||
<SessionPanelFrame newLayout raised={!!props.sessionID}>
|
||||
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
|
||||
</SessionPanelFrame>
|
||||
</SessionRouteFrame>
|
||||
) : (
|
||||
<ErrorPage error={error} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionErrorFallback(props: { error: unknown; sessionID?: string; serverKey?: ServerConnection.Key }) {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const displayServer = createMemo(() => {
|
||||
const key = props.serverKey ?? server.key
|
||||
const conn = server.list.find((item) => ServerConnection.key(item) === key)
|
||||
return conn ? serverName(conn) : key
|
||||
})
|
||||
const closeTab = () => {
|
||||
if (!props.sessionID) return
|
||||
tabs.removeSessionTab({ server: props.serverKey ?? server.key, sessionId: props.sessionID })
|
||||
}
|
||||
if (isCurrentSessionNotFoundError(props.error, props.sessionID)) {
|
||||
return (
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-4">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="text-16-medium text-text max-w-md">{language.t("session.error.notFound")}</div>
|
||||
<div class="text-13-regular text-text-weak max-w-md">
|
||||
{language.t("session.error.notFound.description")}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.sessionID}>
|
||||
{(sessionID) => (
|
||||
<div class="max-w-full flex flex-col items-center gap-1">
|
||||
<div class="max-w-full text-11-regular text-text-faint break-all">{displayServer()}</div>
|
||||
<code class="max-w-full rounded-[4px] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-text-base break-all bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)]">
|
||||
{sessionID()}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<ButtonV2 variant="neutral" size="normal" icon="xmark-small" onClick={closeTab}>
|
||||
{language.t("session.error.notFound.closeTab")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <ErrorPage error={props.error} />
|
||||
}
|
||||
|
||||
function ResolvedTargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const sync = useServerSync()
|
||||
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
||||
const cached = createMemo(() => sync().session.lineage.peek(params.id))
|
||||
const [resolved] = createResource(
|
||||
() => {
|
||||
if (cached()) return
|
||||
return { id: params.id, sync: sync() }
|
||||
},
|
||||
({ id, sync }) => sync.session.lineage.resolve(id),
|
||||
)
|
||||
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
|
||||
const directory = createMemo(() => current()?.session.directory)
|
||||
const targetDirectory = () => directory()!
|
||||
|
||||
createEffect(() => {
|
||||
const session = current()
|
||||
if (!session) return
|
||||
tabs.addSessionTab({
|
||||
server: serverKey(),
|
||||
sessionId: session.root.id,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||
<Show when={directory()}>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
|
||||
>
|
||||
<SDKProvider directory={targetDirectory}>
|
||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||
<TargetSessionPage />
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</Show>
|
||||
</Show>
|
||||
</TargetServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetSessionPage() {
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
return (
|
||||
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
|
||||
<SessionPage />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetServerScopedProviders(
|
||||
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
|
||||
) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
|
||||
const notification = useNotification()
|
||||
createEffect(() => {
|
||||
const sessionID = props.sessionID?.()
|
||||
if (!notification.ready() || !sessionID) return
|
||||
if (notification.session.unseenCount(sessionID) === 0) return
|
||||
notification.session.markViewed(sessionID)
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
function SessionProviders(props: ParentProps) {
|
||||
return (
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<CommentsProvider>{props.children}</CommentsProvider>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col" classList={{ "p-2": props.padded }}>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boolean }>) {
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"flex-1 min-h-0 flex flex-col": true,
|
||||
"bg-v2-background-bg-base": props.newLayout,
|
||||
"bg-background-stronger": !props.newLayout,
|
||||
"rounded-[10px] overflow-hidden": props.newLayout,
|
||||
"shadow-[var(--v2-elevation-raised)]": props.newLayout && props.raised,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const serverSync = useServerSync()
|
||||
const layout = useLayout()
|
||||
@@ -602,7 +820,7 @@ export default function Page() {
|
||||
)
|
||||
|
||||
const stopVcs = sdk().event.listen((evt) => {
|
||||
if (evt.details.type !== "file.watcher.updated") return
|
||||
if (evt.details.type !== "filesystem.changed") return
|
||||
const props =
|
||||
typeof evt.details.properties === "object" && evt.details.properties
|
||||
? (evt.details.properties as Record<string, unknown>)
|
||||
@@ -1695,9 +1913,88 @@ export default function Page() {
|
||||
() => !isDesktop() && settings.general.newLayoutDesigns() && settings.general.mobileTitlebarPosition() === "bottom",
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
const sessionErrorFallback = (error: unknown, reset: () => void) => {
|
||||
createEffect(on(sessionKey, reset, { defer: true }))
|
||||
return <SessionErrorFallback error={error} sessionID={params.id} />
|
||||
}
|
||||
|
||||
const sessionPanelContent = () => (
|
||||
<>
|
||||
{sessionSync() ?? ""}
|
||||
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
|
||||
{mobileTabs(true)}
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id && mobileChanges()}>
|
||||
<div class="relative h-full overflow-hidden">
|
||||
{reviewContent({
|
||||
diffStyle: "unified",
|
||||
classes: {
|
||||
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
|
||||
header: "px-4 !h-16 !pb-4",
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={params.id}>
|
||||
<Show when={messagesReady() ? params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<MessageTimeline
|
||||
actions={actions}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
onUserScroll={markUserScroll}
|
||||
onHistoryScroll={onHistoryScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={() =>
|
||||
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||
}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
userMessages={visibleUserMessages()}
|
||||
setHistoryAnchor={(handlers) => {
|
||||
captureHistoryAnchor = handlers.capture
|
||||
restoreHistoryAnchor = handlers.restore
|
||||
}}
|
||||
anchor={anchor}
|
||||
setRevealMessage={(fn) => {
|
||||
revealMessage = fn
|
||||
}}
|
||||
setScrollToEnd={(fn) => {
|
||||
scrollToEnd = fn
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<NewSessionView worktree={newSessionWorktree()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show>
|
||||
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<SessionRouteFrame>
|
||||
<SessionHeader />
|
||||
<div
|
||||
class="flex-1 min-h-0 flex flex-col md:flex-row"
|
||||
@@ -1717,85 +2014,13 @@ export default function Page() {
|
||||
width: sessionPanelWidth(),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"flex-1 min-h-0 flex flex-col": true,
|
||||
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
||||
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
|
||||
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
|
||||
}}
|
||||
>
|
||||
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
|
||||
{mobileTabs(true)}
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id && mobileChanges()}>
|
||||
<div class="relative h-full overflow-hidden">
|
||||
{reviewContent({
|
||||
diffStyle: "unified",
|
||||
classes: {
|
||||
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
|
||||
header: "px-4 !h-16 !pb-4",
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={params.id}>
|
||||
<Show when={messagesReady() ? params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<MessageTimeline
|
||||
actions={actions}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
onUserScroll={markUserScroll}
|
||||
onHistoryScroll={onHistoryScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={() =>
|
||||
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||
}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
userMessages={visibleUserMessages()}
|
||||
setHistoryAnchor={(handlers) => {
|
||||
captureHistoryAnchor = handlers.capture
|
||||
restoreHistoryAnchor = handlers.restore
|
||||
}}
|
||||
anchor={anchor}
|
||||
setRevealMessage={(fn) => {
|
||||
revealMessage = fn
|
||||
}}
|
||||
setScrollToEnd={(fn) => {
|
||||
scrollToEnd = fn
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<NewSessionView worktree={newSessionWorktree()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show>
|
||||
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
|
||||
</div>
|
||||
<SessionPanelFrame newLayout={settings.general.newLayoutDesigns()} raised={!!params.id}>
|
||||
{settings.general.newLayoutDesigns() ? (
|
||||
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
|
||||
) : (
|
||||
sessionPanelContent()
|
||||
)}
|
||||
</SessionPanelFrame>
|
||||
|
||||
<Show when={desktopReviewOpen()}>
|
||||
<div onPointerDown={() => size.start()}>
|
||||
@@ -1832,6 +2057,6 @@ export default function Page() {
|
||||
</div>
|
||||
|
||||
<TerminalPanel />
|
||||
</div>
|
||||
</SessionRouteFrame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ export function SessionComposerRegion(props: {
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"relative z-30": true,
|
||||
"relative z-[70]": true,
|
||||
}}
|
||||
style={{
|
||||
"margin-top": `${-controller.lift()}px`,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -77,9 +78,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||
editing: false,
|
||||
focus: 0,
|
||||
minimized: false,
|
||||
optionsHeight: 180,
|
||||
})
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
let optionsRef: HTMLDivElement | undefined
|
||||
let customRef: HTMLButtonElement | undefined
|
||||
let optsRef: HTMLButtonElement[] = []
|
||||
let replied = false
|
||||
@@ -96,11 +100,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
return language.t("session.question.progress", { current: n, total: total() })
|
||||
})
|
||||
|
||||
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
|
||||
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
|
||||
|
||||
const last = createMemo(() => store.tab >= total() - 1)
|
||||
const collapse = useSpring(() => (store.minimized ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
||||
const hidden = createMemo(() => Math.max(0, Math.min(1, collapse())))
|
||||
const optionsOff = createMemo(() => hidden() > 0.98)
|
||||
|
||||
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||
const prev = input().trim()
|
||||
@@ -192,6 +198,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
focus(pickFocus())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const el = optionsRef
|
||||
if (!el) return
|
||||
const update = () => setStore("optionsHeight", (height) => Math.max(height, el.scrollHeight))
|
||||
update()
|
||||
createResizeObserver(el, update)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
||||
if (replied) return
|
||||
@@ -405,7 +419,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const tab = store.tab + 1
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
if (!store.minimized) focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const back = () => {
|
||||
@@ -414,163 +428,212 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const tab = store.tab - 1
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
if (!store.minimized) focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const jump = (tab: number) => {
|
||||
if (sending()) return
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
if (!store.minimized) focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const minimize = () => {
|
||||
if (sending()) return
|
||||
setStore("editing", false)
|
||||
setStore("minimized", true)
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
if (sending()) return
|
||||
setStore("minimized", false)
|
||||
focus(pickFocus())
|
||||
}
|
||||
|
||||
return (
|
||||
<DockPrompt
|
||||
kind="question"
|
||||
ref={(el) => (root = el)}
|
||||
onKeyDown={nav}
|
||||
header={
|
||||
<>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<Show when={total() > 1}>
|
||||
<div data-slot="question-progress">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-progress-segment"
|
||||
data-active={i() === store.tab}
|
||||
data-answered={answered(i())}
|
||||
disabled={sending()}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<div data-component="session-question-dock">
|
||||
<DockPrompt
|
||||
kind="question"
|
||||
ref={(el) => (root = el)}
|
||||
onKeyDown={nav}
|
||||
header={
|
||||
<>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<div data-slot="question-header-actions">
|
||||
<Show when={total() > 1}>
|
||||
<div data-slot="question-progress">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-progress-segment"
|
||||
data-active={i() === store.tab}
|
||||
data-answered={answered(i())}
|
||||
disabled={sending()}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
data-component="icon-button"
|
||||
data-icon="chevron-down"
|
||||
data-size="normal"
|
||||
data-variant="ghost"
|
||||
disabled={sending()}
|
||||
style={{ transform: `rotate(${hidden() * 180}deg)` }}
|
||||
onClick={store.minimized ? restore : minimize}
|
||||
aria-label={language.t(store.minimized ? "session.question.restore" : "session.question.minimize")}
|
||||
>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
|
||||
{language.t("ui.common.dismiss")}
|
||||
</Button>
|
||||
<div data-slot="question-footer-actions">
|
||||
<Show when={store.tab > 0}>
|
||||
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
|
||||
{language.t("ui.common.back")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
variant={last() ? "primary" : "secondary"}
|
||||
size="large"
|
||||
disabled={sending()}
|
||||
onClick={next}
|
||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||
>
|
||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
|
||||
{language.t("ui.common.dismiss")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div data-slot="question-text" class="overflow-auto">
|
||||
{question()?.question}
|
||||
</div>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => (
|
||||
<Option
|
||||
multi={multi()}
|
||||
picked={picked(opt.label)}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
disabled={sending()}
|
||||
ref={(el) => (optsRef[i()] = el)}
|
||||
onFocus={() => setStore("focus", i())}
|
||||
onClick={() => selectOption(i())}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<div data-slot="question-footer-actions">
|
||||
<Show when={store.tab > 0}>
|
||||
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
|
||||
{language.t("ui.common.back")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
variant={last() ? "primary" : "secondary"}
|
||||
size="large"
|
||||
disabled={sending()}
|
||||
onClick={next}
|
||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||
>
|
||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-slot="question-text"
|
||||
style={{
|
||||
display: store.minimized ? "-webkit-box" : undefined,
|
||||
"-webkit-line-clamp": store.minimized ? "3" : undefined,
|
||||
"-webkit-box-orient": store.minimized ? "vertical" : undefined,
|
||||
overflow: store.minimized ? "hidden" : undefined,
|
||||
}}
|
||||
>
|
||||
{question()?.question}
|
||||
</div>
|
||||
<Show when={!store.minimized}>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
</Show>
|
||||
<div
|
||||
ref={(el) => (optionsRef = el)}
|
||||
data-slot="question-options"
|
||||
aria-hidden={store.minimized || optionsOff() ? "true" : undefined}
|
||||
classList={{ "pointer-events-none": hidden() > 0.1 }}
|
||||
style={{
|
||||
"max-height": `${Math.max(0, store.optionsHeight * (1 - hidden()))}px`,
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - hidden()))}`,
|
||||
visibility: optionsOff() ? "hidden" : "visible",
|
||||
}}
|
||||
>
|
||||
<For each={options()}>
|
||||
{(opt, i) => (
|
||||
<Option
|
||||
multi={multi()}
|
||||
picked={picked(opt.label)}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
disabled={sending()}
|
||||
ref={(el) => (optsRef[i()] = el)}
|
||||
onFocus={() => setStore("focus", i())}
|
||||
onClick={() => selectOption(i())}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
ref={customRef}
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
ref={customRef}
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={sending()}
|
||||
onFocus={() => setStore("focus", options().length)}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={sending()}
|
||||
onFocus={() => setStore("focus", options().length)}
|
||||
onClick={customOpen}
|
||||
onMouseDown={(e) => {
|
||||
if (sending()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
onMouseDown={(e) => {
|
||||
if (sending()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<textarea
|
||||
ref={focusCustom}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={customPlaceholder()}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
<textarea
|
||||
ref={focusCustom}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={customPlaceholder()}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
return
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
return
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
resizeInput(e.currentTarget)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</DockPrompt>
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
resizeInput(e.currentTarget)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</DockPrompt>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,9 +31,14 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
@@ -671,6 +676,34 @@ export function MessageTimeline(props: {
|
||||
if (!shareEnabled()) return
|
||||
unshareMutation.mutate(id)
|
||||
}
|
||||
const copyShareUrl = () => {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
void navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() =>
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("session.share.copy.copied"),
|
||||
description: url,
|
||||
}),
|
||||
)
|
||||
.catch((err: unknown) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(err),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const selectShareUrlText: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(event.currentTarget)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
@@ -703,8 +736,9 @@ export function MessageTimeline(props: {
|
||||
if (!sessionID() || parentID()) return
|
||||
setTitle({ editing: true, draft: titleLabel() ?? "" })
|
||||
requestAnimationFrame(() => {
|
||||
titleRef?.focus()
|
||||
titleRef?.select()
|
||||
if (!titleRef) return
|
||||
titleRef.focus()
|
||||
titleRef.select()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -856,6 +890,26 @@ export function MessageTimeline(props: {
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
if (settings.general.newLayoutDesigns())
|
||||
return (
|
||||
<DialogV2 fit>
|
||||
<DialogHeader hideClose>
|
||||
<DialogTitleGroup
|
||||
title={language.t("session.delete.title")}
|
||||
description={language.t("session.delete.confirm", { name: name() })}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 variant="danger" onClick={handleDelete}>
|
||||
{language.t("session.delete.button")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</DialogV2>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("session.delete.title")} fit>
|
||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||
@@ -960,6 +1014,7 @@ export function MessageTimeline(props: {
|
||||
message={message()}
|
||||
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
|
||||
turnDurationMs={turnDurationMs(row().userMessageID)}
|
||||
useV2Actions={settings.general.newLayoutDesigns()}
|
||||
defaultOpen={defaultOpen()}
|
||||
toolOpen={toolOpen[part().id] ?? defaultOpen()}
|
||||
onToolOpenChange={(open) => setToolOpen(part().id, open)}
|
||||
@@ -1067,6 +1122,7 @@ export function MessageTimeline(props: {
|
||||
message={message()}
|
||||
parts={getMsgParts(userMessageRow().userMessageID)}
|
||||
actions={props.actions}
|
||||
useV2Actions={settings.general.newLayoutDesigns()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1302,26 +1358,31 @@ export function MessageTimeline(props: {
|
||||
"w-full": true,
|
||||
"pb-4": true,
|
||||
"pr-3": true,
|
||||
"pl-4": settings.general.newLayoutDesigns(),
|
||||
"pl-2": settings.general.newLayoutDesigns(),
|
||||
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
||||
<div class="flex items-center min-w-0 grow-1">
|
||||
<div
|
||||
classList={{
|
||||
"flex items-center gap-1 min-w-0 flex-1": true,
|
||||
"pr-3": !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center min-w-0 flex-1 w-full">
|
||||
<Show when={parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="session-title-parent"
|
||||
class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base"
|
||||
class="min-w-0 max-w-[40%] truncate px-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
onClick={navigateParent}
|
||||
>
|
||||
{parentTitle()}
|
||||
</button>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="px-2 text-14-medium text-text-weak"
|
||||
class="-translate-y-[0.5px] px-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
@@ -1333,8 +1394,13 @@ export function MessageTimeline(props: {
|
||||
fallback={
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
||||
onDblClick={openTitleEditor}
|
||||
classList={{
|
||||
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
|
||||
settings.general.newLayoutDesigns(),
|
||||
"grow-1 min-w-0": !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
onClick={openTitleEditor}
|
||||
>
|
||||
{childTitle()}
|
||||
</h1>
|
||||
@@ -1347,8 +1413,17 @@ export function MessageTimeline(props: {
|
||||
data-slot="session-title-child"
|
||||
value={title.draft}
|
||||
disabled={titleMutation.isPending}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px] pl-1 -ml-1"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
classList={{
|
||||
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !settings.general.newLayoutDesigns(),
|
||||
"field-sizing-content self-start rounded-[6px] px-2 py-1 ":
|
||||
settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
style={{
|
||||
"--inline-input-shadow": settings.general.newLayoutDesigns()
|
||||
? "none"
|
||||
: "var(--shadow-xs-border-select)",
|
||||
}}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
@@ -1370,88 +1445,170 @@ export function MessageTimeline(props: {
|
||||
</div>
|
||||
<Show when={sessionID()} keyed>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<div
|
||||
classList={{
|
||||
"shrink-0 flex items-center": true,
|
||||
"gap-2": settings.general.newLayoutDesigns(),
|
||||
"gap-3": !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<SessionContextUsage
|
||||
placement="bottom"
|
||||
buttonAppearance={settings.general.newLayoutDesigns() ? "v2" : "default"}
|
||||
/>
|
||||
<Show when={!parentID()}>
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => {
|
||||
setTitle("menuOpen", open)
|
||||
if (open) return
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
classList={{
|
||||
"bg-surface-base-active": share.open || title.pendingShare,
|
||||
}}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
aria-expanded={title.menuOpen || share.open || title.pendingShare}
|
||||
ref={(el: HTMLButtonElement) => {
|
||||
more = el
|
||||
}}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
style={{ "min-width": "104px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (title.pendingRename) {
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
return
|
||||
}
|
||||
if (title.pendingShare) {
|
||||
event.preventDefault()
|
||||
requestAnimationFrame(() => {
|
||||
setShare({ open: true, dismiss: null })
|
||||
setTitle("pendingShare", false)
|
||||
})
|
||||
}
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => {
|
||||
setTitle("menuOpen", open)
|
||||
if (open) return
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
classList={{
|
||||
"bg-surface-base-active": share.open || title.pendingShare,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={shareEnabled()}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle({ pendingShare: true, menuOpen: false })
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
aria-expanded={title.menuOpen || share.open || title.pendingShare}
|
||||
ref={(el: HTMLButtonElement) => {
|
||||
more = el
|
||||
}}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
style={{ "min-width": "104px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (title.pendingRename) {
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
return
|
||||
}
|
||||
if (title.pendingShare) {
|
||||
event.preventDefault()
|
||||
requestAnimationFrame(() => {
|
||||
setShare({ open: true, dismiss: null })
|
||||
setTitle("pendingShare", false)
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("session.share.action.share")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={shareEnabled()}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle({ pendingShare: true, menuOpen: false })
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("session.share.action.share")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
}
|
||||
>
|
||||
<MenuV2
|
||||
gutter={6}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => {
|
||||
setTitle("menuOpen", open)
|
||||
if (open) return
|
||||
}}
|
||||
>
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
icon={<IconV2 name="outline-dots" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
state={share.open || title.pendingShare ? "pressed" : undefined}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
aria-expanded={title.menuOpen || share.open || title.pendingShare}
|
||||
ref={(el: HTMLButtonElement) => {
|
||||
more = el
|
||||
}}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content
|
||||
style={{ width: "120px", "min-width": "120px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (title.pendingRename) {
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
return
|
||||
}
|
||||
if (title.pendingShare) {
|
||||
event.preventDefault()
|
||||
requestAnimationFrame(() => {
|
||||
setShare({ open: true, dismiss: null })
|
||||
setTitle("pendingShare", false)
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
<MenuV2.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
{language.t("common.rename")}
|
||||
</MenuV2.Item>
|
||||
<Show when={shareEnabled()}>
|
||||
<MenuV2.Item
|
||||
onSelect={() => {
|
||||
setTitle({ pendingShare: true, menuOpen: false })
|
||||
}}
|
||||
>
|
||||
{language.t("session.share.action.share")}...
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
||||
{language.t("common.archive")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
|
||||
{language.t("common.delete")}...
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</Show>
|
||||
|
||||
<KobaltePopover
|
||||
open={share.open}
|
||||
anchorRef={() => more}
|
||||
placement="bottom-end"
|
||||
gutter={4}
|
||||
gutter={settings.general.newLayoutDesigns() ? 6 : 4}
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
if (open) setShare("dismiss", null)
|
||||
@@ -1461,6 +1618,10 @@ export function MessageTimeline(props: {
|
||||
<KobaltePopover.Portal>
|
||||
<KobaltePopover.Content
|
||||
data-component="popover-content"
|
||||
classList={{
|
||||
"flex w-80 max-w-none flex-col items-start gap-3 rounded-[10px] border-0 bg-v2-background-bg-layer-01 p-3 shadow-[var(--v2-elevation-floating)]":
|
||||
settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
style={{ "min-width": "320px" }}
|
||||
onEscapeKeyDown={(event) => {
|
||||
setShare({ dismiss: "escape", open: false })
|
||||
@@ -1478,24 +1639,90 @@ export function MessageTimeline(props: {
|
||||
setShare("dismiss", null)
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col p-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-13-medium text-text-strong">
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={
|
||||
<div class="flex flex-col p-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-13-medium text-text-strong">
|
||||
{language.t("session.share.popover.title")}
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak">
|
||||
{shareUrl()
|
||||
? language.t("session.share.popover.description.shared")
|
||||
: language.t("session.share.popover.description.unshared")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
<Show
|
||||
when={shareUrl()}
|
||||
fallback={
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={shareSession}
|
||||
disabled={shareMutation.isPending}
|
||||
>
|
||||
{shareMutation.isPending
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TextField
|
||||
value={shareUrl() ?? ""}
|
||||
readOnly
|
||||
copyable
|
||||
copyKind="link"
|
||||
tabIndex={-1}
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={unshareSession}
|
||||
disabled={unshareMutation.isPending}
|
||||
>
|
||||
{unshareMutation.isPending
|
||||
? language.t("session.share.action.unpublishing")
|
||||
: language.t("session.share.action.unpublish")}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={viewShare}
|
||||
disabled={unshareMutation.isPending}
|
||||
>
|
||||
{language.t("session.share.action.view")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex w-full flex-col gap-1.5 px-0.5 pt-0.5">
|
||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base [font-variation-settings:'slnt'_0]">
|
||||
{language.t("session.share.popover.title")}
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak">
|
||||
<div class="select-none text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-variation-settings:'slnt'_0]">
|
||||
{shareUrl()
|
||||
? language.t("session.share.popover.description.shared")
|
||||
: language.t("session.share.popover.description.unshared")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<Show
|
||||
when={shareUrl()}
|
||||
fallback={
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
<ButtonV2
|
||||
variant="contrast"
|
||||
class="w-full"
|
||||
onClick={shareSession}
|
||||
disabled={shareMutation.isPending}
|
||||
@@ -1503,48 +1730,57 @@ export function MessageTimeline(props: {
|
||||
{shareMutation.isPending
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</Button>
|
||||
</ButtonV2>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TextField
|
||||
value={shareUrl() ?? ""}
|
||||
readOnly
|
||||
copyable
|
||||
copyKind="link"
|
||||
tabIndex={-1}
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class={
|
||||
settings.general.newLayoutDesigns()
|
||||
? "w-full shadow-none border-[0.5px] border-border-weak-base"
|
||||
: "w-full shadow-none border border-border-weak-base"
|
||||
}
|
||||
<div
|
||||
class="flex h-8 w-full items-center gap-1.5 rounded-[6px] py-1 pl-2.5 pr-1.5 shadow-[var(--v2-elevation-button-neutral)]"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-button-neutral)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="min-w-0 flex-1 truncate select-text cursor-text text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-variation-settings:'slnt'_0]"
|
||||
onClick={selectShareUrlText}
|
||||
>
|
||||
{shareUrl()}
|
||||
</div>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
icon={<IconV2 name="outline-copy" />}
|
||||
aria-label={language.t("session.share.copy.copyLink")}
|
||||
onClick={copyShareUrl}
|
||||
/>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
icon={<IconV2 name="outline-square-arrow" />}
|
||||
aria-label={language.t("session.share.action.view")}
|
||||
onClick={viewShare}
|
||||
disabled={unshareMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex w-full">
|
||||
<ButtonV2
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
onClick={unshareSession}
|
||||
disabled={unshareMutation.isPending}
|
||||
>
|
||||
{unshareMutation.isPending
|
||||
? language.t("session.share.action.unpublishing")
|
||||
: language.t("session.share.action.unpublish")}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={viewShare}
|
||||
disabled={unshareMutation.isPending}
|
||||
>
|
||||
{language.t("session.share.action.view")}
|
||||
</Button>
|
||||
</ButtonV2>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</KobaltePopover.Content>
|
||||
</KobaltePopover.Portal>
|
||||
</KobaltePopover>
|
||||
|
||||
@@ -16,6 +16,7 @@ type PersistedWithReady<T> = [
|
||||
|
||||
type PersistTarget = {
|
||||
storage?: string
|
||||
scope?: "window"
|
||||
legacyStorageNames?: string[]
|
||||
key: string
|
||||
legacy?: string[]
|
||||
@@ -24,6 +25,7 @@ type PersistTarget = {
|
||||
|
||||
const LEGACY_STORAGE = "default.dat"
|
||||
const GLOBAL_STORAGE = "opencode.global.dat"
|
||||
const WINDOW_STORAGE = "opencode.window"
|
||||
const LOCAL_PREFIX = "opencode."
|
||||
const fallback = new Map<string, boolean>()
|
||||
|
||||
@@ -347,6 +349,11 @@ function draftStorage(draftID: string) {
|
||||
return `opencode.draft.${head}.${sum}.dat`
|
||||
}
|
||||
|
||||
function windowStorage(windowID: string) {
|
||||
const safe = (windowID || "browser").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
return `${WINDOW_STORAGE}.${safe}.dat`
|
||||
}
|
||||
|
||||
function legacyWorkspaceStorage(dir: string) {
|
||||
const storage = workspaceStorage(pathKey(dir))
|
||||
const result = new Set<string>()
|
||||
@@ -467,6 +474,8 @@ export const PersistTesting = {
|
||||
localStorageWithPrefix,
|
||||
migrateLegacy,
|
||||
normalize,
|
||||
resolveTarget,
|
||||
windowStorage,
|
||||
workspaceStorage,
|
||||
}
|
||||
|
||||
@@ -474,6 +483,9 @@ export const Persist = {
|
||||
global(key: string, legacy?: string[]): PersistTarget {
|
||||
return { storage: GLOBAL_STORAGE, key, legacy }
|
||||
},
|
||||
window(key: string, legacy?: string[]): PersistTarget {
|
||||
return { scope: "window", key, legacy }
|
||||
},
|
||||
draft(draftID: string, key: string, legacy?: string[]): PersistTarget {
|
||||
return { storage: draftStorage(draftID), key: `draft:${key}`, legacy }
|
||||
},
|
||||
@@ -503,6 +515,16 @@ export const Persist = {
|
||||
},
|
||||
}
|
||||
|
||||
function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget {
|
||||
if (target.scope !== "window") return target
|
||||
if (platform.platform === "desktop" && !platform.windowID) return { ...target, storage: GLOBAL_STORAGE }
|
||||
const windowID = platform.platform === "desktop" ? (platform.windowID ?? "browser") : "browser"
|
||||
return {
|
||||
...target,
|
||||
storage: windowStorage(windowID),
|
||||
}
|
||||
}
|
||||
|
||||
export function removePersisted(
|
||||
target: { storage?: string; legacyStorageNames?: string[]; key: string },
|
||||
platform?: Platform,
|
||||
@@ -533,7 +555,7 @@ export function persisted<T>(
|
||||
store: [Store<T>, SetStoreFunction<T>],
|
||||
): PersistedWithReady<T> {
|
||||
const platform = usePlatform()
|
||||
const config: PersistTarget = typeof target === "string" ? { key: target } : target
|
||||
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
|
||||
|
||||
const defaults = snapshot(store[0])
|
||||
const legacy = config.legacy ?? []
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { createEffect } from "solid-js"
|
||||
import type { Accessor } from "solid-js"
|
||||
import {
|
||||
addServerProbePlan,
|
||||
createProbeFailureGate,
|
||||
runAddableProbePlan,
|
||||
type AddServerProbePlan,
|
||||
type WslAddServerView,
|
||||
} from "./settings-model"
|
||||
import type { WslInstalledDistro, WslServersPlatform, WslServersState } from "./types"
|
||||
|
||||
export function useWslAddServerProbes(input: {
|
||||
state: Accessor<WslServersState | undefined>
|
||||
api: WslServersPlatform
|
||||
view: Accessor<WslAddServerView>
|
||||
adding: Accessor<boolean>
|
||||
busy: Accessor<boolean>
|
||||
selectedDistro: Accessor<string | null>
|
||||
addableInstalledDistros: Accessor<WslInstalledDistro[]>
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
const gate = createProbeFailureGate()
|
||||
const probe = useMutation(() => ({
|
||||
mutationFn: async (command: AddServerProbePlan) => {
|
||||
if (command.kind === "addable") {
|
||||
await runAddableProbePlan({ plan: command.plan, api: input.api })
|
||||
return
|
||||
}
|
||||
if (command.plan.action === "probeRuntime") await input.api.probeRuntime()
|
||||
if (command.plan.action === "refreshDistros") await input.api.refreshDistros()
|
||||
},
|
||||
onError: input.onError,
|
||||
onSettled: (_result, error, command) => {
|
||||
if (command) gate.settle(command.key, error)
|
||||
},
|
||||
}))
|
||||
|
||||
createEffect(() => {
|
||||
if (probe.isPending) return
|
||||
const command = addServerProbePlan({
|
||||
state: input.state(),
|
||||
view: input.view(),
|
||||
adding: input.adding(),
|
||||
busy: input.busy(),
|
||||
selectedDistro: input.selectedDistro(),
|
||||
addableInstalledDistros: input.addableInstalledDistros(),
|
||||
})
|
||||
if (!command || !gate.accepts(command.key)) return
|
||||
probe.mutate(command)
|
||||
})
|
||||
|
||||
return {
|
||||
probingAddable: () => probe.isPending && probe.variables?.kind === "addable",
|
||||
resetProbeFailure: () => gate.reset(),
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,28 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2"
|
||||
import { RadioGroupV2, RadioItemV2 } from "@opencode-ai/ui/v2/radio-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useWslAddServerProbes } from "./add-server-probes"
|
||||
import { useWslServers } from "./context"
|
||||
import { enterWslOpencodeStep } from "./settings-model"
|
||||
import { addServerViewModel, type AddServerText } from "./settings-model"
|
||||
import "./dialog-add-wsl-server.css"
|
||||
|
||||
type WslServerStep = "wsl" | "distro" | "opencode"
|
||||
function isWslRuntimeMissing(error: string | null | undefined) {
|
||||
if (!error) return true
|
||||
return /WSL is not installed|not been installed|wsl(?:\.exe)? --install/i.test(error)
|
||||
}
|
||||
|
||||
const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
|
||||
|
||||
function isHiddenDistro(name: string) {
|
||||
return /^docker-desktop(?:-data)?$/i.test(name)
|
||||
function translate(language: ReturnType<typeof useLanguage>, value: AddServerText) {
|
||||
if (value.params) return language.t(value.key, value.params)
|
||||
return language.t(value.key)
|
||||
}
|
||||
|
||||
interface DialogWslServerProps {
|
||||
@@ -22,210 +30,272 @@ interface DialogWslServerProps {
|
||||
}
|
||||
|
||||
export function DialogAddWslServer(props: DialogWslServerProps = {}) {
|
||||
const language = useLanguage()
|
||||
const controller = useWslAddServerController(props)
|
||||
const model = controller.model
|
||||
const primaryButton = () => model().primaryButton
|
||||
const primaryButtonStyle = () => {
|
||||
const width = primaryButton().width
|
||||
if (!width) return undefined
|
||||
return { width }
|
||||
}
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!controller.wslServers.isPending && !controller.wslServers.isError}
|
||||
fallback={
|
||||
<Dialog fit class="settings-v2-wsl-dialog">
|
||||
<Show
|
||||
when={!controller.wslServers.isError}
|
||||
fallback={<div class="settings-v2-wsl-loading">{controller.loadError()}</div>}
|
||||
>
|
||||
<div class="settings-v2-wsl-loading">
|
||||
<LoaderV2 />
|
||||
</div>
|
||||
</Show>
|
||||
</Dialog>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={model().runtimeState === "ready"}
|
||||
fallback={
|
||||
<Show
|
||||
when={model().runtimeState === "checking" || model().runtimeState === "loading"}
|
||||
fallback={
|
||||
<DialogWslSetup
|
||||
state={model().runtimeState}
|
||||
error={controller.runtimeError()}
|
||||
installable={isWslRuntimeMissing(controller.runtimeError())}
|
||||
busy={model().busy}
|
||||
onInstall={controller.installWsl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Dialog fit class="settings-v2-wsl-dialog">
|
||||
<div class="settings-v2-wsl-loading">
|
||||
<LoaderV2 />
|
||||
</div>
|
||||
</Dialog>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Dialog fit class="settings-v2-wsl-dialog">
|
||||
<DialogHeader hideClose={true}>
|
||||
<DialogTitle>
|
||||
{controller.view() === "main" ? language.t("wsl.server.add") : language.t("wsl.onboarding.installDistro")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DividerV2 />
|
||||
<Show
|
||||
when={controller.view() === "main"}
|
||||
fallback={
|
||||
<>
|
||||
<DialogBody class="settings-v2-wsl-dialog-body settings-v2-wsl-catalog-picker">
|
||||
<TextInputV2
|
||||
class="settings-v2-wsl-catalog-search"
|
||||
appearance="large"
|
||||
placeholder={language.t("wsl.onboarding.searchDistros")}
|
||||
value={controller.catalogSearch()}
|
||||
disabled={model().busy}
|
||||
onInput={(event) => controller.setCatalogSearch(event.currentTarget.value)}
|
||||
/>
|
||||
<div class="settings-v2-wsl-catalog-list">
|
||||
<RadioGroupV2
|
||||
hideLabel
|
||||
class="settings-v2-wsl-distro-group"
|
||||
label={language.t("wsl.onboarding.installDistro")}
|
||||
value={model().catalogTarget ?? undefined}
|
||||
onChange={controller.setCatalogTarget}
|
||||
disabled={model().busy}
|
||||
>
|
||||
<For each={model().filteredInstallableDistros}>
|
||||
{(item) => (
|
||||
<RadioItemV2
|
||||
class="settings-v2-wsl-distro-row settings-v2-wsl-catalog-row"
|
||||
value={item.name}
|
||||
disabled={model().busy}
|
||||
label={<span class="settings-v2-wsl-distro-label">{item.label}</span>}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</RadioGroupV2>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="neutral" disabled={model().busy} onClick={controller.closeCatalog}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2
|
||||
variant={model().installingCatalogDistro ? "loading" : "contrast"}
|
||||
disabled={!model().installingCatalogDistro && (model().busy || !model().catalogTarget)}
|
||||
style={{ width: "99px" }}
|
||||
onClick={controller.installCatalogDistro}
|
||||
>
|
||||
<Show when={model().installingCatalogDistro} fallback={language.t("wsl.onboarding.installDistro")}>
|
||||
<LoaderV2 />
|
||||
</Show>
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DialogBody class="settings-v2-wsl-dialog-body">
|
||||
<div class="settings-v2-wsl-section-header">
|
||||
<span class="settings-v2-wsl-section-title">{language.t("wsl.onboarding.installedDistros")}</span>
|
||||
<ButtonV2
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
disabled={model().busy}
|
||||
onClick={controller.refreshDistros}
|
||||
>
|
||||
{language.t("wsl.onboarding.checkAgain")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={model().addableInstalledDistros.length > 0}
|
||||
fallback={
|
||||
<div class="settings-v2-wsl-distro-list">
|
||||
<div class="settings-v2-wsl-distro-empty">
|
||||
{model().visibleInstalledDistros.length
|
||||
? language.t("wsl.onboarding.allDistrosAdded")
|
||||
: language.t("wsl.onboarding.noDistros")}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="settings-v2-wsl-distro-list">
|
||||
<RadioGroupV2
|
||||
hideLabel
|
||||
class="settings-v2-wsl-distro-group"
|
||||
label={language.t("wsl.onboarding.installedDistros")}
|
||||
value={model().selectedDistro ?? undefined}
|
||||
onChange={controller.setSelectedDistro}
|
||||
disabled={model().busy}
|
||||
>
|
||||
<For each={model().addableInstalledDistros}>
|
||||
{(item) => {
|
||||
const status = () => model().distroStatuses[item.name] ?? null
|
||||
return (
|
||||
<RadioItemV2
|
||||
class={`settings-v2-wsl-distro-row${item.version === 1 ? " settings-v2-wsl-distro-row--unsupported" : ""}`}
|
||||
value={item.name}
|
||||
disabled={item.version === 1 || model().busy}
|
||||
label={<span class="settings-v2-wsl-distro-label">{item.name}</span>}
|
||||
description={
|
||||
<Show when={status()}>
|
||||
{(value) => (
|
||||
<span class="settings-v2-wsl-distro-status" data-tone={value().tone}>
|
||||
{translate(language, value().label)}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</RadioGroupV2>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={model().installableDistros.length > 0}>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-v2-wsl-catalog-card"
|
||||
disabled={model().busy}
|
||||
onClick={controller.openCatalog}
|
||||
>
|
||||
<span class="settings-v2-wsl-catalog-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M13.5564 10.4443V13.5554H4.22309C3.24087 13.5554 2.44531 13.5554 2.44531 13.5554V10.4443M11.112 5.99989L8.00087 9.111L4.88976 5.99989M8.00087 9.111L8.00087 2.44434"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="settings-v2-wsl-catalog-copy">
|
||||
<span class="settings-v2-wsl-catalog-title">{language.t("wsl.onboarding.needAnotherDistro")}</span>
|
||||
<span class="settings-v2-wsl-catalog-description">
|
||||
{language.t("wsl.onboarding.needAnotherDistroHint")}
|
||||
</span>
|
||||
</span>
|
||||
<span class="settings-v2-wsl-catalog-chevron" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 12L10 8L6 4" stroke="currentColor" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="neutral" disabled={controller.adding()} onClick={controller.close}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2
|
||||
variant={primaryButton().loading ? "loading" : primaryButton().variant}
|
||||
disabled={!primaryButton().loading && primaryButton().disabled}
|
||||
style={primaryButtonStyle()}
|
||||
onClick={controller.runPrimary}
|
||||
>
|
||||
<Show when={primaryButton().loading} fallback={translate(language, primaryButton().label)}>
|
||||
<LoaderV2 />
|
||||
</Show>
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</Show>
|
||||
</Dialog>
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function useWslAddServerController(props: DialogWslServerProps) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const wslServers = useWslServers()
|
||||
const api = platform.wslServers!
|
||||
const [store, setStore] = createStore({
|
||||
step: undefined as WslServerStep | undefined,
|
||||
view: "main" as "main" | "catalog",
|
||||
selectedDistro: null as string | null,
|
||||
installTarget: undefined as string | undefined,
|
||||
catalogSearch: "",
|
||||
catalogTarget: null as string | null,
|
||||
adding: false,
|
||||
})
|
||||
const current = () => wslServers.data
|
||||
let disposed = false
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
const viewModel = (probingAddable: boolean) =>
|
||||
addServerViewModel({
|
||||
state: current(),
|
||||
view: store.view,
|
||||
selectedDistro: store.selectedDistro,
|
||||
catalogSearch: store.catalogSearch,
|
||||
catalogTarget: store.catalogTarget,
|
||||
adding: store.adding,
|
||||
probingAddable,
|
||||
})
|
||||
const baseModel = createMemo(() => viewModel(false))
|
||||
const probes = useWslAddServerProbes({
|
||||
state: current,
|
||||
api,
|
||||
view: () => store.view,
|
||||
adding: () => store.adding,
|
||||
busy: () => baseModel().busy,
|
||||
selectedDistro: () => baseModel().selectedDistro,
|
||||
addableInstalledDistros: () => baseModel().addableInstalledDistros,
|
||||
onError: (error) => requestError(language, error),
|
||||
})
|
||||
const busy = createMemo(() => !!current()?.job || store.adding)
|
||||
const visibleInstalledDistros = createMemo(() =>
|
||||
(current()?.installed ?? []).filter((item) => !isHiddenDistro(item.name)),
|
||||
)
|
||||
const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name)))
|
||||
const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null)
|
||||
const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
|
||||
const addableInstalledDistros = createMemo(() => {
|
||||
return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name))
|
||||
})
|
||||
const selectedDistro = createMemo(() => {
|
||||
if (store.selectedDistro && addableInstalledDistros().some((item) => item.name === store.selectedDistro)) {
|
||||
return store.selectedDistro
|
||||
}
|
||||
const distro = defaultInstalledDistro()
|
||||
if (distro && !existingServerDistros().has(distro.name)) return distro.name
|
||||
return null
|
||||
})
|
||||
const selectedProbe = createMemo(() => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return null
|
||||
return current()?.distroProbes[distro] ?? null
|
||||
})
|
||||
const selectedInstalled = createMemo(() => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return null
|
||||
return (current()?.installed ?? []).find((item) => item.name === distro) ?? null
|
||||
})
|
||||
const opencodeCheck = createMemo(() => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return null
|
||||
return current()?.opencodeChecks[distro] ?? null
|
||||
})
|
||||
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
|
||||
const distroReady = createMemo(() => {
|
||||
const probe = selectedProbe()
|
||||
if (!probe || !selectedDistro()) return false
|
||||
if (selectedInstalled()?.version === 1) return false
|
||||
return probe.canExecute && probe.hasBash && probe.hasCurl
|
||||
})
|
||||
const opencodeReady = createMemo(() => {
|
||||
const check = opencodeCheck()
|
||||
return !!check?.resolvedPath && !check.error
|
||||
})
|
||||
const distroWarningProbe = createMemo(() => {
|
||||
const probe = selectedProbe()
|
||||
if (!probe) return null
|
||||
if (distroReady()) return null
|
||||
return probe
|
||||
})
|
||||
const distroUnavailableMessage = createMemo(() => {
|
||||
const probe = distroWarningProbe()
|
||||
const distro = selectedDistro()
|
||||
if (!probe || probe.canExecute || !distro) return null
|
||||
if (!selectedInstalled()) return language.t("wsl.onboarding.distroNotInstalled", { distro })
|
||||
return language.t("wsl.onboarding.openDistroOnce", { distro })
|
||||
})
|
||||
const distroMissingTools = createMemo(() => {
|
||||
const probe = distroWarningProbe()
|
||||
if (!probe?.canExecute) return null
|
||||
if (probe.hasBash && probe.hasCurl) return null
|
||||
return probe
|
||||
})
|
||||
const installableDistros = createMemo(() => {
|
||||
const online = visibleOnlineDistros()
|
||||
const installed = new Set(visibleInstalledDistros().map((item) => item.name))
|
||||
const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
|
||||
return online
|
||||
.filter((item) => !installed.has(item.name))
|
||||
.filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
|
||||
})
|
||||
const installTarget = createMemo(
|
||||
() => installableDistros().find((item) => item.name === store.installTarget) ?? installableDistros()[0] ?? null,
|
||||
)
|
||||
const installingDistro = createMemo(() => current()?.job?.kind === "install-distro")
|
||||
const installingOpencode = createMemo(() => {
|
||||
const job = current()?.job
|
||||
return job?.kind === "install-opencode" && job.distro === selectedDistro()
|
||||
})
|
||||
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
|
||||
const addDisabled = createMemo(() => {
|
||||
const job = current()?.job
|
||||
if (!job) return store.adding
|
||||
return store.adding || job.kind !== "probe-opencode"
|
||||
})
|
||||
const recommendedStep = createMemo<WslServerStep>(() => {
|
||||
if (!wslReady()) return "wsl"
|
||||
if (!distroReady()) return "distro"
|
||||
return "opencode"
|
||||
})
|
||||
// activeStep falls back to recommendedStep when the user hasn't picked one.
|
||||
// Once the user clicks a step tab we respect their choice rather than snapping
|
||||
// them back when a probe result updates recommendedStep.
|
||||
const activeStep = createMemo(() => store.step ?? recommendedStep())
|
||||
const model = createMemo(() => viewModel(probes.probingAddable()))
|
||||
|
||||
const autoProbe = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state || busy()) return null
|
||||
if (state.pendingRestart) return null
|
||||
if (!state.runtime) return { key: "runtime", run: () => api.probeRuntime() }
|
||||
if (!wslReady()) return null
|
||||
if (!state.installed.length && !state.online.length) {
|
||||
return { key: "distros", run: () => api.refreshDistros() }
|
||||
}
|
||||
const distro = selectedDistro()
|
||||
if (distro && !state.distroProbes[distro]) {
|
||||
return { key: `probe-distro:${distro}`, run: () => api.probeDistro(distro) }
|
||||
}
|
||||
if (!distro || !distroReady()) return null
|
||||
if (!state.opencodeChecks[distro]) {
|
||||
return { key: `probe-opencode:${distro}`, run: () => api.probeOpencode(distro) }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
let lastAutoProbe: string | null = null
|
||||
createEffect(() => {
|
||||
const probe = autoProbe()
|
||||
if (!probe || probe.key === lastAutoProbe) return
|
||||
const key = probe.key
|
||||
lastAutoProbe = key
|
||||
void (async () => {
|
||||
try {
|
||||
await probe.run()
|
||||
} catch (err) {
|
||||
if (disposed) return
|
||||
// Allow the same probe to run again when reactive inputs next change
|
||||
// (e.g. user reselects a distro). Without this the user would be stuck
|
||||
// on a transient wsl.exe failure until they pick a different distro.
|
||||
if (lastAutoProbe === key) lastAutoProbe = null
|
||||
requestError(language, err)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
const wslMessage = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state || state.job?.kind === "runtime") return language.t("wsl.onboarding.checkingRuntime")
|
||||
if (state.pendingRestart) return language.t("wsl.onboarding.restartRequired")
|
||||
if (state.runtime?.available) return state.runtime.version ?? language.t("wsl.onboarding.ready")
|
||||
return state.runtime?.error ?? language.t("wsl.onboarding.required")
|
||||
})
|
||||
|
||||
const distroMessage = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state) return language.t("wsl.onboarding.checkingDistros")
|
||||
const distro = selectedDistro()
|
||||
if (state.job?.kind === "install-distro")
|
||||
return language.t("wsl.onboarding.installingDistro", { distro: state.job.distro })
|
||||
if (state.job?.kind === "probe-distro")
|
||||
return language.t("wsl.onboarding.checkingDistro", { distro: state.job.distro })
|
||||
if (state.job?.kind === "distros") return language.t("wsl.onboarding.listingDistros")
|
||||
if (distroUnavailableMessage()) return distroUnavailableMessage()!
|
||||
if (selectedProbe() && distroReady())
|
||||
return language.t("wsl.onboarding.distroReady", { distro: selectedProbe()!.name })
|
||||
if (distro) return language.t("wsl.onboarding.finishingDistro", { distro })
|
||||
return language.t("wsl.onboarding.pickDistro")
|
||||
})
|
||||
|
||||
const opencodeMessage = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state) return language.t("wsl.onboarding.checkingOpencode")
|
||||
const distro = selectedDistro()
|
||||
if (state.job?.kind === "install-opencode") {
|
||||
return distro
|
||||
? language.t("wsl.onboarding.updatingOpencodeIn", { distro })
|
||||
: language.t("wsl.onboarding.updatingOpencode")
|
||||
}
|
||||
if (state.job?.kind === "probe-opencode") {
|
||||
return distro
|
||||
? language.t("wsl.onboarding.checkingOpencodeIn", { distro })
|
||||
: language.t("wsl.onboarding.checkingOpencode")
|
||||
}
|
||||
if (opencodeCheck()?.error) return opencodeCheck()!.error
|
||||
if (opencodeCheck()?.matchesDesktop === false) {
|
||||
return distro
|
||||
? language.t("wsl.onboarding.updateOpencodeIn", { distro })
|
||||
: language.t("wsl.onboarding.updateOpencode")
|
||||
}
|
||||
if (opencodeReady()) {
|
||||
return distro
|
||||
? language.t("wsl.onboarding.opencodeReadyIn", { distro })
|
||||
: language.t("wsl.onboarding.opencodeReady")
|
||||
}
|
||||
return distro
|
||||
? language.t("wsl.onboarding.installOpencodeIn", { distro })
|
||||
: language.t("wsl.onboarding.chooseDistroFirst")
|
||||
})
|
||||
const openCatalog = () => {
|
||||
const first = model().installableDistros[0]
|
||||
setStore({
|
||||
view: "catalog",
|
||||
catalogSearch: "",
|
||||
catalogTarget: first?.name ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const run = async (action: () => Promise<unknown>) => {
|
||||
try {
|
||||
@@ -235,26 +305,43 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const runSelectedDistro = (action: (distro: string) => Promise<unknown>) => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return
|
||||
void run(() => action(distro))
|
||||
const refreshDistros = () => {
|
||||
void run(async () => {
|
||||
probes.resetProbeFailure()
|
||||
await api.refreshDistros()
|
||||
})
|
||||
}
|
||||
|
||||
const selectDistro = (name: string) => {
|
||||
setStore("selectedDistro", name)
|
||||
setStore("step", undefined)
|
||||
const installDistro = (name: string) => {
|
||||
void run(async () => {
|
||||
probes.resetProbeFailure()
|
||||
await api.installDistro(name)
|
||||
setStore("view", "main")
|
||||
})
|
||||
}
|
||||
|
||||
const openOpencodeStep = () => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return
|
||||
void run(() => enterWslOpencodeStep(distro, api.probeOpencode, (step) => setStore("step", step)))
|
||||
const installCatalogDistro = () => {
|
||||
if (model().installingCatalogDistro) return
|
||||
const name = model().catalogTarget
|
||||
if (!name) return
|
||||
installDistro(name)
|
||||
}
|
||||
|
||||
const finish = async () => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return
|
||||
const closeCatalog = () => {
|
||||
probes.resetProbeFailure()
|
||||
setStore({ view: "main", catalogSearch: "", catalogTarget: null })
|
||||
}
|
||||
|
||||
const runPrimary = async () => {
|
||||
const button = model().primaryButton
|
||||
if (button.loading) return
|
||||
const distro = model().selectedDistro
|
||||
const action = button.action
|
||||
if (!distro || !action) return
|
||||
if (action === "install-opencode") {
|
||||
await run(() => api.installOpencode(distro))
|
||||
return
|
||||
}
|
||||
setStore("adding", true)
|
||||
try {
|
||||
await api.addServer(distro)
|
||||
@@ -270,346 +357,99 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const steps = createMemo(() => {
|
||||
const active = activeStep()
|
||||
const activeIndex = STEPS.indexOf(active)
|
||||
const recommendedIndex = STEPS.indexOf(recommendedStep())
|
||||
return STEPS.map((step) => {
|
||||
const index = STEPS.indexOf(step)
|
||||
return {
|
||||
step,
|
||||
title:
|
||||
step === "wsl"
|
||||
? language.t("wsl.server.label")
|
||||
: step === "distro"
|
||||
? language.t("wsl.onboarding.step.distro")
|
||||
: language.t("wsl.onboarding.step.opencode"),
|
||||
state:
|
||||
active === step
|
||||
? "current"
|
||||
: step === "wsl"
|
||||
? wslReady()
|
||||
? "done"
|
||||
: "warning"
|
||||
: step === "distro"
|
||||
? distroReady()
|
||||
? "done"
|
||||
: index > activeIndex
|
||||
? "locked"
|
||||
: "warning"
|
||||
: opencodeCheck()?.matchesDesktop === false
|
||||
? "warning"
|
||||
: opencodeReady()
|
||||
? "done"
|
||||
: index > activeIndex
|
||||
? "locked"
|
||||
: "warning",
|
||||
locked: index > recommendedIndex,
|
||||
}
|
||||
})
|
||||
})
|
||||
const loadError = createMemo(() => {
|
||||
const loadError = () => {
|
||||
const error = wslServers.error
|
||||
if (!error) return language.t("wsl.onboarding.loadFailed")
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
wslServers,
|
||||
model,
|
||||
loadError,
|
||||
runtimeError: () => current()?.runtime?.error ?? null,
|
||||
view: () => store.view,
|
||||
catalogSearch: () => store.catalogSearch,
|
||||
adding: () => store.adding,
|
||||
setCatalogSearch: (value: string) => setStore("catalogSearch", value),
|
||||
setCatalogTarget: (value: string) => setStore("catalogTarget", value),
|
||||
setSelectedDistro: (value: string) => setStore("selectedDistro", value),
|
||||
openCatalog,
|
||||
closeCatalog,
|
||||
refreshDistros,
|
||||
installCatalogDistro,
|
||||
installWsl: () => void run(() => api.installWsl()),
|
||||
runPrimary: () => void runPrimary(),
|
||||
close: () => dialog.close(),
|
||||
}
|
||||
}
|
||||
|
||||
function DialogWslSetup(props: {
|
||||
state: string
|
||||
error: string | null
|
||||
installable: boolean
|
||||
busy: boolean
|
||||
onInstall: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const title = () =>
|
||||
props.state === "pendingRestart"
|
||||
? language.t("wsl.onboarding.restartRequired")
|
||||
: props.installable
|
||||
? language.t("wsl.onboarding.wslNotInstalled.title")
|
||||
: language.t("wsl.onboarding.wslUnavailable.title")
|
||||
const description = () => {
|
||||
if (props.state === "pendingRestart") return language.t("wsl.onboarding.windowsRestartRequired")
|
||||
if (!props.installable) return language.t("wsl.onboarding.wslUnavailable.description")
|
||||
return language.t("wsl.onboarding.wslNotInstalled.description")
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="px-5 pb-5 flex flex-col gap-4">
|
||||
<Show
|
||||
when={!wslServers.isPending}
|
||||
fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{language.t("wsl.onboarding.loading")}</div>}
|
||||
>
|
||||
<Show
|
||||
when={!wslServers.isError}
|
||||
fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{loadError()}</div>}
|
||||
>
|
||||
<div class="flex gap-2 pb-1">
|
||||
<For each={steps()}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
|
||||
classList={{
|
||||
"border-border-strong-base bg-surface-base-hover": item.state === "current",
|
||||
"border-icon-success-base/40 bg-surface-base": item.state === "done",
|
||||
"border-border-weak-base bg-background-base opacity-60": item.state === "locked",
|
||||
"border-icon-warning-base/40 bg-surface-base": item.state === "warning",
|
||||
}}
|
||||
disabled={item.locked}
|
||||
onClick={() => setStore("step", item.step)}
|
||||
>
|
||||
<div class="text-13-medium text-text-strong">{item.title}</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Switch>
|
||||
<Match when={activeStep() === "wsl"}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("wsl.server.label")}</div>
|
||||
<Show when={current()?.runtime && !wslReady() && !current()?.pendingRestart}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => void run(() => api.installWsl())}
|
||||
>
|
||||
{language.t("wsl.onboarding.installWsl")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{wslMessage()}</div>
|
||||
<Show when={current()?.pendingRestart}>
|
||||
<div class="rounded-md border border-border-weak-base px-3 py-3">
|
||||
<div class="text-12-regular text-text-warning-base">
|
||||
{language.t("wsl.onboarding.windowsRestartRequired")}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy() || !wslReady()}
|
||||
onClick={() => setStore("step", "distro")}
|
||||
>
|
||||
{language.t("wsl.onboarding.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={activeStep() === "distro"}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("wsl.onboarding.step.distro")}</div>
|
||||
<Show when={selectedDistro()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
disabled={busy()}
|
||||
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
|
||||
>
|
||||
{language.t("wsl.onboarding.refresh")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{distroMessage()}</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Show
|
||||
when={addableInstalledDistros().length > 0}
|
||||
fallback={
|
||||
<div class="text-12-regular text-text-weak">
|
||||
{visibleInstalledDistros().length
|
||||
? language.t("wsl.onboarding.allDistrosAdded")
|
||||
: current()?.runtime?.available
|
||||
? language.t("wsl.onboarding.noDistros")
|
||||
: language.t("wsl.onboarding.checkingDistros")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={addableInstalledDistros()}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border-weak-base px-3 py-2 text-left transition-colors"
|
||||
classList={{ "bg-surface-raised-base": selectedDistro() === item.name }}
|
||||
onClick={() => selectDistro(item.name)}
|
||||
>
|
||||
<div class="text-13-medium text-text-strong">{item.name}</div>
|
||||
<Show when={item.isDefault}>
|
||||
<div class="text-12-regular text-text-weak">{language.t("common.default")}</div>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={installableDistros().length > 0}>
|
||||
<div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
|
||||
<div class="px-1 flex items-center justify-between gap-3">
|
||||
<div class="text-12-medium text-text-weak">{language.t("wsl.onboarding.install")}</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Show when={installingDistro()}>
|
||||
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
|
||||
</Show>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={busy() || !installTarget()}
|
||||
onClick={() => void run(() => api.installDistro(installTarget()!.name))}
|
||||
>
|
||||
{installingDistro()
|
||||
? language.t("wsl.onboarding.installing")
|
||||
: language.t("wsl.onboarding.install")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={language.t("wsl.onboarding.installDistro")}
|
||||
class="max-h-52 overflow-y-auto rounded-md bg-background-base"
|
||||
>
|
||||
<For each={installableDistros()}>
|
||||
{(item) => {
|
||||
const selected = () => installTarget()?.name === item.name
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected()}
|
||||
disabled={busy()}
|
||||
class="w-full px-3 py-2 flex items-center gap-3 text-left border-b border-border-weak-base last:border-b-0 transition-colors"
|
||||
classList={{
|
||||
"bg-surface-raised-base": selected(),
|
||||
"hover:bg-surface-base": !selected(),
|
||||
}}
|
||||
onClick={() => setStore("installTarget", item.name)}
|
||||
>
|
||||
<div
|
||||
class="mt-0.5 h-4 w-4 rounded-full border border-border-strong-base flex items-center justify-center shrink-0"
|
||||
classList={{ "border-text-strong": selected() }}
|
||||
>
|
||||
<div class="h-2 w-2 rounded-full bg-text-strong" classList={{ hidden: !selected() }} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-13-medium text-text-strong truncate">{item.label}</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={selectedInstalled()?.version === 1 || distroUnavailableMessage() || distroMissingTools()}>
|
||||
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
|
||||
<Show when={selectedInstalled()?.version === 1}>
|
||||
<div class="text-12-regular text-text-warning-base">
|
||||
{language.t("wsl.onboarding.wsl2Required")}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={distroUnavailableMessage()}>
|
||||
{(message) => <div class="text-12-regular text-text-warning-base">{message()}</div>}
|
||||
</Show>
|
||||
<Show when={distroMissingTools()}>
|
||||
<div class="text-12-regular text-text-warning-base">
|
||||
{language.t("wsl.onboarding.toolsRequired")}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy() || !selectedInstalled()}
|
||||
onClick={() => runSelectedDistro((distro) => api.openTerminal(distro))}
|
||||
>
|
||||
{language.t("wsl.onboarding.openTerminal")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
disabled={busy() || !selectedDistro()}
|
||||
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
|
||||
>
|
||||
{language.t("wsl.onboarding.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy() || !selectedDistro() || !distroReady()}
|
||||
onClick={openOpencodeStep}
|
||||
>
|
||||
{language.t("wsl.onboarding.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={activeStep() === "opencode"}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("wsl.onboarding.step.opencode")}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={selectedDistro()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => runSelectedDistro((distro) => api.probeOpencode(distro))}
|
||||
>
|
||||
{language.t("wsl.onboarding.refresh")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))}
|
||||
>
|
||||
<Show when={installingOpencode()}>
|
||||
<Spinner class="size-4 shrink-0" />
|
||||
</Show>
|
||||
{opencodeCheck()?.resolvedPath
|
||||
? language.t("wsl.onboarding.updateOpencode")
|
||||
: language.t("wsl.onboarding.installOpencode")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
|
||||
<Show when={opencodeCheck()?.matchesDesktop === false ? opencodeCheck() : null}>
|
||||
{(check) => (
|
||||
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
|
||||
<div class="text-12-regular text-text-weak">
|
||||
{language.t("wsl.onboarding.path", {
|
||||
path: check().resolvedPath ?? language.t("wsl.onboarding.notFound"),
|
||||
})}
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak">
|
||||
{language.t("wsl.onboarding.version", {
|
||||
version: check().version ?? language.t("wsl.onboarding.unknown"),
|
||||
})}
|
||||
<Show when={check().expectedVersion}>
|
||||
{(expected) => (
|
||||
<span>{` · ${language.t("wsl.onboarding.desktopVersion", { version: expected() })}`}</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-warning-base">
|
||||
{language.t("wsl.onboarding.versionMismatch")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<Show when={activeStep() === "opencode" && allReady() && selectedDistro()}>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" disabled={addDisabled()} onClick={() => void finish()}>
|
||||
{store.adding ? language.t("wsl.onboarding.adding") : language.t("wsl.server.add")}
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog fit class="settings-v2-wsl-not-installed-dialog">
|
||||
<div class="settings-v2-wsl-not-installed-content">
|
||||
<div class="settings-v2-wsl-not-installed-message">
|
||||
<svg
|
||||
class="settings-v2-wsl-not-installed-icon"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g clip-path="url(#settings-v2-wsl-warning-clip)">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 -0.00244141L23.6926 20.2498H0.308594L12 -0.00244141ZM12.7954 6.32932C12.5844 6.11834 12.2982 5.99982 11.9999 5.99982C11.7015 5.99982 11.4154 6.11834 11.2044 6.32932C10.9934 6.5403 10.8749 6.82645 10.8749 7.12482V11.6248C10.8749 11.9232 10.9934 12.2093 11.2044 12.4203C11.4154 12.6313 11.7015 12.7498 11.9999 12.7498C12.2982 12.7498 12.5844 12.6313 12.7954 12.4203C13.0064 12.2093 13.1249 11.9232 13.1249 11.6248V7.12482C13.1249 6.82645 13.0064 6.5403 12.7954 6.32932ZM13.0605 17.5605C12.7792 17.8418 12.3977 17.9998 11.9999 17.9998C11.6021 17.9998 11.2205 17.8418 10.9392 17.5605C10.6579 17.2792 10.4999 16.8976 10.4999 16.4998C10.4999 16.102 10.6579 15.7205 10.9392 15.4392C11.2205 15.1579 11.6021 14.9998 11.9999 14.9998C12.3977 14.9998 12.7792 15.1579 13.0605 15.4392C13.3418 15.7205 13.4999 16.102 13.4999 16.4998C13.4999 16.8976 13.3418 17.2792 13.0605 17.5605Z"
|
||||
fill="#DBDBDB"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="settings-v2-wsl-warning-clip">
|
||||
<rect width="24" height="24" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<h2 class="settings-v2-wsl-not-installed-title">{title()}</h2>
|
||||
<p class="settings-v2-wsl-not-installed-description">{description()}</p>
|
||||
<Show when={!props.installable && props.error}>
|
||||
<p class="settings-v2-wsl-unavailable-error">{props.error}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.state === "unavailable" && props.installable}>
|
||||
<ButtonV2 variant="neutral" disabled={props.busy} onClick={props.onInstall}>
|
||||
{language.t("wsl.onboarding.installWsl")}
|
||||
</ButtonV2>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.state !== "unavailable"}>
|
||||
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
|
||||
{language.t("common.close")}
|
||||
</ButtonV2>
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-container"] {
|
||||
width: 480px;
|
||||
max-width: calc(100vw - 32px);
|
||||
height: auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-content"] {
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-header"] {
|
||||
align-items: center;
|
||||
padding: 24px 16px 0;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-body"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-dialog-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
box-sizing: border-box;
|
||||
padding: 16px 16px 1px;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-footer"] {
|
||||
padding: 0 16px 24px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-wsl-not-installed-dialog [data-slot="dialog-container"] {
|
||||
width: 480px;
|
||||
max-width: calc(100vw - 32px);
|
||||
min-height: 264px;
|
||||
height: auto;
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
box-shadow: var(--v2-elevation-overlay);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-wsl-not-installed-dialog [data-slot="dialog-content"] {
|
||||
align-items: stretch;
|
||||
min-height: 264px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-wsl-not-installed-dialog [data-slot="dialog-body"] {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-not-installed-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 56px 0;
|
||||
gap: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-not-installed-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-not-installed-icon {
|
||||
flex: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-not-installed-title {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-variation-settings: "slnt" 0;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-not-installed-description {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin: 0;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
letter-spacing: -0.04px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-variation-settings: "slnt" 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-unavailable-error {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
margin: 4px 0 0;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
letter-spacing: -0.04px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-variation-settings: "slnt" 0;
|
||||
color: var(--v2-text-text-faint);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-section-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
gap: 12px;
|
||||
height: 24px;
|
||||
padding: 0 0 0 9px;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-section-title {
|
||||
flex: none;
|
||||
user-select: none;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.04px;
|
||||
font-variation-settings: "slnt" 0;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
max-height: 220px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--v2-background-bg-base);
|
||||
box-shadow: var(--v2-elevation-raised);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-group [data-slot="radio-v2-items"] {
|
||||
width: 100%;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-row {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 62px;
|
||||
padding: 14px 14px 14px 16px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-row [data-slot="radio-v2-item-label"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-row [data-slot="radio-v2-item-label"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -14px -14px -14px -44px;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-row [data-slot="radio-v2-item-text"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-label {
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.04px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-variation-settings: "slnt" 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-status {
|
||||
flex: none;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
letter-spacing: 0.05px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-variation-settings: "slnt" 0;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-status[data-tone="success"] {
|
||||
color: var(--v2-state-fg-success);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-status[data-tone="warning"] {
|
||||
color: var(--v2-state-fg-warning);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-status[data-tone="muted"] {
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-row:where([data-disabled]) .settings-v2-wsl-distro-status {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-empty {
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1.4;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-card {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
padding: 14px 14px 16px 16px;
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-card:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-card:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-icon {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-title {
|
||||
user-select: none;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.04px;
|
||||
font-variation-settings: "slnt" 0;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-description {
|
||||
user-select: none;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
letter-spacing: 0.05px;
|
||||
font-variation-settings: "slnt" 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-chevron {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
max-height: 220px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-strong);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-picker {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-row {
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,5 +1,31 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { enterWslOpencodeStep, wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
|
||||
import {
|
||||
addableProbePlan,
|
||||
addServerProbePlan,
|
||||
addServerViewModel,
|
||||
autoProbePlan,
|
||||
createProbeFailureGate,
|
||||
runAddableProbePlan,
|
||||
wslOpencodeAction,
|
||||
wslRuntimeRetryable,
|
||||
} from "./settings-model"
|
||||
import type { WslServersState } from "./types"
|
||||
|
||||
const readyWslState = readyState()
|
||||
|
||||
function readyState(input: Partial<WslServersState> = {}): WslServersState {
|
||||
return {
|
||||
runtime: { available: true, version: "2.4.13.0", error: null },
|
||||
installed: [],
|
||||
online: [],
|
||||
distroProbes: {},
|
||||
opencodeChecks: {},
|
||||
pendingRestart: false,
|
||||
servers: [],
|
||||
job: null,
|
||||
...input,
|
||||
}
|
||||
}
|
||||
|
||||
describe("WSL server settings presentation", () => {
|
||||
test("retries only settled unsuccessful runtimes", () => {
|
||||
@@ -45,13 +71,161 @@ describe("WSL server settings presentation", () => {
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test("probes the selected distro before entering the OpenCode step", async () => {
|
||||
const calls: string[] = []
|
||||
await enterWslOpencodeStep(
|
||||
"Debian",
|
||||
async (distro) => calls.push(distro),
|
||||
(step) => calls.push(step),
|
||||
)
|
||||
expect(calls).toEqual(["Debian", "opencode"])
|
||||
test("plans addable distro probes with the selected distro first", () => {
|
||||
const plan = addableProbePlan({
|
||||
state: readyWslState,
|
||||
view: "main",
|
||||
adding: false,
|
||||
selectedDistro: "Ubuntu",
|
||||
addableInstalledDistros: [
|
||||
{ name: "Debian", version: 2, isDefault: true },
|
||||
{ name: "Ubuntu", version: 2, isDefault: false },
|
||||
],
|
||||
})
|
||||
|
||||
expect(plan?.key).toBe("distro:Ubuntu|distro:Debian")
|
||||
expect(plan?.distros).toEqual(["Ubuntu", "Debian"])
|
||||
})
|
||||
|
||||
test("plans bootstrap probes for missing runtime and initial distro lists", () => {
|
||||
expect(autoProbePlan({ state: undefined, busy: false })).toBeUndefined()
|
||||
expect(autoProbePlan({ state: { ...readyWslState, runtime: null }, busy: false })).toEqual({
|
||||
key: "runtime",
|
||||
action: "probeRuntime",
|
||||
})
|
||||
expect(autoProbePlan({ state: readyWslState, busy: false })).toEqual({
|
||||
key: "distros",
|
||||
action: "refreshDistros",
|
||||
})
|
||||
})
|
||||
|
||||
test("uses one command plan for bootstrap before addable distro probing", () => {
|
||||
expect(
|
||||
addServerProbePlan({
|
||||
state: { ...readyWslState, runtime: null },
|
||||
view: "main",
|
||||
adding: false,
|
||||
busy: false,
|
||||
selectedDistro: null,
|
||||
addableInstalledDistros: [{ name: "Debian", version: 2, isDefault: true }],
|
||||
}),
|
||||
).toEqual({ kind: "auto", key: "auto:runtime", plan: { key: "runtime", action: "probeRuntime" } })
|
||||
|
||||
expect(
|
||||
addServerProbePlan({
|
||||
state: {
|
||||
...readyWslState,
|
||||
installed: [{ name: "Debian", version: 2, isDefault: true }],
|
||||
online: [{ name: "Ubuntu", label: "Ubuntu" }],
|
||||
},
|
||||
view: "main",
|
||||
adding: false,
|
||||
busy: false,
|
||||
selectedDistro: "Debian",
|
||||
addableInstalledDistros: [{ name: "Debian", version: 2, isDefault: true }],
|
||||
}),
|
||||
).toEqual({ kind: "addable", key: "addable:distro:Debian", plan: { key: "distro:Debian", distros: ["Debian"] } })
|
||||
})
|
||||
|
||||
test("does not accept the same failed probe command until reset", () => {
|
||||
const gate = createProbeFailureGate()
|
||||
|
||||
expect(gate.accepts("addable:distro:Debian")).toBe(true)
|
||||
gate.settle("addable:distro:Debian", new Error("wsl failed"))
|
||||
expect(gate.accepts("addable:distro:Debian")).toBe(false)
|
||||
expect(gate.accepts("addable:distro:Ubuntu")).toBe(true)
|
||||
gate.reset()
|
||||
expect(gate.accepts("addable:distro:Debian")).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps default distro selection stable while probes resolve", () => {
|
||||
const model = addServerViewModel({
|
||||
state: {
|
||||
...readyWslState,
|
||||
installed: [
|
||||
{ name: "Debian", version: 2, isDefault: true },
|
||||
{ name: "Ubuntu", version: 2, isDefault: false },
|
||||
],
|
||||
online: [{ name: "Alpine", label: "Alpine Linux" }],
|
||||
distroProbes: {
|
||||
Ubuntu: { name: "Ubuntu", canExecute: true, hasBash: true, hasCurl: true, error: null },
|
||||
},
|
||||
},
|
||||
view: "main",
|
||||
selectedDistro: null,
|
||||
catalogSearch: "",
|
||||
catalogTarget: null,
|
||||
adding: false,
|
||||
probingAddable: false,
|
||||
})
|
||||
|
||||
expect(model.selectedDistro).toBe("Debian")
|
||||
})
|
||||
|
||||
test("keeps the dialog busy across serial addable probe job gaps", () => {
|
||||
const model = addServerViewModel({
|
||||
state: readyState({
|
||||
installed: [{ name: "Debian", version: 2, isDefault: true }],
|
||||
online: [{ name: "Ubuntu", label: "Ubuntu" }],
|
||||
}),
|
||||
view: "main",
|
||||
selectedDistro: null,
|
||||
catalogSearch: "",
|
||||
catalogTarget: null,
|
||||
adding: false,
|
||||
probingAddable: true,
|
||||
})
|
||||
|
||||
expect(model.busy).toBe(true)
|
||||
})
|
||||
|
||||
test("does not report ready when OpenCode is present but cannot run", () => {
|
||||
const model = addServerViewModel({
|
||||
state: {
|
||||
...readyWslState,
|
||||
installed: [{ name: "Debian", version: 2, isDefault: true }],
|
||||
online: [{ name: "Ubuntu", label: "Ubuntu" }],
|
||||
distroProbes: {
|
||||
Debian: { name: "Debian", canExecute: true, hasBash: true, hasCurl: true, error: null },
|
||||
},
|
||||
opencodeChecks: {
|
||||
Debian: {
|
||||
distro: "Debian",
|
||||
resolvedPath: "/home/me/.opencode/bin/opencode",
|
||||
version: null,
|
||||
expectedVersion: "1.2.3",
|
||||
matchesDesktop: null,
|
||||
error: "opencode is installed but could not run",
|
||||
},
|
||||
},
|
||||
},
|
||||
view: "main",
|
||||
selectedDistro: null,
|
||||
catalogSearch: "",
|
||||
catalogTarget: null,
|
||||
adding: false,
|
||||
probingAddable: false,
|
||||
})
|
||||
|
||||
expect(model.distroStatuses.Debian).toEqual({
|
||||
label: { key: "wsl.onboarding.installOpencode" },
|
||||
tone: "warning",
|
||||
})
|
||||
expect(model.primaryButton.action).toBe("install-opencode")
|
||||
})
|
||||
|
||||
test("delegates addable probe plans to one batch command", async () => {
|
||||
const calls: string[][] = []
|
||||
|
||||
await runAddableProbePlan({
|
||||
plan: { key: "distro:Debian|distro:Ubuntu", distros: ["Debian", "Ubuntu"] },
|
||||
api: {
|
||||
probeAddable: async (distros) => {
|
||||
calls.push(distros)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(calls).toEqual([["Debian", "Ubuntu"]])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,341 @@
|
||||
import type { WslOpencodeCheck, WslServerRuntime } from "./types"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import type {
|
||||
WslInstalledDistro,
|
||||
WslOnlineDistro,
|
||||
WslOpencodeCheck,
|
||||
WslServersPlatform,
|
||||
WslServerRuntime,
|
||||
WslServersState,
|
||||
} from "./types"
|
||||
|
||||
export type AddServerText = {
|
||||
key: string
|
||||
params?: Record<string, string>
|
||||
}
|
||||
|
||||
export type DistroStatusTone = "success" | "warning" | "muted"
|
||||
|
||||
export type DistroStatus = {
|
||||
label: AddServerText
|
||||
tone: DistroStatusTone
|
||||
}
|
||||
|
||||
export type AddServerPrimaryButton = {
|
||||
variant: "neutral" | "contrast"
|
||||
label: AddServerText
|
||||
disabled: boolean
|
||||
action: "install-opencode" | "add" | null
|
||||
loading: boolean
|
||||
width: string | null
|
||||
}
|
||||
|
||||
export type AddServerRuntimeState = "loading" | "pendingRestart" | "checking" | "unavailable" | "ready"
|
||||
|
||||
export type AddableProbePlan = {
|
||||
key: string
|
||||
distros: string[]
|
||||
}
|
||||
|
||||
export type AutoProbePlan = { key: "runtime"; action: "probeRuntime" } | { key: "distros"; action: "refreshDistros" }
|
||||
|
||||
export type AddServerProbePlan =
|
||||
| { kind: "auto"; key: string; plan: AutoProbePlan }
|
||||
| { kind: "addable"; key: string; plan: AddableProbePlan }
|
||||
|
||||
export type WslAddServerView = "main" | "catalog"
|
||||
|
||||
function isHiddenDistro(name: string) {
|
||||
return /^docker-desktop(?:-data)?$/i.test(name)
|
||||
}
|
||||
|
||||
export const wslRuntimeRetryable = (runtime: WslServerRuntime) =>
|
||||
runtime.kind === "failed" || runtime.kind === "stopped"
|
||||
|
||||
export async function enterWslOpencodeStep(
|
||||
distro: string,
|
||||
probe: (distro: string) => Promise<unknown>,
|
||||
select: (step: "opencode") => void,
|
||||
) {
|
||||
await probe(distro)
|
||||
select("opencode")
|
||||
}
|
||||
|
||||
export function wslOpencodeAction(check?: WslOpencodeCheck) {
|
||||
if (!check) return
|
||||
if (!check.resolvedPath) return "Install OpenCode"
|
||||
if (check.matchesDesktop === false) return "Update OpenCode"
|
||||
}
|
||||
|
||||
export function wslDistroReady(state: WslServersState | undefined, name: string) {
|
||||
const installed = state?.installed.find((item) => item.name === name)
|
||||
const probe = state?.distroProbes[name]
|
||||
if (!probe || !installed) return false
|
||||
if (installed.version === 1) return false
|
||||
return probe.canExecute && probe.hasBash && probe.hasCurl
|
||||
}
|
||||
|
||||
export function addServerViewModel(input: {
|
||||
state: WslServersState | undefined
|
||||
view: WslAddServerView
|
||||
selectedDistro: string | null
|
||||
catalogSearch: string
|
||||
catalogTarget: string | null
|
||||
adding: boolean
|
||||
probingAddable: boolean
|
||||
}) {
|
||||
const state = input.state
|
||||
const visibleInstalledDistros = (state?.installed ?? []).filter((item) => !isHiddenDistro(item.name))
|
||||
const visibleOnlineDistros = (state?.online ?? []).filter((item) => !isHiddenDistro(item.name))
|
||||
const existingServerDistros = new Set((state?.servers ?? []).map((item) => item.config.distro))
|
||||
const addableInstalledDistros = visibleInstalledDistros.filter((item) => !existingServerDistros.has(item.name))
|
||||
const selectedDistro = addServerSelectedDistro(input.selectedDistro, visibleInstalledDistros, addableInstalledDistros)
|
||||
const opencodeCheck = selectedDistro ? (state?.opencodeChecks[selectedDistro] ?? null) : null
|
||||
const installableDistros = addServerInstallableDistros(visibleInstalledDistros, visibleOnlineDistros)
|
||||
const filteredInstallableDistros = addServerFilteredInstallableDistros(installableDistros, input.catalogSearch)
|
||||
const catalogTarget = addServerCatalogTarget(input.catalogTarget, filteredInstallableDistros)
|
||||
const busy = !!state?.job || input.adding || input.probingAddable
|
||||
|
||||
return {
|
||||
busy,
|
||||
runtimeState: addServerRuntimeState(state),
|
||||
visibleInstalledDistros,
|
||||
visibleOnlineDistros,
|
||||
addableInstalledDistros,
|
||||
selectedDistro,
|
||||
opencodeCheck,
|
||||
wslReady: !!state?.runtime?.available && !state?.pendingRestart,
|
||||
distroStatuses: Object.fromEntries(
|
||||
addableInstalledDistros.flatMap((item) => {
|
||||
const status = addServerDistroStatus({ state, name: item.name, probingAddable: input.probingAddable })
|
||||
if (!status) return []
|
||||
return [[item.name, status]]
|
||||
}),
|
||||
) as Record<string, DistroStatus | undefined>,
|
||||
primaryButton: addServerPrimaryButton({
|
||||
state,
|
||||
selectedDistro,
|
||||
opencodeCheck,
|
||||
adding: input.adding,
|
||||
probingAddable: input.probingAddable,
|
||||
}),
|
||||
installableDistros,
|
||||
filteredInstallableDistros,
|
||||
catalogTarget,
|
||||
installingCatalogDistro: state?.job?.kind === "install-distro",
|
||||
}
|
||||
}
|
||||
|
||||
function addServerSelectedDistro(
|
||||
selected: string | null,
|
||||
visibleInstalledDistros: WslInstalledDistro[],
|
||||
addableInstalledDistros: WslInstalledDistro[],
|
||||
) {
|
||||
if (selected && addableInstalledDistros.some((item) => item.name === selected && item.version !== 1)) return selected
|
||||
const defaultDistro = visibleInstalledDistros.find((item) => item.isDefault)
|
||||
if (
|
||||
defaultDistro &&
|
||||
defaultDistro.version !== 1 &&
|
||||
addableInstalledDistros.some((item) => item.name === defaultDistro.name)
|
||||
) {
|
||||
return defaultDistro.name
|
||||
}
|
||||
return addableInstalledDistros.find((item) => item.version !== 1)?.name ?? null
|
||||
}
|
||||
|
||||
function addServerRuntimeState(state: WslServersState | undefined): AddServerRuntimeState {
|
||||
if (!state) return "loading"
|
||||
if (state.pendingRestart) return "pendingRestart"
|
||||
if (!state.runtime) return "checking"
|
||||
if (!state.runtime.available) return "unavailable"
|
||||
return "ready"
|
||||
}
|
||||
|
||||
function addServerDistroStatus(input: {
|
||||
state: WslServersState | undefined
|
||||
name: string
|
||||
probingAddable: boolean
|
||||
}): DistroStatus | undefined {
|
||||
const installed = input.state?.installed.find((item) => item.name === input.name)
|
||||
if (installed?.version === 1) return { label: { key: "wsl.onboarding.distroStatus.unsupported" }, tone: "muted" }
|
||||
const job = input.state?.job
|
||||
const probe = input.state?.distroProbes[input.name]
|
||||
if (!probe) {
|
||||
if (input.probingAddable || (job?.kind === "probe-addable" && job.distros.includes(input.name))) {
|
||||
return checkingStatus()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!probe.canExecute) {
|
||||
if (!installed) {
|
||||
return { label: { key: "wsl.onboarding.distroNotInstalled", params: { distro: input.name } }, tone: "warning" }
|
||||
}
|
||||
return { label: { key: "wsl.onboarding.openDistroOnce", params: { distro: input.name } }, tone: "warning" }
|
||||
}
|
||||
if (!probe.hasBash || !probe.hasCurl) {
|
||||
return { label: { key: "wsl.onboarding.distroStatus.missingTools" }, tone: "warning" }
|
||||
}
|
||||
const check = input.state?.opencodeChecks[input.name]
|
||||
if (!check) {
|
||||
if (input.probingAddable || (job?.kind === "probe-addable" && job.distros.includes(input.name))) {
|
||||
return checkingStatus()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (check.matchesDesktop === false) return { label: { key: "wsl.onboarding.updateOpencode" }, tone: "warning" }
|
||||
if (!check.resolvedPath) return { label: { key: "wsl.onboarding.distroStatus.opencodeMissing" }, tone: "warning" }
|
||||
if (check.error) return { label: { key: "wsl.onboarding.installOpencode" }, tone: "warning" }
|
||||
return { label: { key: "wsl.onboarding.distroStatus.ready" }, tone: "success" }
|
||||
}
|
||||
|
||||
function checkingStatus(): DistroStatus {
|
||||
return { label: { key: "wsl.onboarding.distroStatus.checking" }, tone: "muted" }
|
||||
}
|
||||
|
||||
function addServerPrimaryButton(input: {
|
||||
state: WslServersState | undefined
|
||||
selectedDistro: string | null
|
||||
opencodeCheck: WslOpencodeCheck | null
|
||||
adding: boolean
|
||||
probingAddable: boolean
|
||||
}): AddServerPrimaryButton {
|
||||
const ready = !!input.selectedDistro && wslDistroReady(input.state, input.selectedDistro)
|
||||
const probingSelected = input.probingAddable && !addServerSelectedDistroSettled(input.state, input.selectedDistro)
|
||||
const probingOpencode =
|
||||
probingSelected ||
|
||||
(ready &&
|
||||
(!input.opencodeCheck ||
|
||||
(!!input.selectedDistro &&
|
||||
input.state?.job?.kind === "probe-addable" &&
|
||||
input.state.job.distros.includes(input.selectedDistro))))
|
||||
const installingOpencode =
|
||||
input.state?.job?.kind === "install-opencode" && input.state.job.distro === input.selectedDistro
|
||||
if (!ready || probingOpencode) {
|
||||
return {
|
||||
variant: "contrast",
|
||||
label: probingSelected ? { key: "wsl.onboarding.distroStatus.checking" } : { key: "wsl.server.add" },
|
||||
disabled: true,
|
||||
action: null,
|
||||
loading: probingSelected,
|
||||
width: null,
|
||||
}
|
||||
}
|
||||
if (!addServerOpencodeReady(input.opencodeCheck)) {
|
||||
const update = !!input.opencodeCheck?.resolvedPath && input.opencodeCheck.matchesDesktop === false
|
||||
return {
|
||||
variant: "neutral",
|
||||
label: installingOpencode
|
||||
? { key: "wsl.onboarding.updatingOpencode" }
|
||||
: update
|
||||
? { key: "wsl.onboarding.updateOpencode" }
|
||||
: { key: "wsl.onboarding.installOpencode" },
|
||||
disabled: !!input.state?.job || input.adding,
|
||||
action: "install-opencode",
|
||||
loading: installingOpencode,
|
||||
width: update ? "138px" : "129px",
|
||||
}
|
||||
}
|
||||
return {
|
||||
variant: "contrast",
|
||||
label: input.adding ? { key: "wsl.onboarding.adding" } : { key: "wsl.server.add" },
|
||||
disabled: input.adding || !!input.state?.job,
|
||||
action: "add",
|
||||
loading: input.adding,
|
||||
width: null,
|
||||
}
|
||||
}
|
||||
|
||||
function addServerOpencodeReady(check: WslOpencodeCheck | null) {
|
||||
return !!check?.resolvedPath && check.matchesDesktop !== false && !check.error
|
||||
}
|
||||
|
||||
function addServerSelectedDistroSettled(state: WslServersState | undefined, selectedDistro: string | null) {
|
||||
if (!selectedDistro) return false
|
||||
const installed = state?.installed.find((item) => item.name === selectedDistro)
|
||||
if (installed?.version === 1) return false
|
||||
if (!state?.distroProbes[selectedDistro]) return false
|
||||
if (!wslDistroReady(state, selectedDistro)) return true
|
||||
return !!state.opencodeChecks[selectedDistro]
|
||||
}
|
||||
|
||||
function addServerInstallableDistros(installedDistros: WslInstalledDistro[], onlineDistros: WslOnlineDistro[]) {
|
||||
const installed = new Set(installedDistros.map((item) => item.name))
|
||||
const hasVersionedUbuntu = onlineDistros.some((item) => /^Ubuntu-\d/.test(item.name))
|
||||
return onlineDistros
|
||||
.filter((item) => !installed.has(item.name))
|
||||
.filter((item) => item.name !== "Ubuntu" || !hasVersionedUbuntu)
|
||||
}
|
||||
|
||||
function addServerFilteredInstallableDistros(installableDistros: WslOnlineDistro[], search: string) {
|
||||
const query = search.trim()
|
||||
if (!query) return installableDistros
|
||||
return fuzzysort.go(query, installableDistros, { keys: ["label", "name"] }).map((item) => item.obj)
|
||||
}
|
||||
|
||||
function addServerCatalogTarget(target: string | null, distros: WslOnlineDistro[]) {
|
||||
if (target && distros.some((item) => item.name === target)) return target
|
||||
return distros[0]?.name ?? null
|
||||
}
|
||||
|
||||
export function addableProbePlan(input: {
|
||||
state: WslServersState | undefined
|
||||
view: WslAddServerView
|
||||
adding: boolean
|
||||
selectedDistro: string | null
|
||||
addableInstalledDistros: WslInstalledDistro[]
|
||||
}) {
|
||||
const state = input.state
|
||||
if (!state?.runtime?.available || state.pendingRestart || input.view !== "main" || input.adding) return
|
||||
if (state.job) return
|
||||
const ordered = input.selectedDistro
|
||||
? [
|
||||
...input.addableInstalledDistros.filter((item) => item.name === input.selectedDistro),
|
||||
...input.addableInstalledDistros.filter((item) => item.name !== input.selectedDistro),
|
||||
]
|
||||
: input.addableInstalledDistros
|
||||
const pending = ordered.flatMap((item) => {
|
||||
if (item.version === 1) return []
|
||||
if (!state.distroProbes[item.name]) return [`distro:${item.name}`]
|
||||
if (wslDistroReady(state, item.name) && !state.opencodeChecks[item.name]) return [`opencode:${item.name}`]
|
||||
return []
|
||||
})
|
||||
if (!pending.length) return
|
||||
return {
|
||||
key: pending.join("|"),
|
||||
distros: ordered.filter((item) => item.version !== 1).map((item) => item.name),
|
||||
} satisfies AddableProbePlan
|
||||
}
|
||||
|
||||
export function autoProbePlan(input: { state: WslServersState | undefined; busy: boolean }) {
|
||||
if (!input.state || input.busy || input.state.pendingRestart) return
|
||||
if (!input.state.runtime) return { key: "runtime", action: "probeRuntime" } satisfies AutoProbePlan
|
||||
if (!input.state.runtime.available) return
|
||||
if (input.state.installed.length || input.state.online.length) return
|
||||
return { key: "distros", action: "refreshDistros" } satisfies AutoProbePlan
|
||||
}
|
||||
|
||||
export function addServerProbePlan(input: {
|
||||
state: WslServersState | undefined
|
||||
view: WslAddServerView
|
||||
adding: boolean
|
||||
busy: boolean
|
||||
selectedDistro: string | null
|
||||
addableInstalledDistros: WslInstalledDistro[]
|
||||
}) {
|
||||
const auto = autoProbePlan({ state: input.state, busy: input.busy })
|
||||
if (auto) return { kind: "auto", key: `auto:${auto.key}`, plan: auto } satisfies AddServerProbePlan
|
||||
const addable = addableProbePlan(input)
|
||||
if (addable) return { kind: "addable", key: `addable:${addable.key}`, plan: addable } satisfies AddServerProbePlan
|
||||
}
|
||||
|
||||
export function createProbeFailureGate() {
|
||||
let failed: string | undefined
|
||||
return {
|
||||
accepts(key: string) {
|
||||
return key !== failed
|
||||
},
|
||||
settle(key: string, error?: unknown) {
|
||||
if (error) failed = key
|
||||
},
|
||||
reset() {
|
||||
failed = undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAddableProbePlan(input: {
|
||||
plan: AddableProbePlan
|
||||
api: Pick<WslServersPlatform, "probeAddable">
|
||||
}) {
|
||||
await input.api.probeAddable(input.plan.distros)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
@@ -30,17 +28,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const openAddWsl = () => {
|
||||
dialog.push(() => (
|
||||
<Dialog size="large" fit class="settings-v2-wsl-dialog">
|
||||
<DialogHeader hideClose={true}>
|
||||
<DialogTitle>{language.t("wsl.server.add")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DividerV2 />
|
||||
<DialogBody>
|
||||
<DialogAddWslServer />
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
))
|
||||
dialog.push(() => <DialogAddWslServer />)
|
||||
}
|
||||
return (
|
||||
<Show
|
||||
|
||||
@@ -53,8 +53,7 @@ export type WslJob =
|
||||
| { kind: "distros"; startedAt: number }
|
||||
| { kind: "install-wsl"; startedAt: number }
|
||||
| { kind: "install-distro"; distro: string; startedAt: number }
|
||||
| { kind: "probe-distro"; distro: string; startedAt: number }
|
||||
| { kind: "probe-opencode"; distro: string; startedAt: number }
|
||||
| { kind: "probe-addable"; distros: string[]; startedAt: number }
|
||||
| { kind: "install-opencode"; distro: string; startedAt: number }
|
||||
|
||||
export type WslServersState = {
|
||||
@@ -77,8 +76,7 @@ export type WslServersPlatform = {
|
||||
refreshDistros(): Promise<void>
|
||||
installWsl(): Promise<void>
|
||||
installDistro(name: string): Promise<void>
|
||||
probeDistro(name: string): Promise<void>
|
||||
probeOpencode(name: string): Promise<void>
|
||||
probeAddable(distros: string[]): Promise<void>
|
||||
installOpencode(name: string): Promise<void>
|
||||
openTerminal(name: string): Promise<void>
|
||||
addServer(distro: string): Promise<WslServerConfig>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# V2 CLI and TUI development guide
|
||||
|
||||
## Migration context
|
||||
|
||||
- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state.
|
||||
- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released 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`.
|
||||
|
||||
## Interactive debugging
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
## 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 the 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.
|
||||
@@ -31,11 +31,11 @@ function run(target) {
|
||||
|
||||
const envPath = process.env.OPENCODE_BIN_PATH
|
||||
const scriptDir = path.dirname(fs.realpathSync(__filename))
|
||||
const cached = path.join(scriptDir, ".lildax")
|
||||
const cached = path.join(scriptDir, ".opencode2")
|
||||
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
|
||||
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
|
||||
const base = "@opencode-ai/cli-" + platform + "-" + arch
|
||||
const binary = platform === "windows" ? "lildax.exe" : "lildax"
|
||||
const binary = platform === "windows" ? "opencode2.exe" : "opencode2"
|
||||
|
||||
function supportsAvx2() {
|
||||
if (arch !== "x64") return false
|
||||
@@ -121,7 +121,7 @@ function findBinary(startDir) {
|
||||
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
|
||||
if (!resolved) {
|
||||
console.error(
|
||||
"It seems that your package manager failed to install the right lildax CLI package. Try manually installing " +
|
||||
"It seems that your package manager failed to install the right opencode2 CLI package. Try manually installing " +
|
||||
names.map((name) => `"${name}"`).join(" or ") +
|
||||
" package",
|
||||
)
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs"
|
||||
"opencode2": "./bin/opencode2.cjs"
|
||||
},
|
||||
"files": [
|
||||
"bin"
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
@@ -25,12 +26,15 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import pkg from "../package.json"
|
||||
import { modelsData } from "./generate"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
const binary = "lildax"
|
||||
const binary = "opencode2"
|
||||
process.chdir(dir)
|
||||
|
||||
await rm("dist", { recursive: true, force: true })
|
||||
|
||||
@@ -25,14 +25,15 @@ for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" }
|
||||
}
|
||||
console.log("binaries", binaries)
|
||||
const version = Object.values(binaries)[0]
|
||||
const name = pkg.name
|
||||
|
||||
await $`mkdir -p ./dist/${pkg.name}/bin`
|
||||
await $`cp ./bin/lildax.cjs ./dist/${pkg.name}/bin/lildax`
|
||||
await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
await $`mkdir -p ./dist/${name}/bin`
|
||||
await $`cp ./bin/opencode2.cjs ./dist/${name}/bin/opencode2`
|
||||
await Bun.file(`./dist/${name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: pkg.name,
|
||||
bin: { lildax: "./bin/lildax" },
|
||||
name,
|
||||
bin: { opencode2: "./bin/opencode2" },
|
||||
version,
|
||||
license: pkg.license,
|
||||
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
|
||||
@@ -50,4 +51,4 @@ await Promise.all(
|
||||
publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version),
|
||||
),
|
||||
)
|
||||
await publish(`./dist/${pkg.name}`, pkg.name, version)
|
||||
await publish(`./dist/${name}`, name, version)
|
||||
|
||||
@@ -5,6 +5,30 @@ declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
|
||||
description: "OpenCode 2.0 preview command line interface",
|
||||
params: {
|
||||
directory: Argument.string("directory").pipe(
|
||||
Argument.withDescription("Directory to start OpenCode in"),
|
||||
Argument.optional,
|
||||
),
|
||||
standalone: Flag.boolean("standalone").pipe(
|
||||
Flag.withDescription("Run with a private server instead of the background service"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
server: Flag.string("server").pipe(
|
||||
Flag.withDescription("Connect to a server URL instead of the background service"),
|
||||
Flag.optional,
|
||||
),
|
||||
continue: Flag.boolean("continue").pipe(
|
||||
Flag.withAlias("c"),
|
||||
Flag.withDescription("Continue the last session"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
session: Flag.string("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
},
|
||||
commands: [
|
||||
Spec.make("api", {
|
||||
description: "Make a request to the running server",
|
||||
@@ -26,6 +50,43 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
description: "Debugging and troubleshooting tools",
|
||||
commands: [Spec.make("agents", { description: "List all agents" })],
|
||||
}),
|
||||
Spec.make("mcp", {
|
||||
description: "Manage MCP (Model Context Protocol) servers",
|
||||
commands: [
|
||||
Spec.make("list", { description: "List configured MCP servers and their status" }),
|
||||
Spec.make("add", {
|
||||
description: "Add an MCP server to your configuration",
|
||||
params: {
|
||||
name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")),
|
||||
command: Argument.string("command").pipe(
|
||||
Argument.withDescription("Command and arguments for a local server, passed after --"),
|
||||
Argument.variadic({ min: 0 }),
|
||||
),
|
||||
url: Flag.string("url").pipe(Flag.withDescription("URL for a remote MCP server"), Flag.optional),
|
||||
header: Flag.keyValuePair("header").pipe(
|
||||
Flag.withDescription("HTTP header for a remote server, as name=value"),
|
||||
Flag.optional,
|
||||
),
|
||||
env: Flag.keyValuePair("env").pipe(
|
||||
Flag.withDescription("Environment variable for a local server, as name=value"),
|
||||
Flag.optional,
|
||||
),
|
||||
global: Flag.boolean("global").pipe(
|
||||
Flag.withDescription("Write to the global config instead of the project config"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("auth", {
|
||||
description: "Authenticate with an OAuth-capable remote MCP server",
|
||||
params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) },
|
||||
}),
|
||||
Spec.make("logout", {
|
||||
description: "Remove stored OAuth credentials for an MCP server",
|
||||
params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
|
||||
Spec.make("service", {
|
||||
description: "Manage the background server",
|
||||
@@ -34,18 +95,27 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
Spec.make("restart", { description: "Restart the background server" }),
|
||||
Spec.make("status", { description: "Show background server status" }),
|
||||
Spec.make("stop", { description: "Stop the background server" }),
|
||||
Spec.make("password", {
|
||||
description: "Get or set the server password",
|
||||
params: { value: Argument.string("value").pipe(Argument.optional) },
|
||||
Spec.make("get", {
|
||||
description: "Get service configuration",
|
||||
params: { key: Argument.string("key").pipe(Argument.optional) },
|
||||
}),
|
||||
Spec.make("set", {
|
||||
description: "Set service configuration",
|
||||
params: { key: Argument.string("key"), value: Argument.string("value") },
|
||||
}),
|
||||
Spec.make("unset", {
|
||||
description: "Unset service configuration",
|
||||
params: { key: Argument.string("key") },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("serve", {
|
||||
description: "Start the v2 API server",
|
||||
params: {
|
||||
hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")),
|
||||
hostname: Flag.string("hostname").pipe(Flag.optional),
|
||||
port: Flag.integer("port").pipe(Flag.optional),
|
||||
register: Flag.boolean("register").pipe(Flag.withDefault(false)),
|
||||
service: Flag.boolean("service").pipe(Flag.withDefault(false)),
|
||||
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -2,7 +2,8 @@ import { EOL } from "node:os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Daemon } from "../../services/daemon"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { ServiceConfig } from "../../services/service-config"
|
||||
|
||||
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
|
||||
|
||||
@@ -17,8 +18,9 @@ type OpenApi = {
|
||||
export default Runtime.handler(
|
||||
Commands.commands.api,
|
||||
Effect.fn("cli.api")(function* (input) {
|
||||
const daemon = yield* Daemon.Service
|
||||
const transport = yield* daemon.transport()
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const transport = found ?? (yield* Service.start(options))
|
||||
const params = Option.getOrElse(input.param, () => ({}))
|
||||
const request = yield* resolveRequest(transport, input.request, params)
|
||||
const headers = new Headers(transport.headers)
|
||||
@@ -58,7 +60,7 @@ export function rawRequest(input: readonly string[]) {
|
||||
}
|
||||
|
||||
function resolveRequest(
|
||||
transport: { url: string; headers: RequestInit["headers"] },
|
||||
transport: Service.Transport,
|
||||
input: readonly string[],
|
||||
params: Record<string, string>,
|
||||
) {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { EOL } from "os"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Effect } from "effect"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Daemon } from "../../../services/daemon"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.debug.commands.agents,
|
||||
Effect.fn("cli.debug.agents")(function* () {
|
||||
const daemon = yield* Daemon.Service
|
||||
const client = yield* daemon.client()
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const transport = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
|
||||
const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } }))
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
|
||||
@@ -1,13 +1,86 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Effect } from "effect"
|
||||
import { Daemon } from "../../services/daemon"
|
||||
import { Effect, Option, Redacted } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { Env } from "../../env"
|
||||
import { ServiceConfig } from "../../services/service-config"
|
||||
import { Standalone } from "../../services/standalone"
|
||||
import { Updater } from "../../services/updater"
|
||||
|
||||
export default Runtime.handler(Commands, () =>
|
||||
export default Runtime.handler(Commands, (input) =>
|
||||
Effect.gen(function* () {
|
||||
const daemon = yield* Daemon.Service
|
||||
const transport = yield* daemon.transport()
|
||||
const directory = Option.getOrUndefined(input.directory)
|
||||
if (directory !== undefined) process.chdir(directory)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater.check().pipe(Effect.forkScoped)
|
||||
const server = Option.getOrUndefined(input.server)
|
||||
if (server !== undefined && input.standalone)
|
||||
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
||||
const transport = yield* Effect.gen(function* () {
|
||||
if (server !== undefined) {
|
||||
const password = yield* Env.password
|
||||
const explicit = {
|
||||
url: server,
|
||||
headers: password
|
||||
? { authorization: "Basic " + btoa("opencode:" + Redacted.value(password)) }
|
||||
: undefined,
|
||||
} satisfies Service.Transport
|
||||
// Fail loudly before entering the TUI: an explicit server that is
|
||||
// unreachable or rejects auth should not present as reconnect churn.
|
||||
const response = yield* Effect.tryPromise(() =>
|
||||
fetch(new URL("/api/health", server), { headers: explicit.headers, signal: AbortSignal.timeout(5_000) }),
|
||||
).pipe(Effect.mapError((cause) => new Error(`Could not reach server at ${server}`, { cause })))
|
||||
if (response.status === 401)
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
password
|
||||
? `Server at ${server} rejected the password`
|
||||
: `Server at ${server} requires a password; set OPENCODE_PASSWORD`,
|
||||
),
|
||||
)
|
||||
if (!response.ok)
|
||||
return yield* Effect.fail(new Error(`Server at ${server} responded with status ${response.status}`))
|
||||
return explicit
|
||||
}
|
||||
if (input.standalone) return yield* Standalone.transport()
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
return found ?? (yield* Service.start(options))
|
||||
})
|
||||
const { runTui } = yield* Effect.promise(() => import("../../tui"))
|
||||
yield* runTui(transport)
|
||||
// The TUI re-runs discover whenever its event stream drops. For an explicit
|
||||
// --server or a standalone child the transport is fixed, so reconnects
|
||||
// retry the same address; for the managed service discovery re-reads the
|
||||
// registration and may start a replacement.
|
||||
const serviceOptions = server === undefined && !input.standalone ? yield* ServiceConfig.options() : undefined
|
||||
const discover = serviceOptions
|
||||
? () =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const found = yield* Service.discover(serviceOptions)
|
||||
return found ?? (yield* Service.start(serviceOptions))
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
: () => Promise.resolve(transport)
|
||||
// Restart the managed service in place; start() resolves once the
|
||||
// replacement is healthy and the reconnect loop reattaches on its own.
|
||||
// Only meaningful in service mode: --server is not ours to restart and a
|
||||
// standalone child cannot be respawned.
|
||||
const reload = serviceOptions
|
||||
? () =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
yield* Service.stop(serviceOptions)
|
||||
yield* Service.start(serviceOptions)
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
: undefined
|
||||
yield* runTui(
|
||||
transport,
|
||||
{ continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||
discover,
|
||||
reload,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { EOL } from "node:os"
|
||||
import path from "node:path"
|
||||
import { Effect, Option } from "effect"
|
||||
import { applyEdits, modify } from "jsonc-parser"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.add,
|
||||
Effect.fn("cli.mcp.add")(function* (input) {
|
||||
const url = Option.getOrUndefined(input.url)
|
||||
const headers = Option.getOrUndefined(input.header)
|
||||
const environment = Option.getOrUndefined(input.env)
|
||||
// The CLI framework strands `--` operands on the root command, so read the local server command
|
||||
// straight from argv after `--`. This also lets the command carry its own flags (e.g. `npx -y`).
|
||||
const dash = process.argv.indexOf("--")
|
||||
const command = dash === -1 ? [...input.command] : process.argv.slice(dash + 1)
|
||||
|
||||
const hasCommand = command.length > 0
|
||||
if (url && hasCommand)
|
||||
return yield* Effect.fail(new Error("Provide either --url <url> or a command after --, not both"))
|
||||
if (!url && !hasCommand) return yield* Effect.fail(new Error("Provide either --url <url> or a command after --"))
|
||||
if (url && !URL.canParse(url)) return yield* Effect.fail(new Error(`Invalid URL: ${url}`))
|
||||
if (url && environment) return yield* Effect.fail(new Error("--env is only valid for local MCP servers"))
|
||||
if (hasCommand && headers) return yield* Effect.fail(new Error("--header is only valid for remote MCP servers"))
|
||||
|
||||
const server = url
|
||||
? { type: "remote" as const, url, ...(headers ? { headers } : {}) }
|
||||
: { type: "local" as const, command, ...(environment ? { environment } : {}) }
|
||||
|
||||
const configPath = yield* Effect.promise(() => resolveConfigPath(input.global ? Global.Path.config : process.cwd()))
|
||||
yield* Effect.promise(() => write(configPath, input.name, server))
|
||||
process.stdout.write(`MCP server "${input.name}" added to ${configPath}` + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
async function resolveConfigPath(directory: string) {
|
||||
const candidates = [
|
||||
path.join(directory, "opencode.json"),
|
||||
path.join(directory, "opencode.jsonc"),
|
||||
path.join(directory, ".opencode", "opencode.json"),
|
||||
path.join(directory, ".opencode", "opencode.jsonc"),
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (await Bun.file(candidate).exists()) return candidate
|
||||
}
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
async function write(configPath: string, name: string, server: unknown) {
|
||||
const file = Bun.file(configPath)
|
||||
const text = (await file.exists()) ? await file.text() : "{}"
|
||||
const edits = modify(text, ["mcp", "servers", name], server, {
|
||||
formattingOptions: { tabSize: 2, insertSpaces: true },
|
||||
})
|
||||
await Bun.write(configPath, applyEdits(text, edits))
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
createOpencodeClient,
|
||||
type IntegrationAttemptStatus,
|
||||
type IntegrationOAuthMethod,
|
||||
type OpencodeClient,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { resolveIntegration } from "./resolve"
|
||||
|
||||
const location = { directory: process.cwd() }
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.auth,
|
||||
Effect.fn("cli.mcp.auth")(function* (input) {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const transport = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
|
||||
|
||||
const integration = yield* resolveIntegration(client, input.name, location)
|
||||
if (!integration)
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
|
||||
const method = integration.methods.find(
|
||||
(candidate): candidate is IntegrationOAuthMethod => candidate.type === "oauth",
|
||||
)
|
||||
if (!method)
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
|
||||
|
||||
const started = yield* Effect.promise(() =>
|
||||
client.v2.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }),
|
||||
)
|
||||
const attempt = started.data?.data
|
||||
if (!attempt) return yield* Effect.fail(new Error(started.error?.message ?? "Failed to start OAuth attempt"))
|
||||
if (attempt.mode === "code")
|
||||
return yield* Effect.fail(new Error("This server requires manual code entry, which the CLI does not support"))
|
||||
|
||||
process.stdout.write(attempt.instructions + EOL + attempt.url + EOL)
|
||||
|
||||
const result = yield* poll(client, attempt.attemptID)
|
||||
if (result.status === "complete") {
|
||||
process.stdout.write(`Authenticated with ${input.name}` + EOL)
|
||||
return
|
||||
}
|
||||
const reason = result.status === "failed" ? `: ${result.message}` : ""
|
||||
return yield* Effect.fail(new Error(`Authentication ${result.status}${reason}`))
|
||||
}),
|
||||
)
|
||||
|
||||
const poll = (
|
||||
client: OpencodeClient,
|
||||
attemptID: string,
|
||||
): Effect.Effect<Exclude<IntegrationAttemptStatus, { status: "pending" }>> =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.promise(() => client.v2.integration.attempt.status({ attemptID, location }))
|
||||
const status = response.data?.data
|
||||
if (!status || status.status === "pending") {
|
||||
yield* Effect.sleep("1 second")
|
||||
return yield* poll(client, attemptID)
|
||||
}
|
||||
return status
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { createOpencodeClient, type McpServer } from "@opencode-ai/sdk/v2/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.list,
|
||||
Effect.fn("cli.mcp.list")(function* () {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const transport = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
|
||||
const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } }))
|
||||
const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
if (servers.length === 0) {
|
||||
process.stdout.write("No MCP servers configured" + EOL)
|
||||
return
|
||||
}
|
||||
const width = Math.max(...servers.map((server) => server.name.length))
|
||||
const lines = servers.map(
|
||||
(server) => `${icon(server.status)} ${server.name.padEnd(width)} ${describe(server.status)}`,
|
||||
)
|
||||
process.stdout.write(lines.join(EOL) + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
function icon(status: McpServer["status"]) {
|
||||
switch (status.status) {
|
||||
case "connected":
|
||||
return "✓"
|
||||
case "needs_auth":
|
||||
return "⚠"
|
||||
case "failed":
|
||||
case "needs_client_registration":
|
||||
return "✗"
|
||||
default:
|
||||
return "○"
|
||||
}
|
||||
}
|
||||
|
||||
function describe(status: McpServer["status"]) {
|
||||
switch (status.status) {
|
||||
case "needs_auth":
|
||||
return "needs authentication"
|
||||
case "needs_client_registration":
|
||||
return `needs client registration: ${status.error}`
|
||||
case "failed":
|
||||
return `failed: ${status.error}`
|
||||
default:
|
||||
return status.status
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { resolveIntegration } from "./resolve"
|
||||
|
||||
const location = { directory: process.cwd() }
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.logout,
|
||||
Effect.fn("cli.mcp.logout")(function* (input) {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const transport = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
|
||||
|
||||
const integration = yield* resolveIntegration(client, input.name, location)
|
||||
if (!integration) {
|
||||
process.stdout.write(`No stored credentials for ${input.name}` + EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const credentials = integration.connections.filter((connection) => connection.type === "credential")
|
||||
if (credentials.length === 0) {
|
||||
process.stdout.write(`No stored credentials for ${input.name}` + EOL)
|
||||
return
|
||||
}
|
||||
|
||||
yield* Effect.forEach(
|
||||
credentials,
|
||||
(connection) => Effect.promise(() => client.v2.credential.remove({ credentialID: connection.id, location })),
|
||||
{ discard: true },
|
||||
)
|
||||
process.stdout.write(`Removed OAuth credentials for ${input.name}` + EOL)
|
||||
}),
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user