Compare commits

..

5 Commits

Author SHA1 Message Date
Adam
a32a46d219 chore: cleanup 2026-02-17 11:23:19 -06:00
Adam
19a1e1ed49 chore: cleanup 2026-02-17 11:23:09 -06:00
Adam
bfe875a651 chore: cleanup 2026-02-17 11:05:27 -06:00
Adam
acb46679e3 chore: cleanup 2026-02-17 10:34:48 -06:00
Adam
fafc74b052 fix(app): virtualizer getting wrong scroll root 2026-02-17 10:29:35 -06:00
207 changed files with 3663 additions and 7808 deletions

15
.github/TEAM_MEMBERS vendored
View File

@@ -1,15 +0,0 @@
adamdotdevin
Brendonovich
fwang
Hona
iamdavidhill
jayair
jlongster
kitlangton
kommander
MrMushrooooom
nexxeln
R44VC0RP
rekram1-node
RhysSullivan
thdxr

View File

@@ -3,13 +3,12 @@ description: "Setup Bun with caching and install dependencies"
runs:
using: "composite"
steps:
- name: Cache Bun dependencies
uses: actions/cache@v4
- name: Mount Bun Cache
if: ${{ runner.os == 'Linux' }}
uses: useblacksmith/stickydisk@v1
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
key: ${{ github.repository }}-bun-cache-${{ runner.os }}
path: ~/.bun
- name: Setup Bun
uses: oven-sh/setup-bun@v2

View File

@@ -1,29 +1,7 @@
### Issue for this PR
Closes #
### Type of change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor / code improvement
- [ ] Documentation
### What does this PR do?
Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR.
Please provide a description of the issue (if there is one), the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR.
**If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!**
### How did you verify your code works?
### Screenshots / recordings
_If this is a UI change, please include a screenshot or recording._
### Checklist
- [ ] I have tested my changes locally
- [ ] I have not included unrelated changes in this PR
_If you do not follow this template your PR will be automatically rejected._

View File

@@ -2,11 +2,10 @@ name: duplicate-issues
on:
issues:
types: [opened, edited]
types: [opened]
jobs:
check-duplicates:
if: github.event.action == 'opened'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
@@ -35,7 +34,7 @@ jobs:
"webfetch": "deny"
}
run: |
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:
Issue number: ${{ github.event.issue.number }}
@@ -116,62 +115,3 @@ jobs:
If you believe this was flagged incorrectly, please let a maintainer know.
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
recheck-compliance:
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance')
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: ./.github/actions/setup-bun
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Recheck compliance
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: |
{
"bash": {
"*": "deny",
"gh issue*": "allow"
},
"webfetch": "deny"
}
run: |
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
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:
1. Bug Report - requires a Description field with real content
2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]:
3. Question - requires the Question field with real content
Additionally check:
- No AI-generated walls of text (long, AI-generated descriptions are not acceptable)
- The issue has real content, not just template placeholder text left unchanged
- Bug reports should include some context about how to reproduce
- Feature requests should explain the problem or need
- We want to push for having the user provide system description & information
Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content.
If the issue is NOW compliant:
1. Remove the needs:compliance label: gh issue edit ${{ github.event.issue.number }} --remove-label needs:compliance
2. Find and delete the previous compliance comment (the one containing <!-- issue-compliance -->) using: gh api repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments --jq '.[] | select(.body | contains(\"<!-- issue-compliance -->\")) | .id' then delete it with: gh api -X DELETE repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments/{id}
3. Post a short comment thanking them for updating the issue.
If the issue is STILL not compliant:
Post a comment explaining what still needs to be fixed. Keep the needs:compliance label."

View File

@@ -0,0 +1,46 @@
name: nix-desktop
on:
push:
branches: [dev]
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
- ".github/workflows/nix-desktop.yml"
pull_request:
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
- ".github/workflows/nix-desktop.yml"
workflow_dispatch:
jobs:
nix-desktop:
strategy:
fail-fast: false
matrix:
os:
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-4vcpu-ubuntu-2404-arm
- macos-15-intel
- macos-latest
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Build desktop via flake
run: |
set -euo pipefail
nix --version
nix build .#desktop -L

View File

@@ -1,95 +0,0 @@
name: nix-eval
on:
push:
branches: [dev]
pull_request:
branches: [dev]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
nix-eval:
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Evaluate flake outputs (all systems)
run: |
set -euo pipefail
nix --version
echo "=== Flake metadata ==="
nix flake metadata
echo ""
echo "=== Flake structure ==="
nix flake show --all-systems
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
PACKAGES="opencode"
# TODO: move 'desktop' to PACKAGES when #11755 is fixed
OPTIONAL_PACKAGES="desktop"
echo ""
echo "=== Evaluating packages for all systems ==="
for system in $SYSTEMS; do
echo ""
echo "--- $system ---"
for pkg in $PACKAGES; do
printf " %s: " "$pkg"
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::error::Evaluation failed for packages.$system.$pkg"
echo "$output"
exit 1
fi
done
done
echo ""
echo "=== Evaluating optional packages ==="
for system in $SYSTEMS; do
echo ""
echo "--- $system ---"
for pkg in $OPTIONAL_PACKAGES; do
printf " %s: " "$pkg"
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::warning::Evaluation failed for packages.$system.$pkg"
echo "$output"
fi
done
done
echo ""
echo "=== Evaluating devShells for all systems ==="
for system in $SYSTEMS; do
printf "%s: " "$system"
if output=$(nix eval ".#devShells.$system.default.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::error::Evaluation failed for devShells.$system.default"
echo "$output"
exit 1
fi
done
echo ""
echo "=== All evaluations passed ==="

View File

@@ -6,7 +6,7 @@ permissions:
on:
workflow_dispatch:
push:
branches: [dev, beta]
branches: [dev]
paths:
- "bun.lock"
- "package.json"

View File

@@ -6,6 +6,17 @@ on:
jobs:
check-duplicates:
if: |
github.event.pull_request.user.login != 'actions-user' &&
github.event.pull_request.user.login != 'opencode' &&
github.event.pull_request.user.login != 'rekram1-node' &&
github.event.pull_request.user.login != 'thdxr' &&
github.event.pull_request.user.login != 'kommander' &&
github.event.pull_request.user.login != 'jayair' &&
github.event.pull_request.user.login != 'fwang' &&
github.event.pull_request.user.login != 'adamdotdevin' &&
github.event.pull_request.user.login != 'iamdavidhill' &&
github.event.pull_request.user.login != 'opencode-agent[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
@@ -16,31 +27,16 @@ jobs:
with:
fetch-depth: 1
- name: Check team membership
id: team-check
run: |
LOGIN="${{ github.event.pull_request.user.login }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] || grep -qxF "$LOGIN" .github/TEAM_MEMBERS; then
echo "is_team=true" >> "$GITHUB_OUTPUT"
echo "Skipping: $LOGIN is a team member or bot"
else
echo "is_team=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun
if: steps.team-check.outputs.is_team != 'true'
uses: ./.github/actions/setup-bun
- name: Install dependencies
if: steps.team-check.outputs.is_team != 'true'
run: bun install
- name: Install opencode
if: steps.team-check.outputs.is_team != 'true'
run: curl -fsSL https://opencode.ai/install | bash
- name: Build prompt
if: steps.team-check.outputs.is_team != 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
@@ -57,7 +53,6 @@ jobs:
} > pr_info.txt
- name: Check for duplicate PRs
if: steps.team-check.outputs.is_team != 'true'
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -6,9 +6,19 @@ on:
jobs:
check-standards:
if: |
github.event.pull_request.user.login != 'actions-user' &&
github.event.pull_request.user.login != 'opencode' &&
github.event.pull_request.user.login != 'rekram1-node' &&
github.event.pull_request.user.login != 'thdxr' &&
github.event.pull_request.user.login != 'kommander' &&
github.event.pull_request.user.login != 'jayair' &&
github.event.pull_request.user.login != 'fwang' &&
github.event.pull_request.user.login != 'adamdotdevin' &&
github.event.pull_request.user.login != 'iamdavidhill' &&
github.event.pull_request.user.login != 'opencode-agent[bot]'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Check PR standards
@@ -16,22 +26,6 @@ jobs:
with:
script: |
const pr = context.payload.pull_request;
const login = pr.user.login;
// Check if author is a team member or bot
if (login === 'opencode-agent[bot]') return;
const { data: file } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/TEAM_MEMBERS',
ref: 'dev'
});
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
if (members.includes(login)) {
console.log(`Skipping: ${login} is a team member`);
return;
}
const title = pr.title;
async function addLabel(label) {
@@ -143,193 +137,3 @@ jobs:
await removeLabel('needs:issue');
console.log('PR meets all standards');
check-compliance:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Check PR template compliance
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const login = pr.user.login;
// Check if author is a team member or bot
if (login === 'opencode-agent[bot]') return;
const { data: file } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/TEAM_MEMBERS',
ref: 'dev'
});
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
if (members.includes(login)) {
console.log(`Skipping: ${login} is a team member`);
return;
}
const body = pr.body || '';
const title = pr.title;
const isDocsOrRefactor = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
const issues = [];
// Check: template sections exist
const hasWhatSection = /### What does this PR do\?/.test(body);
const hasTypeSection = /### Type of change/.test(body);
const hasVerifySection = /### How did you verify your code works\?/.test(body);
const hasChecklistSection = /### Checklist/.test(body);
const hasIssueSection = /### Issue for this PR/.test(body);
if (!hasWhatSection || !hasTypeSection || !hasVerifySection || !hasChecklistSection || !hasIssueSection) {
issues.push('PR description is missing required template sections. Please use the [PR template](../blob/dev/.github/pull_request_template.md).');
}
// Check: "What does this PR do?" has real content (not just placeholder text)
if (hasWhatSection) {
const whatMatch = body.match(/### What does this PR do\?\s*\n([\s\S]*?)(?=###|$)/);
const whatContent = whatMatch ? whatMatch[1].trim() : '';
const placeholder = 'Please provide a description of the issue';
const onlyPlaceholder = whatContent.includes(placeholder) && whatContent.replace(placeholder, '').replace(/[*\s]/g, '').length < 20;
if (!whatContent || onlyPlaceholder) {
issues.push('"What does this PR do?" section is empty or only contains placeholder text. Please describe your changes.');
}
}
// Check: at least one "Type of change" checkbox is checked
if (hasTypeSection) {
const typeMatch = body.match(/### Type of change\s*\n([\s\S]*?)(?=###|$)/);
const typeContent = typeMatch ? typeMatch[1] : '';
const hasCheckedBox = /- \[x\]/i.test(typeContent);
if (!hasCheckedBox) {
issues.push('No "Type of change" checkbox is checked. Please select at least one.');
}
}
// Check: issue reference (skip for docs/refactor)
if (!isDocsOrRefactor && hasIssueSection) {
const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/);
const issueContent = issueMatch ? issueMatch[1].trim() : '';
const hasIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent);
if (!hasIssueRef) {
issues.push('No issue referenced. Please add `Closes #<number>` linking to the relevant issue.');
}
}
// Check: "How did you verify" has content
if (hasVerifySection) {
const verifyMatch = body.match(/### How did you verify your code works\?\s*\n([\s\S]*?)(?=###|$)/);
const verifyContent = verifyMatch ? verifyMatch[1].trim() : '';
if (!verifyContent) {
issues.push('"How did you verify your code works?" section is empty. Please explain how you tested.');
}
}
// Check: checklist boxes are checked
if (hasChecklistSection) {
const checklistMatch = body.match(/### Checklist\s*\n([\s\S]*?)(?=###|$)/);
const checklistContent = checklistMatch ? checklistMatch[1] : '';
const unchecked = (checklistContent.match(/- \[ \]/g) || []).length;
const checked = (checklistContent.match(/- \[x\]/gi) || []).length;
if (checked < 2) {
issues.push('Not all checklist items are checked. Please confirm you have tested locally and have not included unrelated changes.');
}
}
// Helper functions
async function addLabel(label) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [label]
});
}
async function removeLabel(label) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name: label
});
} catch (e) {}
}
const hasComplianceLabel = pr.labels.some(l => l.name === 'needs:compliance');
if (issues.length > 0) {
// Non-compliant
if (!hasComplianceLabel) {
await addLabel('needs:compliance');
}
const marker = '<!-- issue-compliance -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});
const existing = comments.find(c => c.body.includes(marker));
const body_text = `${marker}
This PR doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) and [PR template](../blob/dev/.github/pull_request_template.md).
**What needs to be fixed:**
${issues.map(i => `- ${i}`).join('\n')}
Please edit this PR description to address the above within **2 hours**, or it will be automatically closed.
If you believe this was flagged incorrectly, please let a maintainer know.`;
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: body_text
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: body_text
});
}
console.log(`PR #${pr.number} is non-compliant: ${issues.join(', ')}`);
} else if (hasComplianceLabel) {
// Was non-compliant, now fixed
await removeLabel('needs:compliance');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});
const marker = '<!-- issue-compliance -->';
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id
});
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: 'Thanks for updating your PR! It now meets our contributing guidelines. :+1:'
});
console.log(`PR #${pr.number} is now compliant, label removed`);
} else {
console.log(`PR #${pr.number} is compliant`);
}

View File

@@ -69,10 +69,6 @@ Examples:
- Provider integration issues
- New, broken, or poor-quality models
#### acp
If the issue mentions acp support, assign acp label.
#### docs
Add if the issue requests better documentation or docs updates.
@@ -134,7 +130,3 @@ Determinism rules:
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
ACP:
- rekram1-node (assign any acp issues to rekram1-node)

View File

@@ -68,7 +68,17 @@ export default tool({
results.push("Dropped label: nix (issue does not mention nix)")
}
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
const assignee = nix
? "rekram1-node"
: web
? pick(TEAM.desktop)
: args.assignee === "jlongster"
? "thdxr"
: args.assignee
if (args.assignee === "jlongster" && assignee === "thdxr") {
results.push("Remapped assignee: jlongster -> thdxr (jlongster not assignable yet)")
}
if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'")

View File

@@ -1,9 +0,0 @@
{
"format_on_save": "on",
"formatter": {
"external": {
"command": "bunx",
"arguments": ["prettier", "--stdin-filepath", "{buffer_path}"]
}
}
}

View File

@@ -24,11 +24,6 @@ If you are unsure if a PR would be accepted, feel free to ask a maintainer or lo
Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on.
## Adding New Providers
New providers shouldn't require many if ANY code changes, but if you want to add support for a new provider first make a PR to:
https://github.com/anomalyco/models.dev
## Developing OpenCode
- Requirements: Bun 1.3+

View File

@@ -1,11 +1,5 @@
# Security
## IMPORTANT
We do not accept AI generated security reports. We receive a large number of
these and we absolutely do not have the resources to review them all. If you
submit one that will be an automatic ban from the project.
## Threat Model
### Overview

117
bun.lock
View File

@@ -14,11 +14,10 @@
"devDependencies": {
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"husky": "9.1.7",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "3.18.10",
"sst": "3.17.23",
"turbo": "2.5.6",
},
},
@@ -289,7 +288,7 @@
"@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/gitlab-ai-provider": "3.5.1",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
@@ -325,7 +324,6 @@
"hono-openapi": "catalog:",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"open": "10.1.2",
"opentui-spinner": "0.0.6",
@@ -358,7 +356,6 @@
"@tsconfig/bun": "catalog:",
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
@@ -992,7 +989,7 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.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": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.5.1", "", { "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": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-I8+EGdUeKmGJSjAdFobHtqpxM9Fm00w0j7NJbtln/D/XQ1SKEGoZIuqJko4v0pV2mkhGUIs7qezljH/2kbXovA=="],
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
@@ -1612,7 +1609,7 @@
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.6", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ=="],
"@smithy/core": ["@smithy/core@3.23.2", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-HaaH4VbGie4t0+9nY3tNBRSxVTr96wzIqexUa6C2qx3MPePAuz7lIxPxYtt1Wc//SPfJLNoZJzfdt0B6ksj2jA=="],
"@smithy/core": ["@smithy/core@3.23.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw=="],
@@ -1642,9 +1639,9 @@
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.8", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A=="],
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.16", "", { "dependencies": { "@smithy/core": "^3.23.2", "@smithy/middleware-serde": "^4.2.9", "@smithy/node-config-provider": "^4.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-L5GICFCSsNhbJ5JSKeWFGFy16Q2OhoBizb3X2DrxaJwXSEujVvjG9Jt386dpQn2t7jINglQl0b4K/Su69BdbMA=="],
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.14", "", { "dependencies": { "@smithy/core": "^3.23.0", "@smithy/middleware-serde": "^4.2.9", "@smithy/node-config-provider": "^4.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag=="],
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.33", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/service-error-classification": "^4.2.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-jLqZOdJhtIL4lnA9hXnAG6GgnJlo1sD3FqsTxm9wSfjviqgWesY/TMBVnT84yr4O0Vfe0jWoXlfFbzsBVph3WA=="],
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.31", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/service-error-classification": "^4.2.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg=="],
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.9", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ=="],
@@ -1668,7 +1665,7 @@
"@smithy/signature-v4": ["@smithy/signature-v4@5.3.8", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg=="],
"@smithy/smithy-client": ["@smithy/smithy-client@4.11.5", "", { "dependencies": { "@smithy/core": "^3.23.2", "@smithy/middleware-endpoint": "^4.4.16", "@smithy/middleware-stack": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.12", "tslib": "^2.6.2" } }, "sha512-xixwBRqoeP2IUgcAl3U9dvJXc+qJum4lzo3maaJxifsZxKUYLfVfCXvhT4/jD01sRrHg5zjd1cw2Zmjr4/SuKQ=="],
"@smithy/smithy-client": ["@smithy/smithy-client@4.11.3", "", { "dependencies": { "@smithy/core": "^3.23.0", "@smithy/middleware-endpoint": "^4.4.14", "@smithy/middleware-stack": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.12", "tslib": "^2.6.2" } }, "sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg=="],
"@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
@@ -1684,9 +1681,9 @@
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q=="],
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.32", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-092sjYfFMQ/iaPH798LY/OJFBcYu0sSK34Oy9vdixhsU36zlZu8OcYjF3TD4e2ARupyK7xaxPXl+T0VIJTEkkg=="],
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.30", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng=="],
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.35", "", { "dependencies": { "@smithy/config-resolver": "^4.4.6", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-miz/ggz87M8VuM29y7jJZMYkn7+IErM5p5UgKIf8OtqVs/h2bXr1Bt3uTsREsI/4nK8a0PQERbAPsVPVNIsG7Q=="],
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.33", "", { "dependencies": { "@smithy/config-resolver": "^4.4.6", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA=="],
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw=="],
@@ -1920,8 +1917,6 @@
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
"@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/mssql": ["@types/mssql@9.1.9", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-P0nCgw6vzY23UxZMnbI4N7fnLGANt4LI4yvxze1paPj+LuN28cFv5EI+QidP8udnId/BKhkcRhm/BleNsjK65A=="],
@@ -1988,7 +1983,7 @@
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20251207.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5l51HlXjX7lXwo65DEl1IaCFLjmkMtL6K3NrSEamPNeNTtTQwZRa3pQ9V65dCglnnCQ0M3+VF1RqzC7FU0iDKg=="],
"@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="],
"@typescript/vfs": ["@typescript/vfs@1.6.2", "", { "dependencies": { "debug": "^4.1.1" }, "peerDependencies": { "typescript": "*" } }, "sha512-hoBwJwcbKHmvd2QVebiytN1aELvpk9B74B4L1mFm/XT1Q/VOYAWl2vQ9AWRFtQq8zmz6enTpfTV8WRc4ATjW/g=="],
"@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.3", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA=="],
@@ -2054,7 +2049,7 @@
"ai-gateway-provider": ["ai-gateway-provider@2.3.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.19", "ai": "^5.0.116" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.71", "@ai-sdk/anthropic": "^2.0.56", "@ai-sdk/azure": "^2.0.90", "@ai-sdk/cerebras": "^1.0.33", "@ai-sdk/cohere": "^2.0.21", "@ai-sdk/deepgram": "^1.0.21", "@ai-sdk/deepseek": "^1.0.32", "@ai-sdk/elevenlabs": "^1.0.21", "@ai-sdk/fireworks": "^1.0.30", "@ai-sdk/google": "^2.0.51", "@ai-sdk/google-vertex": "3.0.90", "@ai-sdk/groq": "^2.0.33", "@ai-sdk/mistral": "^2.0.26", "@ai-sdk/openai": "^2.0.88", "@ai-sdk/perplexity": "^2.0.22", "@ai-sdk/xai": "^2.0.42", "@openrouter/ai-sdk-provider": "^1.5.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^1.0.29" } }, "sha512-PqI6TVNEDNwr7kOhy7XUGnA8XJB1SpeA9aLqGjr0CyWkKgH+y+ofPm8MZGZ74DOwVejDF+POZq0Qs9jKEKUeYg=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
@@ -2136,11 +2131,11 @@
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"b4a": ["b4a@1.7.5", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-iEsKNwDh1wiWTps1/hdkNdmBgDlDVZP5U57ZVOlt+dNFqpc/lpPouCIxZw+DYBgc4P9NDfIZMPNR4CHNhzwLIA=="],
"b4a": ["b4a@1.7.4", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-u20zJLDaSWpxaZ+zaAkEIB2dZZ1o+DF4T/MRbmsvGp9nletHOyiai19OzX1fF8xUBYsO1bPXxODvcd0978pnug=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.5", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-8TFKemVLDYezqqv4mWz+PhRrkryTzivTGu0twyLrOkVZ0P63COx2Y04eVsUjFlwSOXui1z3P3Pn209dokWnirg=="],
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.3", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-5HOwwt0BYiv/zxl7j8Pf2bGL6rDXfV6nUhLs8ygBX+EFJXzBPHM/euj9j/6deMZ6wa52Wb2PBaAV5U/jKwIY1w=="],
"babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="],
@@ -2216,13 +2211,13 @@
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qM7W5IaFpWYGPDcNiQ8DOng3noQ97gxpH2MFH1mGsdKwI0T4oy++egSh5Z7s6AQx8WKgc9GzAsTUM4KZkFdacw=="],
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eDgLN9teKTfmvrCqgwwmWNsNszxYs7IZdCqk0S1DCarvMhr4wcajoSBlA/nQA0/owwLduPTS8xxCnQp4/N/gDg=="],
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-oVoIsme27pcXB68YxnQSAgdNGCa4A3PGWYIBUewOh9VnJaoik4JenGb5Yy+svGE+ETFhQXV9nhHqgMPsDRrO6A=="],
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-X+PjwJUWenUmdQBP8EtdItMyieQ6Nlpn+BH518oaouDiSnWj5+b0Y7DNDZJq7Ezom4EaxmqL/uGYZK3aCQ7CXg=="],
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-+SYt09k+xDEl/GfcU7L1zdNgm7IlvAFKV5Xl/auBwuprKG5UwXNhjRlRAWfhTMCUZWN+NDf8E+ZQx0cQi9K2/g=="],
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-zMLs2YIGB+/jxrYFXaFhVKX/GBt05UTF45lc9srcHc9JXGjEj+12CIo1CHLTAWatXMTqt0Jsu6ukWEoWVT/ayA=="],
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-zvnUl4EAsQbKsmZVu+lEJcH8axQ7MiCfqg2OmnHd6uw1THABmHaX0GbpKiHshdgadNN2Nf+4zDyTJB5YMcAdrA=="],
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Z5yAK28xrcm8Wb5k7TZ8FJKpOI/r+aVCRdlHYAqI2SDJFN3nD4mJs900X6kNVmG/xFzb5yOuKVYWGg+6ZXWbyA=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -2242,7 +2237,7 @@
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="],
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
@@ -2476,7 +2471,7 @@
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="],
@@ -2712,7 +2707,7 @@
"h3": ["h3@2.0.1-rc.4", "", { "dependencies": { "rou3": "^0.7.8", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-vZq8pEUp6THsXKXrUXX44eOqfChic2wVQ1GlSzQCBr7DeFBkfIZAo2WyNND4GSv54TAa0E4LYIK73WSPdgKUgw=="],
"happy-dom": ["happy-dom@20.6.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-Xk/Y0cuq9ngN/my8uvK4gKoyDl6sBKkIl8A/hJ0IabZVH7E5SJLHNE7uKRPVmSrQbhJaLIHTEcvTct4GgNtsRA=="],
"happy-dom": ["happy-dom@20.6.1", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^6.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-+0vhESXXhFwkdjZnJ5DlmJIfUYGgIEEjzIjB+aKJbFuqlvvKyOi+XkI1fYbgYR9QCxG5T08koxsQ6HrQfa5gCQ=="],
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
@@ -2924,7 +2919,7 @@
"is-whitespace": ["is-whitespace@0.3.0", "", {}, "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg=="],
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
"is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="],
@@ -3502,7 +3497,7 @@
"punycode": ["punycode@1.3.2", "", {}, "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw=="],
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="],
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
@@ -3764,23 +3759,23 @@
"srvx": ["srvx@0.9.8", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RZaxTKJEE/14HYn8COLuUOJAt0U55N9l1Xf6jj+T0GoA01EUH1Xz5JtSUOI+EHn+AEgPCVn7gk6jHJffrr06fQ=="],
"sst": ["sst@3.18.10", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.18.10", "sst-darwin-x64": "3.18.10", "sst-linux-arm64": "3.18.10", "sst-linux-x64": "3.18.10", "sst-linux-x86": "3.18.10", "sst-win32-arm64": "3.18.10", "sst-win32-x64": "3.18.10", "sst-win32-x86": "3.18.10" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-SY+ldeJ9K5E9q+DhjXA3e2W3BEOzBwkE3IyLSD71uA3/5nRhUAST31iOWEpW36LbIvSQ9uOVDFcebztoLJ8s7w=="],
"sst": ["sst@3.17.23", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.23", "sst-darwin-x64": "3.17.23", "sst-linux-arm64": "3.17.23", "sst-linux-x64": "3.17.23", "sst-linux-x86": "3.17.23", "sst-win32-arm64": "3.17.23", "sst-win32-x64": "3.17.23", "sst-win32-x86": "3.17.23" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-TwKgUgDnZdc1Swe+bvCNeyO4dQnYz5cTodMpYj3jlXZdK9/KNz0PVxT1f0u5E76i1pmilXrUBL/f7iiMPw4RDg=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.18.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3MwIpMZhhdZKDqLp9ZQNlwkWix5+q+N0PWstuTomYwgZOxCCe6u9IIsoIszSk+GAJJN/jvGZyLiXKeV4iiQvw=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.23", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R6kvmF+rUideOoU7KBs2SdvrIupoE+b+Dor/eq9Uo4Dojj7KvYDZI/EDm8sSCbbcx/opiWeyNqKtlnLEdCxE6g=="],
"sst-darwin-x64": ["sst-darwin-x64@3.18.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-nQ0jMKkPOa+kj6Ygz8+kYhBua/vgNTLkd+4r8NSmk7v+Zs78lKnx3T//kEzS0yik6Q6QwGfokwrTcA1Jii2xSw=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.23", "", { "os": "darwin", "cpu": "x64" }, "sha512-WW4P1S35iYCifQXxD+sE3wuzcN+LHLpuKMaNoaBqEcWGZnH3IPaDJ7rpLF0arkDAo/z3jZmWWzOCkr0JuqJ8vQ=="],
"sst-linux-arm64": ["sst-linux-arm64@3.18.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-mj9VNj3SvLS+HaXx2PhCX0aTA7CwJNoM6JhRc0s/zCilqchcvqDjbhpYBJO4brEPv6aOaaa7T3WvIQqtYauK4Q=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.23", "", { "os": "linux", "cpu": "arm64" }, "sha512-TjtNqgIh7RlAWgPLFCAt0mXvIB+J7WjmRvIRrAdX0mXsndOiBJ/DMOgXSLVsIWHCfPj8MIEot/hWpnJgXgIeag=="],
"sst-linux-x64": ["sst-linux-x64@3.18.10", "", { "os": "linux", "cpu": "x64" }, "sha512-7iy1Eq2eqnT9Ag/8OVgC04vRjV7AAQyf/BvzLc+6Sz+GvRiKA8VEuPnbXNYQF+NIvEqsawfcd7MknSTtImpsvQ=="],
"sst-linux-x64": ["sst-linux-x64@3.17.23", "", { "os": "linux", "cpu": "x64" }, "sha512-qdqJiEbYfCjZlI3F/TA6eoIU7JXVkEEI/UMILNf2JWhky0KQdCW2Xyz+wb6c0msVJCWdUM/uj+1DaiP2eXvghw=="],
"sst-linux-x86": ["sst-linux-x86@3.18.10", "", { "os": "linux", "cpu": "none" }, "sha512-77qZSuPZeQ5bdRCiq1pQEdY8EcGNHboKrx4P2yFid2FBDKJsXxOXtIxJdloyx+ljBn0+nxl/g040QBmXxdc9tA=="],
"sst-linux-x86": ["sst-linux-x86@3.17.23", "", { "os": "linux", "cpu": "none" }, "sha512-aGmUujIvoNlmAABEGsOgfY1rxD9koC6hN8bnTLbDI+oI/u/zjHYh50jsbL0p3TlaHpwF/lxP3xFSuT6IKp+KgA=="],
"sst-win32-arm64": ["sst-win32-arm64@3.18.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-aY+FhMxvYs8crlrKALpLn/kKmud8YQj6LkMHsrOAAIJhfNyxhCja2vrYQaY+bcqdsS5W2LMVcS2hyaMqKXZKcg=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.23", "", { "os": "win32", "cpu": "arm64" }, "sha512-ZxdkGqYDrrZGz98rijDCN+m5yuCcwD6Bc9/6hubLsvdpNlVorUqzpg801Ec97xSK0nIC9g6pNiRyxAcsQQstUg=="],
"sst-win32-x64": ["sst-win32-x64@3.18.10", "", { "os": "win32", "cpu": "x64" }, "sha512-rY+yJXOpG+P5xXnaQRpCvBK2zwwLhjzpYidGkp6F+cGgiVdh2Wre/CIQNRaVHr20ncj8lLe/RsHWa9QCNM48jg=="],
"sst-win32-x64": ["sst-win32-x64@3.17.23", "", { "os": "win32", "cpu": "x64" }, "sha512-yc9cor4MS49Ccy2tQCF1tf6M81yLeSGzGL+gjhUxpVKo2pN3bxl3w70eyU/mTXSEeyAmG9zEfbt6FNu4sy5cUA=="],
"sst-win32-x86": ["sst-win32-x86@3.18.10", "", { "os": "win32", "cpu": "none" }, "sha512-pq8SmV0pIjBFMY6DraUZ4akyTxHnfjIKCRbBLdMxFUZK8TzA1NK2YdjRt1AwrgXRYGRyctrz/mt4WyO0SMOVQQ=="],
"sst-win32-x86": ["sst-win32-x86@3.17.23", "", { "os": "win32", "cpu": "none" }, "sha512-DIp3s54IpNAfdYjSRt6McvkbEPQDMxUu6RUeRAd2C+FcTJgTloon/ghAPQBaDgu2VoVgymjcJARO/XyfKcCLOQ=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
@@ -3846,19 +3841,19 @@
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="],
"tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="],
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
"tarn": ["tarn@3.0.2", "", {}, "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ=="],
"tedious": ["tedious@18.6.2", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.1", "@types/node": ">=18", "bl": "^6.0.11", "iconv-lite": "^0.6.3", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg=="],
"tedious": ["tedious@19.2.1", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.5", "@types/node": ">=18", "bl": "^6.1.4", "iconv-lite": "^0.7.0", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-pk1Q16Yl62iocuQB+RWbg6rFUFkIyzqOFQ6NfysCltRvQqKwfurgj8v/f2X+CKvDhSL4IJ0cCOfCHDg9PWEEYA=="],
"terracotta": ["terracotta@1.1.0", "", { "dependencies": { "solid-use": "^0.9.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-kfQciWUBUBgYkXu7gh3CK3FAJng/iqZslAaY08C+k1Hdx17aVEpcFFb/WPaysxAfcupNH3y53s/pc53xxZauww=="],
"terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="],
"text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="],
"text-decoder": ["text-decoder@1.2.4", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-mzlffA3tBNhziEHPK5L5InZg1d/ElNIpJhnhbDRNUtem/edZcJ5zg5FgwKKKOyklxk+6Jt+TrSu83musmvrDlg=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
@@ -3966,7 +3961,7 @@
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
"undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="],
"undici": ["undici@7.21.0", "", {}, "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg=="],
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
@@ -4132,7 +4127,7 @@
"xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
@@ -4282,6 +4277,8 @@
"@azure/core-http/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"@azure/core-http/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"@azure/core-xml/fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
"@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
@@ -4302,7 +4299,7 @@
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
"@gitlab/gitlab-ai-provider/openai": ["openai@6.22.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-7Yvy17F33Bi9RutWbsaYt5hJEEJ/krRPOrwan+f9aCPuMat1WVsb2VNSII5W1EksKT6fF69TG/xj4XzodK3JZw=="],
"@gitlab/gitlab-ai-provider/openai": ["openai@6.21.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-26dQFi76dB8IiN/WKGQOV+yKKTTlRCxQjoi2WLt0kMcH8pvxVyvfdBDkld5GTl7W1qvBpwVOtFcsqktj3fBRpA=="],
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -4504,9 +4501,7 @@
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.63", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zXlUPCkumnvp8lWS9VFcen/MLF6CL/t1zAKDhpobYj9y/nmylQrKtRvn3RwH871Wd3dF3KYEUXd6M2c6dfCKOA=="],
"ai-gateway-provider/@ai-sdk/google": ["@ai-sdk/google@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ccCxr5mrd3AC2CjLq4e1ST7+UiN5T2Pdmgi0XdWM3QohmNBwUQ/RBG7BvL+cB/ex/j6y64tkMmpYz9zBw/SEFQ=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.62", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-I3RhaOEMnWlWnrvjNBOYvUb19Dwf2nw01IruZrVJRDi688886e11wnd5DxrBZLd2V29Gizo3vpOPnnExsA+wTA=="],
"ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
@@ -4514,8 +4509,6 @@
"ai-gateway-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ=="],
"ai-gateway-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -4536,8 +4529,6 @@
"aws-sdk/events": ["events@1.1.1", "", {}, "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw=="],
"aws-sdk/xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
"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=="],
@@ -4548,10 +4539,6 @@
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"body-parser/qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="],
"buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="],
"c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
@@ -4586,8 +4573,6 @@
"express/path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="],
"express/qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="],
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -4596,7 +4581,7 @@
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"glob/minimatch": ["minimatch@10.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="],
"glob/minimatch": ["minimatch@10.2.0", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w=="],
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -4634,6 +4619,8 @@
"mssql/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"mssql/tedious": ["tedious@18.6.2", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.1", "@types/node": ">=18", "bl": "^6.0.11", "iconv-lite": "^0.6.3", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg=="],
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
@@ -4662,9 +4649,9 @@
"p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"parse-bmfont-xml/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
@@ -4722,8 +4709,6 @@
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -4740,8 +4725,6 @@
"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=="],
"unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
@@ -4762,8 +4745,6 @@
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"xml2js/sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
"yaml-language-server/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="],
@@ -5024,6 +5005,8 @@
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
@@ -5032,7 +5015,7 @@
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"ai-gateway-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -5052,8 +5035,6 @@
"astro/unstorage/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=="],
"aws-sdk/xml2js/sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
"babel-plugin-module-resolver/glob/minimatch": ["minimatch@8.0.4", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA=="],
"babel-plugin-module-resolver/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
@@ -5088,6 +5069,8 @@
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"mssql/tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"opencode/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"opencontrol/@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=="],

1
github/sst-env.d.ts vendored
View File

@@ -2,7 +2,6 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../sst-env.d.ts" />

View File

@@ -214,9 +214,9 @@ new sst.cloudflare.x.SolidStart("Console", {
},
transform: {
server: {
placement: { region: "aws:us-east-1" },
transform: {
worker: {
placement: { mode: "smart" },
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
},
},

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-7y6gQyIxyrdp2DaG/0oOEpuL+1n9oa8arUn1CuDiDhA=",
"aarch64-linux": "sha256-7dnHO2WqQZ9A8cG3EC8p7408YR9n2F5C6DG5rNWHqNY=",
"aarch64-darwin": "sha256-jxjhnVfE61RVOHaWvDO4mGLk6guQ8jHeXv/pbu5nbaE=",
"x86_64-darwin": "sha256-22yM4FEtVxGWRug6H0rKog86Q/cYE3QsADrRbLeJKVQ="
"x86_64-linux": "sha256-C3WIEER2XgzO85wk2sp3BzQ6dknW026zslD8nKZjo2U=",
"aarch64-linux": "sha256-+tTJHZMZ/+8fAjI/1fUTuca8J2MZfB+5vhBoZ7jgqcE=",
"aarch64-darwin": "sha256-vS82puFGBBToxyIBa8Zi0KLKdJYr64T6HZL2rL32mH8=",
"x86_64-darwin": "sha256-Tr8JMTCxV6WVt3dXV7iq3PNCm2Cn+RXAbU9+o7pKKV0="
}
}

View File

@@ -69,11 +69,10 @@
"devDependencies": {
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"husky": "9.1.7",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "3.18.10",
"sst": "3.17.23",
"turbo": "2.5.6"
},
"dependencies": {

View File

@@ -1,36 +1,35 @@
import "@/index.css"
import { Code } from "@opencode-ai/ui/code"
import { I18nProvider } from "@opencode-ai/ui/context"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { Diff } from "@opencode-ai/ui/diff"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Navigate, Route, Router } from "@solidjs/router"
import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { GlobalSDKProvider } from "@/context/global-sdk"
import { Font } from "@opencode-ai/ui/font"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { I18nProvider } from "@opencode-ai/ui/context"
import { Diff } from "@opencode-ai/ui/diff"
import { Code } from "@opencode-ai/ui/code"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { GlobalSyncProvider } from "@/context/global-sync"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, useLanguage } from "@/context/language"
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 { type ServerConnection, ServerProvider, useServer } from "@/context/server"
import { LayoutProvider } from "@/context/layout"
import { GlobalSDKProvider } from "@/context/global-sdk"
import { normalizeServerUrl, ServerProvider, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout"
import { PromptProvider } from "@/context/prompt"
import { FileProvider } from "@/context/file"
import { CommentsProvider } from "@/context/comments"
import { NotificationProvider } from "@/context/notification"
import { ModelsProvider } from "@/context/models"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
import { LanguageProvider, useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { HighlightsProvider } from "@/context/highlights"
import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout"
import { ErrorPage } from "./pages/error"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full" />
@@ -58,11 +57,7 @@ function UiI18nBridge(props: ParentProps) {
declare global {
interface Window {
__OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[]
wsl?: boolean
}
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean }
}
}
@@ -112,6 +107,30 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
)
}
const getStoredDefaultServerUrl = (platform: ReturnType<typeof usePlatform>) => {
if (platform.platform !== "web") return
const result = platform.getDefaultServerUrl?.()
if (result instanceof Promise) return
if (!result) return
return normalizeServerUrl(result)
}
const resolveDefaultServerUrl = (props: {
defaultUrl?: string
storedDefaultServerUrl?: string
hostname: string
origin: string
isDev: boolean
devHost?: string
devPort?: string
}) => {
if (props.defaultUrl) return props.defaultUrl
if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl
if (props.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}`
return props.origin
}
export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
@@ -138,19 +157,27 @@ export function AppBaseProviders(props: ParentProps) {
function ServerKey(props: ParentProps) {
const server = useServer()
return (
<Show when={server.key} keyed>
<Show when={server.url} keyed>
{props.children}
</Show>
)
}
export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
servers?: Array<ServerConnection.Any>
}) {
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
const platform = usePlatform()
const storedDefaultServerUrl = getStoredDefaultServerUrl(platform)
const defaultServerUrl = resolveDefaultServerUrl({
defaultUrl: props.defaultUrl,
storedDefaultServerUrl,
hostname: location.hostname,
origin: window.location.origin,
isDev: import.meta.env.DEV,
devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST,
devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT,
})
return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>

View File

@@ -1,18 +1,19 @@
import { Button } from "@opencode-ai/ui/button"
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useNavigate } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { useGlobalSDK } from "@/context/global-sdk"
import { showToast } from "@opencode-ai/ui/toast"
import { ServerRow } from "@/components/server/server-row"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
interface AddRowProps {
@@ -88,7 +89,7 @@ function useServerPreview(fetcher: typeof fetch) {
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const result = await checkServerHealth({ url: normalized }, fetcher)
const result = await checkServerHealth(normalized, fetcher)
setStatus(result.healthy)
}
@@ -170,13 +171,14 @@ export function DialogSelectServer() {
const dialog = useDialog()
const server = useServer()
const platform = usePlatform()
const globalSDK = useGlobalSDK()
const language = useLanguage()
const fetcher = platform.fetch ?? globalThis.fetch
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
const { previewStatus } = useServerPreview(fetcher)
let listRoot: HTMLDivElement | undefined
const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
status: {} as Record<string, ServerHealth | undefined>,
addServer: {
url: "",
adding: false,
@@ -212,25 +214,24 @@ export function DialogSelectServer() {
})
}
const replaceServer = (original: ServerConnection.Http, next: string) => {
const active = server.key
const newConn = server.add(next)
if (!newConn) return
const replaceServer = (original: string, next: string) => {
const active = server.url
const nextActive = active === original ? next : active
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
server.add(next)
if (nextActive) server.setActive(nextActive)
server.remove(ServerConnection.key(original))
server.remove(original)
}
const items = createMemo(() => {
const current = server.current
const current = server.url
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)]
})
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0])
const sortedItems = createMemo(() => {
const list = items()
@@ -245,17 +246,17 @@ export function DialogSelectServer() {
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)])
const diff = rank(store.status[a]) - rank(store.status[b])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
async function refreshHealth() {
const results: Record<ServerConnection.Key, ServerHealth> = {}
const results: Record<string, ServerHealth> = {}
await Promise.all(
items().map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
items().map(async (url) => {
results[url] = await checkServerHealth(url, fetcher)
}),
)
setStore("status", reconcile(results))
@@ -268,15 +269,15 @@ export function DialogSelectServer() {
onCleanup(() => clearInterval(interval))
})
async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
async function select(value: string, persist?: boolean) {
if (!persist && store.status[value]?.healthy === false) return
dialog.close()
if (persist) {
server.add(conn.http.url)
server.add(value)
navigate("/")
return
}
server.setActive(ServerConnection.key(conn))
server.setActive(value)
navigate("/")
}
@@ -310,7 +311,7 @@ export function DialogSelectServer() {
setStore("addServer", { adding: true, error: "" })
const result = await checkServerHealth({ url: normalized }, fetcher)
const result = await checkServerHealth(normalized, fetcher)
setStore("addServer", { adding: false })
if (!result.healthy) {
@@ -319,25 +320,25 @@ export function DialogSelectServer() {
}
resetAdd()
await select({ type: "http", http: { url: normalized } }, true)
await select(normalized, true)
}
async function handleEdit(original: ServerConnection.Any, value: string) {
if (store.editServer.busy || original.type !== "http") return
async function handleEdit(original: string, value: string) {
if (store.editServer.busy) return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetEdit()
return
}
if (normalized === original.http.url) {
if (normalized === original) {
resetEdit()
return
}
setStore("editServer", { busy: true, error: "" })
const result = await checkServerHealth({ url: normalized }, fetcher)
const result = await checkServerHealth(normalized, fetcher)
setStore("editServer", { busy: false })
if (!result.healthy) {
@@ -365,7 +366,7 @@ export function DialogSelectServer() {
handleAdd(store.addServer.url)
}
const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => {
const handleEditKey = (event: KeyboardEvent, original: string) => {
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
@@ -377,7 +378,7 @@ export function DialogSelectServer() {
handleEdit(original, store.editServer.value)
}
async function handleRemove(url: ServerConnection.Key) {
async function handleRemove(url: string) {
server.remove(url)
if ((await platform.getDefaultServerUrl?.()) === url) {
platform.setDefaultServerUrl?.(null)
@@ -389,14 +390,11 @@ export function DialogSelectServer() {
<div class="flex flex-col gap-2">
<div ref={(el) => (listRoot = el)}>
<List
search={{
placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false,
}}
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => x.http.url}
key={(x) => x}
onSelect={(x) => {
if (x) select(x)
}}
@@ -430,7 +428,7 @@ export function DialogSelectServer() {
return (
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show
when={store.editServer.id !== i.http.url}
when={store.editServer.id !== i}
fallback={
<EditRow
value={store.editServer.value}
@@ -445,12 +443,12 @@ export function DialogSelectServer() {
}
>
<ServerRow
conn={i}
status={store.status[ServerConnection.key(i)]}
dimmed={store.status[ServerConnection.key(i)]?.healthy === false}
url={i}
status={store.status[i]}
dimmed={store.status[i]?.healthy === false}
class="flex items-center gap-3 px-4 min-w-0 flex-1"
badge={
<Show when={defaultUrl() === i.http.url}>
<Show when={defaultUrl() === i}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
@@ -458,63 +456,59 @@ export function DialogSelectServer() {
}
/>
</Show>
<Show when={store.editServer.id !== i.http.url}>
<Show when={store.editServer.id !== i}>
<div class="flex items-center justify-center gap-5 pl-4">
<Show when={current() === i}>
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
</Show>
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
setStore("editServer", {
id: i.http.url,
value: i.http.url,
error: "",
status: store.status[ServerConnection.key(i)]?.healthy,
})
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultUrl() !== i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultUrl() === i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
setStore("editServer", {
id: i,
value: i,
error: "",
status: store.status[i]?.healthy,
})
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultUrl() !== i}>
<DropdownMenu.Item onSelect={() => setDefault(i)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.delete")}
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</Show>
<Show when={canDefault() && defaultUrl() === i}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(i)}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</Show>
</div>

View File

@@ -71,13 +71,13 @@ const kindLabel = (kind: Kind) => {
const kindTextColor = (kind: Kind) => {
if (kind === "add") return "color: var(--icon-diff-add-base)"
if (kind === "del") return "color: var(--icon-diff-delete-base)"
return "color: var(--icon-diff-modified-base)"
return "color: var(--icon-warning-active)"
}
const kindDotColor = (kind: Kind) => {
if (kind === "add") return "background-color: var(--icon-diff-add-base)"
if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
return "background-color: var(--icon-diff-modified-base)"
return "background-color: var(--icon-warning-active)"
}
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
@@ -447,13 +447,12 @@ export default function FileTree(props: {
})
return (
<div data-component="filetree" class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
<div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
<For each={nodes()}>
{(node) => {
const expanded = () => file.tree.state(node.path)?.expanded ?? false
const deep = () => deeps().get(node.path) ?? -1
const kind = () => visibleKind(node, kinds(), marks())
const active = () => !!kind() && !node.ignored
return (
<Switch>
@@ -531,37 +530,7 @@ export default function FileTree(props: {
onClick={() => props.onFileClick?.(node)}
>
<div class="w-4 shrink-0" />
<Switch>
<Match when={node.ignored}>
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--mono"
style="color: var(--icon-weak-base)"
mono
/>
</Match>
<Match when={active()}>
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--mono"
style={kindTextColor(kind()!)}
mono
/>
</Match>
<Match when={!node.ignored}>
<span class="filetree-iconpair size-4">
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--color opacity-0 group-hover/filetree:opacity-100"
/>
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--mono group-hover/filetree:opacity-0"
mono
/>
</span>
</Match>
</Switch>
<FileIcon node={node} class="text-icon-weak size-4" />
</FileTreeNode>
</FileTreeNodeTooltip>
</Match>

View File

@@ -1,5 +1,5 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
import { createEffect, on, Component, Show, For, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
@@ -109,7 +109,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let slashPopoverRef!: HTMLDivElement
const mirror = { input: false }
const inset = 44
const scrollCursorIntoView = () => {
const container = scrollRef
@@ -119,14 +118,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const range = selection.getRangeAt(0)
if (!editorRef.contains(range.startContainer)) return
const cursor = getCursorPosition(editorRef)
const length = promptLength(prompt.current().filter((part) => part.type !== "image"))
if (cursor >= length) {
container.scrollTop = container.scrollHeight
return
}
const rect = range.getClientRects().item(0) ?? range.getBoundingClientRect()
const rect = range.getBoundingClientRect()
if (!rect.height) return
const containerRect = container.getBoundingClientRect()
@@ -139,8 +131,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
if (bottom > container.scrollTop + container.clientHeight - inset) {
container.scrollTop = bottom - container.clientHeight + inset
if (bottom > container.scrollTop + container.clientHeight - padding) {
container.scrollTop = bottom - container.clientHeight + padding
}
}
@@ -250,6 +242,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return messages.some((m) => m.role === "user")
})
const MAX_HISTORY = 100
const [history, setHistory] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<{
@@ -319,9 +312,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
requestAnimationFrame(() => editorRef?.focus())
}
const shellModeKey = "mod+shift+x"
const normalModeKey = "mod+shift+e"
command.register("prompt-input", () => [
{
id: "file.attach",
@@ -331,22 +321,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
disabled: store.mode !== "normal",
onSelect: pick,
},
{
id: "prompt.mode.shell",
title: language.t("command.prompt.mode.shell"),
category: language.t("command.category.session"),
keybind: shellModeKey,
disabled: store.mode === "shell",
onSelect: () => setMode("shell"),
},
{
id: "prompt.mode.normal",
title: language.t("command.prompt.mode.normal"),
category: language.t("command.category.session"),
keybind: normalModeKey,
disabled: store.mode === "normal",
onSelect: () => setMode("normal"),
},
])
const closePopover = () => setStore("popover", null)
@@ -403,10 +377,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [composing, setComposing] = createSignal(false)
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
const handleBlur = () => {
closePopover()
setComposing(false)
}
createEffect(() => {
if (!isFocused()) closePopover()
})
// Safety: reset composing state on focus change to prevent stuck state
// This handles edge cases where compositionend event may not fire
createEffect(() => {
if (!isFocused()) setComposing(false)
})
const agentList = createMemo(() =>
sync.data.agent
@@ -1081,7 +1060,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
removeLabel={language.t("prompt.attachment.remove")}
/>
<div
class="relative"
class="relative max-h-[240px] overflow-y-auto no-scrollbar"
ref={(el) => (scrollRef = el)}
onMouseDown={(e) => {
const target = e.target
if (!(target instanceof HTMLElement)) return
@@ -1095,154 +1075,151 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef?.focus()
}}
>
<div class="relative max-h-[240px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
<div
data-component="prompt-input"
ref={(el) => {
editorRef = el
props.ref?.(el)
}}
role="textbox"
aria-multiline="true"
aria-label={placeholder()}
contenteditable="true"
autocapitalize="off"
autocorrect="off"
spellcheck={false}
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"w-full pl-3 pr-2 pt-2 pb-12 text-14-regular leading-[var(--line-height-large)] text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
/>
<Show when={!prompt.dirty()}>
<div
data-component="prompt-input"
ref={(el) => {
editorRef = el
props.ref?.(el)
}}
role="textbox"
aria-multiline="true"
aria-label={placeholder()}
contenteditable="true"
autocapitalize="off"
autocorrect="off"
spellcheck={false}
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"w-full pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
/>
<Show when={!prompt.dirty()}>
<div
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
classList={{ "font-mono!": store.mode === "shell" }}
>
{placeholder()}
</div>
</Show>
</div>
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden"
onChange={(e) => {
const file = e.currentTarget.files?.[0]
if (file) addImageAttachment(file)
e.currentTarget.value = ""
}}
/>
<div
aria-hidden={store.mode !== "normal"}
class="flex items-center gap-1 transition-all duration-200 ease-out"
classList={{
"opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
"opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
}}
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-12 text-14-regular leading-[var(--line-height-large)] text-text-weak pointer-events-none whitespace-nowrap truncate"
classList={{ "font-mono!": store.mode === "shell" }}
>
<TooltipKeybind
placement="top"
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
>
<Button
data-action="prompt-attach"
type="button"
variant="ghost"
class="size-8 p-0"
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
>
<Icon name="plus" class="size-4.5" />
</Button>
</TooltipKeybind>
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
value={
<Switch>
<Match when={working()}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
</Match>
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
</Match>
</Switch>
}
>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</div>
<Show when={store.mode === "normal" && permission.permissionsEnabled() && params.id}>
<div class="pointer-events-none absolute bottom-2 left-2">
<div class="pointer-events-auto">
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.permissions.autoaccept.enable")}
keybind={command.keybind("permissions.autoaccept")}
>
<Button
data-action="prompt-permissions"
variant="ghost"
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}}
aria-label={
permission.isAutoAccepting(params.id!, sdk.directory)
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable")
}
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
>
<Icon
name="chevron-double-right"
size="small"
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
/>
</Button>
</TooltipKeybind>
</div>
{placeholder()}
</div>
</Show>
</div>
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden"
onChange={(e) => {
const file = e.currentTarget.files?.[0]
if (file) addImageAttachment(file)
e.currentTarget.value = ""
}}
/>
<div
aria-hidden={store.mode !== "normal"}
class="flex items-center gap-1 transition-all duration-200 ease-out"
classList={{
"opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
"opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
}}
>
<TooltipKeybind
placement="top"
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
>
<Button
data-action="prompt-attach"
type="button"
variant="ghost"
class="size-8 p-0"
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
>
<Icon name="plus" class="size-4.5" />
</Button>
</TooltipKeybind>
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
value={
<Switch>
<Match when={working()}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
</Match>
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
</Match>
</Switch>
}
>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</div>
<Show when={store.mode === "normal" && permission.permissionsEnabled() && params.id}>
<div class="pointer-events-none absolute bottom-2 left-2">
<div class="pointer-events-auto">
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.permissions.autoaccept.enable")}
keybind={command.keybind("permissions.autoaccept")}
>
<Button
data-action="prompt-permissions"
variant="ghost"
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}}
aria-label={
permission.isAutoAccepting(params.id!, sdk.directory)
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable")
}
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
>
<Icon
name="chevron-double-right"
size="small"
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
/>
</Button>
</TooltipKeybind>
</div>
</div>
</Show>
</form>
<Show when={store.mode === "normal" || store.mode === "shell"}>
<div class="-mt-3.5 bg-background-base border border-border-weak-base relative z-0 rounded-[12px] rounded-tl-0 rounded-tr-0 overflow-clip">
@@ -1354,35 +1331,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</TooltipKeybind>
</Show>
</div>
<div class="shrink-0" data-component="prompt-mode-toggle">
<RadioGroup
options={["shell", "normal"] as const}
current={store.mode}
value={(mode) => mode}
label={(mode) => (
<TooltipKeybind
placement="top"
gutter={4}
openDelay={2000}
title={language.t(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
keybind={command.keybind(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
class="size-full flex items-center justify-center"
>
<Icon
name={mode === "shell" ? "console" : "prompt"}
class="size-[18px]"
classList={{
"text-icon-strong-base": store.mode === mode,
"text-icon-weak": store.mode !== mode,
}}
/>
</TooltipKeybind>
)}
onSelect={(mode) => mode && setMode(mode)}
fill
pad="none"
class="w-[68px]"
/>
<div class="shrink-0">
<div data-component="prompt-mode-toggle">
<RadioGroup
options={["shell", "normal"] as const}
current={store.mode}
onSelect={(mode) => mode && setMode(mode)}
label={(mode) => (
<div class="flex size-full items-center justify-center">
<Icon
name={mode === "shell" ? "console" : "prompt"}
class="size-[18px]"
classList={{
"text-icon-strong-base": mode === "shell" && store.mode === "shell",
"text-icon-interactive-base": mode === "normal" && store.mode === "normal",
"text-icon-weak": store.mode !== mode,
}}
/>
<span class="sr-only">{mode === "shell" ? language.t("prompt.mode.shell") : mode}</span>
</div>
)}
/>
</div>
</div>
</div>
</div>

View File

@@ -12,27 +12,24 @@ let selected = "/repo/worktree-a"
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const clientFor = (directory: string) => {
createdClients.push(directory)
return {
session: {
create: async () => {
createdSessions.push(directory)
return { data: { id: `session-${createdSessions.length}` } }
},
shell: async () => {
sentShell.push(directory)
return { data: undefined }
},
prompt: async () => ({ data: undefined }),
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
const clientFor = (directory: string) => ({
session: {
create: async () => {
createdSessions.push(directory)
return { data: { id: `session-${createdSessions.length}` } }
},
worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }),
shell: async () => {
sentShell.push(directory)
return { data: undefined }
},
}
}
prompt: async () => ({ data: undefined }),
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }),
},
})
beforeAll(async () => {
const rootClient = clientFor("/repo/main")
@@ -91,17 +88,11 @@ beforeAll(async () => {
}))
mock.module("@/context/sdk", () => ({
useSDK: () => {
const sdk = {
directory: "/repo/main",
client: rootClient,
url: "http://localhost:4096",
createClient(opts: any) {
return clientFor(opts.directory)
},
}
return sdk
},
useSDK: () => ({
directory: "/repo/main",
client: rootClient,
url: "http://localhost:4096",
}),
}))
mock.module("@/context/sync", () => ({

View File

@@ -1,20 +1,21 @@
import type { Message } from "@opencode-ai/sdk/v2/client"
import { Accessor } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { useNavigate, useParams } from "@solidjs/router"
import type { Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { buildRequestParts } from "./build-request-parts"
import type { FileSelection } from "@/context/file"
import { setCursorPosition } from "./editor-dom"
import { buildRequestParts } from "./build-request-parts"
type PendingPrompt = {
abort: AbortController
@@ -55,6 +56,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sdk = useSDK()
const sync = useSync()
const globalSync = useGlobalSync()
const platform = usePlatform()
const local = useLocal()
const prompt = usePrompt()
const layout = useLayout()
@@ -173,7 +175,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
if (sessionDirectory !== projectDirectory) {
client = sdk.createClient({
client = createOpencodeClient({
baseUrl: sdk.url,
fetch: platform.fetch,
directory: sessionDirectory,
throwOnError: true,
})
@@ -368,10 +372,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const timer = { id: undefined as number | undefined }
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
timer.id = window.setTimeout(() => {
resolve({
status: "failed",
message: language.t("workspace.error.stillPreparing"),
})
resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
}, timeoutMs)
})

View File

@@ -1,7 +1,6 @@
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
@@ -233,11 +232,9 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
}
return (
<DockPrompt
kind="question"
ref={(el) => (root = el)}
header={
<>
<div data-component="question-prompt" ref={(el) => (root = el)}>
<div data-slot="question-body">
<div data-slot="question-header">
<div data-slot="question-header-title">{summary()}</div>
<div data-slot="question-progress">
<For each={questions()}>
@@ -257,169 +254,172 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
)}
</For>
</div>
</>
}
footer={
<>
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
{language.t("ui.common.dismiss")}
</Button>
<div data-slot="question-footer-actions">
<Show when={store.tab > 0}>
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
{language.t("ui.common.back")}
</Button>
</Show>
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</Button>
</div>
</>
}
>
<div data-slot="question-text">{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) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={store.sending}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
</div>
<div data-slot="question-content">
<div data-slot="question-text">{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) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={store.sending}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
)
}}
</For>
<Show
when={store.editing}
fallback={
<button
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={store.sending}
onClick={customOpen}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">
{input() || language.t("ui.question.custom.placeholder")}
</span>
</span>
</button>
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
onMouseDown={(e) => {
if (store.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()
}}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={store.sending}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
}
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
</span>
</button>
)
}}
</For>
<Show
when={store.editing}
fallback={
<button
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={store.sending}
onClick={customOpen}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
</span>
</button>
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
onMouseDown={(e) => {
if (store.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()
}}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={store.sending}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
}
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
</span>
</form>
</Show>
</form>
</Show>
</div>
</div>
</div>
</DockPrompt>
<div data-slot="question-footer">
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
{language.t("ui.common.dismiss")}
</Button>
<div data-slot="question-footer-actions">
<Show when={store.tab > 0}>
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
{language.t("ui.common.back")}
</Button>
</Show>
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -1,19 +1,10 @@
import { Tooltip } from "@opencode-ai/ui/tooltip"
import {
createEffect,
createMemo,
createSignal,
type JSXElement,
onCleanup,
onMount,
type ParentProps,
Show,
} from "solid-js"
import { type ServerConnection, serverDisplayName } from "@/context/server"
import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { serverDisplayName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps {
conn: ServerConnection.Any
url: string
status?: ServerHealth
class?: string
nameClass?: string
@@ -26,7 +17,7 @@ export function ServerRow(props: ServerRowProps) {
const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
const name = createMemo(() => serverDisplayName(props.conn))
const name = createMemo(() => serverDisplayName(props.url))
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -36,7 +27,7 @@ export function ServerRow(props: ServerRowProps) {
createEffect(() => {
name()
props.conn.http.url
props.url
props.status?.version
queueMicrotask(check)
})

View File

@@ -5,7 +5,6 @@ import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
@@ -17,6 +16,13 @@ import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
interface SessionContextTabProps {
messages: () => Message[]
visibleUserMessages: () => UserMessage[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
}
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
system: "var(--syntax-info)",
user: "var(--syntax-success)",
@@ -85,45 +91,11 @@ function RawMessage(props: {
)
}
const emptyMessages: Message[] = []
const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() {
export function SessionContextTab(props: SessionContextTabProps) {
const params = useParams()
const sync = useSync()
const layout = useLayout()
const language = useLanguage()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const messages = createMemo(
() => {
const id = params.id
if (!id) return emptyMessages
return (sync.data.message[id] ?? []) as Message[]
},
emptyMessages,
{ equals: same },
)
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
},
emptyUserMessages,
{ equals: same },
)
const usd = createMemo(
() =>
new Intl.NumberFormat(language.locale(), {
@@ -132,7 +104,7 @@ export function SessionContextTab() {
}),
)
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
@@ -141,7 +113,7 @@ export function SessionContextTab() {
})
const counts = createMemo(() => {
const all = messages()
const all = props.messages()
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
return {
@@ -152,7 +124,7 @@ export function SessionContextTab() {
})
const systemPrompt = createMemo(() => {
const msg = findLast(visibleUserMessages(), (m) => !!m.system)
const msg = findLast(props.visibleUserMessages(), (m) => !!m.system)
const system = msg?.system
if (!system) return
const trimmed = system.trim()
@@ -174,12 +146,12 @@ export function SessionContextTab() {
const breakdown = createMemo(
on(
() => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
() => {
const c = ctx()
if (!c?.input) return []
return estimateSessionContextBreakdown({
messages: messages(),
messages: props.messages(),
parts: sync.data.part as Record<string, Part[] | undefined>,
input: c.input,
systemPrompt: systemPrompt(),
@@ -197,7 +169,7 @@ export function SessionContextTab() {
}
const stats = [
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
{ label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" },
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
{ label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel },
@@ -214,7 +186,7 @@ export function SessionContextTab() {
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
{ label: "context.stats.totalCost", value: cost },
{ label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) },
{ label: "context.stats.sessionCreated", value: () => formatter().time(props.info()?.time.created) },
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
] satisfies { label: string; value: () => JSX.Element }[]
@@ -227,7 +199,7 @@ export function SessionContextTab() {
const el = scroll
if (!el) return
const s = view().scroll("context")
const s = props.view()?.scroll("context")
if (!s) return
if (el.scrollTop !== s.y) el.scrollTop = s.y
@@ -248,13 +220,13 @@ export function SessionContextTab() {
pending = undefined
if (!next) return
view().setScroll("context", next)
props.view().setScroll("context", next)
})
}
createEffect(
on(
() => messages().length,
() => props.messages().length,
() => {
requestAnimationFrame(restoreScroll)
},
@@ -328,7 +300,7 @@ export function SessionContextTab() {
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Accordion multiple>
<For each={messages()}>
<For each={props.messages()}>
{(message) => (
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
)}

View File

@@ -257,12 +257,27 @@ export function SessionHeader() {
] as const
})
const checksReady = createMemo(() => {
if (platform.platform !== "desktop") return true
if (!platform.checkAppExists) return true
const list = apps()
return list.every((app) => exists[app.id] !== undefined)
})
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false })
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
createEffect(() => {
if (platform.platform !== "desktop") return
if (!checksReady()) return
const value = prefs.app
if (options().some((o) => o.id === value)) return
setPrefs("app", options()[0]?.id ?? "finder")
})
const openDir = (app: OpenApp) => {
const directory = projectDirectory()
if (!directory) return
@@ -304,11 +319,9 @@ export function SessionHeader() {
<Show when={centerMount()}>
{(mount) => (
<Portal mount={mount()}>
<Button
<button
type="button"
variant="ghost"
size="small"
class="hidden md:flex w-[240px] max-w-full min-w-0 pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
class="hidden md:flex w-[240px] max-w-full min-w-0 h-[24px] pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel transition-colors cursor-default hover:bg-surface-raised-base-hover focus-visible:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")}
>
@@ -324,7 +337,7 @@ export function SessionHeader() {
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind>
)}
</Show>
</Button>
</button>
</Portal>
)}
</Show>
@@ -341,7 +354,7 @@ export function SessionHeader() {
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
class="rounded-none h-full py-0 pr-2 pl-0.5 gap-1.5 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
@@ -385,7 +398,7 @@ export function SessionHeader() {
<DropdownMenu.Group>
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup
value={current().id}
value={prefs.app}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
setPrefs("app", value as OpenApp)
@@ -451,11 +464,11 @@ export function SessionHeader() {
triggerProps={{
variant: "ghost",
class:
"rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
"rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
classList: { "rounded-r-none": share.shareUrl() !== undefined },
style: { scale: 1 },
}}
trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}
trigger={language.t("session.share.action.share")}
>
<div class="flex flex-col gap-2">
<Show
@@ -524,7 +537,7 @@ export function SessionHeader() {
<IconButton
icon={share.state.copied ? "check" : "link"}
variant="ghost"
class="rounded-l-none h-[24px] border border-border-weak-base bg-surface-panel shadow-none"
class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none"
onClick={() => share.copyLink((error) => showRequestError(language, error))}
disabled={share.state.unshare}
aria-label={

View File

@@ -431,7 +431,7 @@ export const SettingsGeneral: Component = () => {
<SoundsSection />
{/*<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
{(_) => {
const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
@@ -457,7 +457,7 @@ export const SettingsGeneral: Component = () => {
</div>
)
}}
</Show>*/}
</Show>
<UpdatesSection />

View File

@@ -1,21 +1,21 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useNavigate } from "@solidjs/router"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Popover } from "@opencode-ai/ui/popover"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Button } from "@opencode-ai/ui/button"
import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import { useSync } from "@/context/sync"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { DialogSelectServer } from "./dialog-select-server"
import { ServerRow } from "@/components/server/server-row"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
const pollMs = 10_000
@@ -32,9 +32,9 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
}
const listServersByHealth = (
list: ServerConnection.Any[],
active: ServerConnection.Key | undefined,
status: Record<ServerConnection.Key, ServerHealth | undefined>,
list: string[],
active: string | undefined,
status: Record<string, ServerHealth | undefined>,
) => {
if (!list.length) return list
const order = new Map(list.map((url, index) => [url, index] as const))
@@ -45,16 +45,16 @@ const listServersByHealth = (
}
return list.slice().sort((a, b) => {
if (ServerConnection.key(a) === active) return -1
if (ServerConnection.key(b) === active) return 1
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
if (a === active) return -1
if (b === active) return 1
const diff = rank(status[a]) - rank(status[b])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
}
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => {
const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>)
createEffect(() => {
const list = servers()
@@ -63,8 +63,8 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
const refresh = async () => {
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
list.map(async (url) => {
results[url] = await checkServerHealth(url, fetcher)
}),
)
if (dead) return
@@ -82,7 +82,7 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
return status
}
const useDefaultServerKey = (
const useDefaultServerUrl = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => {
const [url, setUrl] = createSignal<string | undefined>()
@@ -117,14 +117,7 @@ const useDefaultServerKey = (
})
})
return {
key: () => {
const u = url()
if (!u) return
return ServerConnection.key({ type: "http", http: { url: u } })
},
refresh: () => setTick((value) => value + 1),
}
return { url, refresh: () => setTick((value) => value + 1) }
}
const useMcpToggle = (input: {
@@ -170,16 +163,16 @@ export function StatusPopover() {
const fetcher = platform.fetch ?? globalThis.fetch
const servers = createMemo(() => {
const current = server.current
const current = server.url
const list = server.list
if (!current) return list
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((item) => item !== current)]
})
const health = useServerHealth(servers, fetcher)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health))
const mcp = useMcpToggle({ sync, sdk, language })
const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
@@ -203,7 +196,7 @@ export function StatusPopover() {
triggerProps={{
variant: "ghost",
class:
"rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
"rounded-md h-[24px] pr-2 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-hover",
style: { scale: 1 },
}}
trigger={
@@ -258,9 +251,8 @@ export function StatusPopover() {
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}>
{(s) => {
const key = ServerConnection.key(s)
const isBlocked = () => health[key]?.healthy === false
{(url) => {
const isBlocked = () => health[url]?.healthy === false
return (
<button
type="button"
@@ -272,19 +264,19 @@ export function StatusPopover() {
aria-disabled={isBlocked()}
onClick={() => {
if (isBlocked()) return
server.setActive(key)
server.setActive(url)
navigate("/")
}}
>
<ServerRow
conn={s}
status={health[key]}
url={url}
status={health[url]}
dimmed={isBlocked()}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={key === defaultServer.key()}>
<Show when={url === defaultServer.url()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")}
</span>
@@ -292,7 +284,7 @@ export function StatusPopover() {
}
>
<div class="flex-1" />
<Show when={server.current && key === ServerConnection.key(server.current)}>
<Show when={url === server.url}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</ServerRow>

View File

@@ -1,15 +1,14 @@
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
import { showToast } from "@opencode-ai/ui/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { SerializeAddon } from "@/addons/serialize"
import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language"
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { monoFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { parseKeybind, matchKeybind } from "@/context/command"
import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal"
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
import { useLanguage } from "@/context/language"
import { showToast } from "@opencode-ai/ui/toast"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
@@ -107,14 +106,8 @@ const useTerminalUiBindings = (input: {
input.container.addEventListener("pointerdown", input.handlePointerDown)
input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown))
input.container.addEventListener("click", input.handleLinkClick, {
capture: true,
})
input.cleanups.push(() =>
input.container.removeEventListener("click", input.handleLinkClick, {
capture: true,
}),
)
input.container.addEventListener("click", input.handleLinkClick, { capture: true })
input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true }))
input.term.textarea?.addEventListener("focus", handleTextareaFocus)
input.term.textarea?.addEventListener("blur", handleTextareaBlur)
@@ -155,7 +148,6 @@ export const Terminal = (props: TerminalProps) => {
const settings = useSettings()
const theme = useTheme()
const language = useLanguage()
const server = useServer()
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
let ws: WebSocket | undefined
@@ -354,7 +346,7 @@ export const Terminal = (props: TerminalProps) => {
}
ghostty = g
term = t
output = terminalWriter((data, done) => t.write(data, done))
output = terminalWriter((data) => t.write(data))
t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
@@ -380,13 +372,7 @@ export const Terminal = (props: TerminalProps) => {
serializeAddon = serializer
t.open(container)
useTerminalUiBindings({
container,
term: t,
cleanups,
handlePointerDown,
handleLinkClick,
})
useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick })
focusTerminal()
@@ -442,8 +428,10 @@ export const Terminal = (props: TerminalProps) => {
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.username = server.current?.http.username ?? ""
url.password = server.current?.http.password ?? ""
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
ws = socket
@@ -532,19 +520,9 @@ export const Terminal = (props: TerminalProps) => {
disposed = true
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close()
const finalize = () => {
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
cleanup()
}
if (!output) {
finalize()
return
}
output.flush(finalize)
output?.flush()
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
cleanup()
})
return (

View File

@@ -1,9 +1,8 @@
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup } from "solid-js"
import z from "zod"
import { createSdkForServer } from "@/utils/server"
import { usePlatform } from "./platform"
import { useServer } from "./server"
@@ -18,10 +17,20 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const platform = usePlatform()
const abort = new AbortController()
const password = typeof window === "undefined" ? undefined : window.__OPENCODE__?.serverPassword
const auth = (() => {
if (!password) return
if (!server.isLocal()) return
return {
Authorization: `Basic ${btoa(`opencode:${password}`)}`,
}
})()
const eventFetch = (() => {
if (!platform.fetch || !server.current) return
if (!platform.fetch) return
try {
const url = new URL(server.current.http.url)
const url = new URL(server.url)
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
if (url.protocol === "http:" && !loopback) return platform.fetch
} catch {
@@ -29,13 +38,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
}
})()
const currentServer = server.current
if (!currentServer) throw new Error("No server available")
const eventSdk = createSdkForServer({
const eventSdk = createOpencodeClient({
baseUrl: server.url,
signal: abort.signal,
fetch: eventFetch,
server: currentServer.http,
headers: eventFetch ? undefined : auth,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
@@ -126,7 +133,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: currentServer.http.url,
url: server.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
@@ -159,7 +166,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: currentServer.http.url,
url: server.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
@@ -193,25 +200,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
flush()
})
const sdk = createSdkForServer({
server: server.current.http,
const sdk = createOpencodeClient({
baseUrl: server.url,
fetch: platform.fetch,
throwOnError: true,
})
return {
url: currentServer.http.url,
client: sdk,
event: emitter,
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current
if (!s) throw new Error("Server not available")
return createSdkForServer({
server: s.http,
fetch: platform.fetch,
...opts,
})
},
}
return { url: server.url, client: sdk, event: emitter }
},
})

View File

@@ -30,6 +30,7 @@ describe("pickDirectoriesToEvict", () => {
describe("loadRootSessionsWithFallback", () => {
test("uses limited roots query when supported", async () => {
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
let fallback = 0
const result = await loadRootSessionsWithFallback({
directory: "dir",
@@ -38,15 +39,20 @@ describe("loadRootSessionsWithFallback", () => {
calls.push(query)
return { data: [] }
},
onFallback: () => {
fallback += 1
},
})
expect(result.data).toEqual([])
expect(result.limited).toBe(true)
expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
expect(fallback).toBe(0)
})
test("falls back to full roots query on limited-query failure", async () => {
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
let fallback = 0
const result = await loadRootSessionsWithFallback({
directory: "dir",
@@ -56,6 +62,9 @@ describe("loadRootSessionsWithFallback", () => {
if (query.limit) throw new Error("unsupported")
return { data: [] }
},
onFallback: () => {
fallback += 1
},
})
expect(result.data).toEqual([])
@@ -64,6 +73,7 @@ describe("loadRootSessionsWithFallback", () => {
{ directory: "dir", roots: true, limit: 25 },
{ directory: "dir", roots: true },
])
expect(fallback).toBe(1)
})
})

View File

@@ -1,41 +1,41 @@
import type {
Config,
OpencodeClient,
Path,
Project,
ProviderAuthResponse,
ProviderListResponse,
Todo,
import {
type Config,
type Path,
type Project,
type ProviderAuthResponse,
type ProviderListResponse,
type Todo,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { createStore, produce, reconcile } from "solid-js/store"
import { useGlobalSDK } from "./global-sdk"
import type { InitError } from "../pages/error"
import {
createContext,
createEffect,
untrack,
getOwner,
Match,
useContext,
onCleanup,
onMount,
type ParentProps,
Switch,
untrack,
useContext,
Match,
} from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { usePlatform } from "./platform"
import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
import type { InitError } from "../pages/error"
import { useGlobalSDK } from "./global-sdk"
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { createRefreshQueue } from "./global-sync/queue"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { createChildStoreManager } from "./global-sync/child-store"
import { trimSessions } from "./global-sync/session-trim"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { sanitizeProject } from "./global-sync/utils"
import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { sanitizeProject } from "./global-sync/utils"
import { usePlatform } from "./platform"
type GlobalStore = {
ready: boolean
@@ -57,6 +57,14 @@ function errorMessage(error: unknown) {
return "Unknown error"
}
function setDevStats(value: {
activeDirectoryStores: number
evictions: number
loadSessionsFullFetchFallback: number
}) {
;(globalThis as { __OPENCODE_GLOBAL_SYNC_STATS?: typeof value }).__OPENCODE_GLOBAL_SYNC_STATS = value
}
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const platform = usePlatform()
@@ -64,7 +72,12 @@ function createGlobalSync() {
const owner = getOwner()
if (!owner) throw new Error("GlobalSync must be created within owner")
const sdkCache = new Map<string, OpencodeClient>()
const stats = {
evictions: 0,
loadSessionsFallback: 0,
}
const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>()
@@ -99,6 +112,15 @@ function createGlobalSync() {
setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
}
const updateStats = (activeDirectoryStores: number) => {
if (!import.meta.env.DEV) return
setDevStats({
activeDirectoryStores,
evictions: stats.evictions,
loadSessionsFullFetchFallback: stats.loadSessionsFallback,
})
}
const paused = () => untrack(() => globalStore.reload) !== undefined
const queue = createRefreshQueue({
@@ -109,6 +131,11 @@ function createGlobalSync() {
const children = createChildStoreManager({
owner,
markStats: updateStats,
incrementEvictions: () => {
stats.evictions += 1
updateStats(Object.keys(children.children).length)
},
isBooting: (directory) => booting.has(directory),
isLoadingSessions: (directory) => sessionLoads.has(directory),
onBootstrap: (directory) => {
@@ -124,7 +151,9 @@ function createGlobalSync() {
const sdkFor = (directory: string) => {
const cached = sdkCache.get(directory)
if (cached) return cached
const sdk = globalSDK.createClient({
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory,
throwOnError: true,
})
@@ -164,10 +193,7 @@ function createGlobalSync() {
const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(directory)
if (meta && meta.limit >= store.limit) {
const next = trimSessions(store.session, {
limit: store.limit,
permission: store.permission,
})
const next = trimSessions(store.session, { limit: store.limit, permission: store.permission })
if (next.length !== store.session.length) {
setStore("session", reconcile(next, { key: "id" }))
}
@@ -180,6 +206,10 @@ function createGlobalSync() {
directory,
limit,
list: (query) => globalSDK.client.session.list(query),
onFallback: () => {
stats.loadSessionsFallback += 1
updateStats(Object.keys(children.children).length)
},
})
.then((x) => {
const nonArchived = (x.data ?? [])
@@ -188,17 +218,10 @@ function createGlobalSync() {
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const limit = store.limit
const childSessions = store.session.filter((s) => !!s.parentID)
const sessions = trimSessions([...nonArchived, ...childSessions], {
limit,
permission: store.permission,
})
const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission })
setStore(
"sessionTotal",
estimateRootSessionTotal({
count: nonArchived.length,
limit: x.limit,
limited: x.limited,
}),
estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }),
)
setStore("session", reconcile(sessions, { key: "id" }))
sessionMeta.set(directory, { limit })
@@ -308,9 +331,7 @@ function createGlobalSync() {
await bootstrapGlobal({
globalSDK: globalSDK.client,
connectErrorTitle: language.t("dialog.server.add.error"),
connectErrorDescription: language.t("error.globalSync.connectFailed", {
url: globalSDK.url,
}),
connectErrorDescription: language.t("error.globalSync.connectFailed", { url: globalSDK.url }),
requestFailedTitle: language.t("common.requestFailed"),
setGlobalStore,
})

View File

@@ -1,21 +1,21 @@
import type {
Config,
OpencodeClient,
Path,
PermissionRequest,
Project,
ProviderAuthResponse,
ProviderListResponse,
QuestionRequest,
Todo,
import {
type Config,
type Path,
type PermissionRequest,
type Project,
type ProviderAuthResponse,
type ProviderListResponse,
type QuestionRequest,
type Todo,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { retry } from "@opencode-ai/util/retry"
import { batch } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State, VcsCache } from "./types"
import { retry } from "@opencode-ai/util/retry"
import { getFilename } from "@opencode-ai/util/path"
import { showToast } from "@opencode-ai/ui/toast"
import { cmp, normalizeProviderList } from "./utils"
import type { State, VcsCache } from "./types"
type GlobalStore = {
ready: boolean
@@ -31,7 +31,7 @@ type GlobalStore = {
}
export async function bootstrapGlobal(input: {
globalSDK: OpencodeClient
globalSDK: ReturnType<typeof createOpencodeClient>
connectErrorTitle: string
connectErrorDescription: string
requestFailedTitle: string
@@ -110,7 +110,7 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
export async function bootstrapDirectory(input: {
directory: string
sdk: OpencodeClient
sdk: ReturnType<typeof createOpencodeClient>
store: Store<State>
setStore: SetStoreFunction<State>
vcsCache: VcsCache

View File

@@ -17,6 +17,8 @@ describe("createChildStoreManager", () => {
const manager = createChildStoreManager({
owner,
markStats() {},
incrementEvictions() {},
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},

View File

@@ -17,6 +17,8 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
export function createChildStoreManager(input: {
owner: Owner
markStats: (activeDirectoryStores: number) => void
incrementEvictions: () => void
isBooting: (directory: string) => boolean
isLoadingSessions: (directory: string) => boolean
onBootstrap: (directory: string) => void
@@ -100,6 +102,7 @@ export function createChildStoreManager(input: {
}
delete children[directory]
input.onDispose(directory)
input.markStats(Object.keys(children).length)
return true
}
@@ -117,6 +120,7 @@ export function createChildStoreManager(input: {
if (list.length === 0) return
for (const directory of list) {
if (!disposeDirectory(directory)) continue
input.incrementEvictions()
}
}
@@ -196,6 +200,7 @@ export function createChildStoreManager(input: {
})
runWithOwner(input.owner, init)
input.markStats(Object.keys(children).length)
}
mark(directory)
const childStore = children[directory]

View File

@@ -9,6 +9,7 @@ export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
limited: true,
} as const
} catch {
input.onFallback()
const result = await input.list({ directory: input.directory, roots: true })
return {
data: result.data,

View File

@@ -119,6 +119,7 @@ export type RootLoadArgs = {
directory: string
limit: number
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
onFallback: () => void
}
export type RootLoadResult = {

View File

@@ -174,10 +174,6 @@ function detectLocale(): Locale {
return "en"
}
function normalizeLocale(value: string): Locale {
return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
}
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language",
init: () => {
@@ -188,7 +184,15 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
}),
)
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
const locale = createMemo<Locale>(() =>
LOCALES.includes(store.locale as Locale) ? (store.locale as Locale) : "en",
)
createEffect(() => {
const current = locale()
if (store.locale === current) return
setStore("locale", current)
})
const dict = createMemo<Dictionary>(() => DICT[locale()])
@@ -209,7 +213,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
label,
t,
setLocale(next: Locale) {
setStore("locale", normalizeLocale(next))
setStore("locale", next)
},
}
},

View File

@@ -1,8 +1,9 @@
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform"
type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
@@ -11,11 +12,14 @@ type SDKEventMap = {
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { directory: Accessor<string> }) => {
const platform = usePlatform()
const globalSDK = useGlobalSDK()
const directory = createMemo(props.directory)
const client = createMemo(() =>
globalSDK.createClient({
createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory: directory(),
throwOnError: true,
}),
@@ -41,9 +45,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
get url() {
return globalSDK.url
},
createClient(opts: Parameters<typeof globalSDK.createClient>[0]) {
return globalSDK.createClient(opts)
},
}
},
})

View File

@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist"
@@ -15,118 +15,92 @@ export function normalizeServerUrl(input: string) {
return withProtocol.replace(/\/+$/, "")
}
export function serverDisplayName(conn?: ServerConnection.Any) {
if (!conn) return ""
if (conn.displayName) return conn.displayName
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
export function serverDisplayName(url: string) {
if (!url) return ""
return url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
}
function projectsKey(key: ServerConnection.Key) {
if (!key) return ""
if (key === "sidecar") return "local"
const host = key.replace(/^https?:\/\//, "").split(":")[0]
function projectsKey(url: string) {
if (!url) return ""
const host = url.replace(/^https?:\/\//, "").split(":")[0]
if (host === "localhost" || host === "127.0.0.1") return "local"
return key
}
export namespace ServerConnection {
type Base = { displayName?: string }
export type HttpBase = {
url: string
username?: string
password?: string
}
// Regular web connections
export type Http = {
type: "http"
http: HttpBase
} & Base
export type Sidecar = {
type: "sidecar"
http: HttpBase
} & (
| // Regular desktop server
{ variant: "base" }
// WSL server (windows only)
| {
variant: "wsl"
distro: string
}
) &
Base
// Remote server desktop can SSH into
export type Ssh = {
type: "ssh"
host: string
// SSH client exposes an HTTP server for the app to use as a proxy
http: HttpBase
} & Base
export type Any =
| Http
// All these are desktop-only
| (Sidecar | Ssh)
export const key = (conn: Any): Key => {
switch (conn.type) {
case "http":
return Key.make(conn.http.url)
case "sidecar": {
if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
return Key.make("sidecar")
}
case "ssh":
return Key.make(`ssh:${conn.host}`)
}
}
export type Key = string & { _brand: "Key" }
export const Key = { make: (v: string) => v as Key }
return url
}
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
init: (props: { defaultUrl: string; isSidecar?: boolean }) => {
const platform = usePlatform()
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
createStore({
list: [] as string[],
currentSidecarUrl: "",
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
}),
)
const allServers = createMemo(
(): Array<ServerConnection.Any> => [
...(props.servers ?? []),
...store.list.map((value) => ({
type: "http" as const,
http: typeof value === "string" ? { url: value } : value,
})),
],
)
const [state, setState] = createStore({
active: props.defaultServer,
active: "",
healthy: undefined as boolean | undefined,
})
const healthy = () => state.healthy
function startHealthPolling(conn: ServerConnection.Any) {
const defaultUrl = () => normalizeServerUrl(props.defaultUrl)
function reconcileStartup() {
const fallback = defaultUrl()
if (!fallback) return
const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl)
const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list
if (!props.isSidecar) {
batch(() => {
setStore("list", list)
if (store.currentSidecarUrl) setStore("currentSidecarUrl", "")
setState("active", fallback)
})
return
}
const nextList = list.includes(fallback) ? list : [...list, fallback]
batch(() => {
setStore("list", nextList)
setStore("currentSidecarUrl", fallback)
setState("active", fallback)
})
}
function updateServerList(url: string, remove = false) {
if (remove) {
const list = store.list.filter((x) => x !== url)
const next = state.active === url ? (list[0] ?? defaultUrl() ?? "") : state.active
batch(() => {
setStore("list", list)
setState("active", next)
})
return
}
batch(() => {
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
}
setState("active", url)
})
}
function startHealthPolling(url: string) {
let alive = true
let busy = false
const run = () => {
if (busy) return
busy = true
void check(conn)
void check(url)
.then((next) => {
if (!alive) return
setState("healthy", next)
@@ -144,73 +118,59 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
}
}
function setActive(input: ServerConnection.Key) {
if (state.active !== input) setState("active", input)
function setActive(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
setState("active", url)
}
function add(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
return batch(() => {
const http: ServerConnection.HttpBase = { url }
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
}
const conn: ServerConnection.Http = { type: "http", http }
setState("active", ServerConnection.key(conn))
return conn
})
updateServerList(url)
}
function remove(key: ServerConnection.Key) {
const list = store.list.filter((x) => x !== key)
batch(() => {
setStore("list", list)
if (state.active === key) {
const next = list[0]
setState("active", next ? ServerConnection.key({ type: "http", http: { url: next } }) : props.defaultServer)
}
})
function remove(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
updateServerList(url, true)
}
createEffect(() => {
if (!ready()) return
if (state.active) return
reconcileStartup()
})
const isReady = createMemo(() => ready() && !!state.active)
const fetcher = platform.fetch ?? globalThis.fetch
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
createEffect(() => {
const current_ = current()
if (!current_) return
const url = state.active
if (!url) return
setState("healthy", undefined)
onCleanup(startHealthPolling(current_))
onCleanup(startHealthPolling(url))
})
const origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? [])
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
)
const isLocal = createMemo(() => {
const c = current()
return c?.type === "sidecar" && c.variant === "base"
})
const isLocal = createMemo(() => origin() === "local")
return {
ready: isReady,
healthy,
isLocal,
get key() {
get url() {
return state.active
},
get name() {
return serverDisplayName(current())
return serverDisplayName(state.active)
},
get list() {
return allServers()
},
get current() {
return current()
return store.list
},
setActive,
add,

View File

@@ -1,14 +1,11 @@
// @refresh reload
import { iife } from "@opencode-ai/util/iife"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
import { type Platform, PlatformProvider } from "@/context/platform"
import { Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click"
import pkg from "../package.json"
import { ServerConnection } from "./context/server"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
@@ -110,22 +107,12 @@ const platform: Platform = {
setDefaultServerUrl: writeDefaultServerUrl,
}
const defaultUrl = iife(() => {
const lsDefault = readDefaultServerUrl()
if (lsDefault) return lsDefault
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
})
if (root instanceof HTMLElement) {
const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
render(
() => (
<PlatformProvider value={platform}>
<AppBaseProviders>
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
<AppInterface />
</AppBaseProviders>
</PlatformProvider>
),

View File

@@ -63,8 +63,6 @@ export const dict = {
"command.agent.cycle.reverse.description": "التبديل إلى الوكيل السابق",
"command.model.variant.cycle": "تغيير جهد التفكير",
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
"command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
"command.workspace.toggle": "تبديل مساحات العمل",
@@ -212,7 +210,6 @@ export const dict = {
"prompt.placeholder.summarizeComments": "لخّص التعليقات…",
"prompt.placeholder.summarizeComment": "لخّص التعليق…",
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc للخروج",
"prompt.example.1": "إصلاح TODO في قاعدة التعليمات البرمجية",
"prompt.example.2": "ما هو المكدس التقني لهذا المشروع؟",

View File

@@ -63,8 +63,6 @@ export const dict = {
"command.agent.cycle.reverse.description": "Mudar para o agente anterior",
"command.model.variant.cycle": "Alternar nível de raciocínio",
"command.model.variant.cycle.description": "Mudar para o próximo nível de esforço",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Aceitar edições automaticamente",
"command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente",
"command.workspace.toggle": "Alternar espaços de trabalho",
@@ -212,7 +210,6 @@ export const dict = {
"prompt.placeholder.summarizeComments": "Resumir comentários…",
"prompt.placeholder.summarizeComment": "Resumir comentário…",
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc para sair",
"prompt.example.1": "Corrigir um TODO no código",
"prompt.example.2": "Qual é a stack tecnológica deste projeto?",

View File

@@ -69,8 +69,6 @@ export const dict = {
"command.agent.cycle.reverse.description": "Prebaci na prethodnog agenta",
"command.model.variant.cycle": "Promijeni nivo razmišljanja",
"command.model.variant.cycle.description": "Prebaci na sljedeći nivo",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Automatski prihvataj izmjene",
"command.permissions.autoaccept.disable": "Zaustavi automatsko prihvatanje izmjena",
"command.workspace.toggle": "Prikaži/sakrij radne prostore",
@@ -230,7 +228,6 @@ export const dict = {
"prompt.placeholder.summarizeComments": "Sažmi komentare…",
"prompt.placeholder.summarizeComment": "Sažmi komentar…",
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc za izlaz",
"prompt.example.1": "Popravi TODO u bazi koda",

View File

@@ -69,8 +69,6 @@ export const dict = {
"command.agent.cycle.reverse.description": "Skift til forrige agent",
"command.model.variant.cycle": "Skift tænkeindsats",
"command.model.variant.cycle.description": "Skift til næste indsatsniveau",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Accepter ændringer automatisk",
"command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer",
"command.workspace.toggle": "Skift arbejdsområder",
@@ -228,7 +226,6 @@ export const dict = {
"prompt.placeholder.summarizeComments": "Opsummér kommentarer…",
"prompt.placeholder.summarizeComment": "Opsummér kommentar…",
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc for at afslutte",
"prompt.example.1": "Ret en TODO i koden",

View File

@@ -67,8 +67,6 @@ export const dict = {
"command.agent.cycle.reverse.description": "Zum vorherigen Agenten wechseln",
"command.model.variant.cycle": "Denkaufwand wechseln",
"command.model.variant.cycle.description": "Zum nächsten Aufwandslevel wechseln",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Änderungen automatisch akzeptieren",
"command.permissions.autoaccept.disable": "Automatische Annahme von Änderungen stoppen",
"command.workspace.toggle": "Arbeitsbereiche umschalten",
@@ -217,7 +215,6 @@ export const dict = {
"prompt.placeholder.summarizeComments": "Kommentare zusammenfassen…",
"prompt.placeholder.summarizeComment": "Kommentar zusammenfassen…",
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc zum Verlassen",
"prompt.example.1": "Ein TODO in der Codebasis beheben",
"prompt.example.2": "Was ist der Tech-Stack dieses Projekts?",

View File

@@ -69,8 +69,6 @@ export const dict = {
"command.agent.cycle.reverse.description": "Switch to the previous agent",
"command.model.variant.cycle": "Cycle thinking effort",
"command.model.variant.cycle.description": "Switch to the next effort level",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Auto-accept edits",
"command.permissions.autoaccept.disable": "Stop auto-accepting edits",
"command.workspace.toggle": "Toggle workspaces",
@@ -230,7 +228,6 @@ export const dict = {
"prompt.placeholder.summarizeComments": "Summarize comments…",
"prompt.placeholder.summarizeComment": "Summarize comment…",
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc to exit",
"prompt.example.1": "Fix a TODO in the codebase",

View File

@@ -69,8 +69,6 @@ export const dict = {
"command.agent.cycle.reverse.description": "Cambiar al agente anterior",
"command.model.variant.cycle": "Alternar esfuerzo de pensamiento",
"command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Aceptar ediciones automáticamente",
"command.permissions.autoaccept.disable": "Dejar de aceptar ediciones automáticamente",
"command.workspace.toggle": "Alternar espacios de trabajo",
@@ -229,7 +227,6 @@ export const dict = {
"prompt.placeholder.summarizeComments": "Resumir comentarios…",
"prompt.placeholder.summarizeComment": "Resumir comentario…",
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc para salir",
"prompt.example.1": "Arreglar un TODO en el código",

View File

@@ -63,8 +63,6 @@ export const dict = {
"command.agent.cycle.reverse.description": "Passer à l'agent précédent",
"command.model.variant.cycle": "Changer l'effort de réflexion",
"command.model.variant.cycle.description": "Passer au niveau d'effort suivant",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Accepter automatiquement les modifications",
"command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications",
"command.workspace.toggle": "Basculer les espaces de travail",
@@ -212,7 +210,6 @@ export const dict = {
"prompt.placeholder.summarizeComments": "Résumer les commentaires…",
"prompt.placeholder.summarizeComment": "Résumer le commentaire…",
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc pour quitter",
"prompt.example.1": "Corriger un TODO dans la base de code",
"prompt.example.2": "Quelle est la pile technique de ce projet ?",

View File

@@ -63,8 +63,6 @@ export const dict = {
"command.agent.cycle.reverse.description": "前のエージェントに切り替え",
"command.model.variant.cycle": "思考レベルの切り替え",
"command.model.variant.cycle.description": "次の思考レベルに切り替え",
"command.prompt.mode.shell": "シェル",
"command.prompt.mode.normal": "プロンプト",
"command.permissions.autoaccept.enable": "編集を自動承認",
"command.permissions.autoaccept.disable": "編集の自動承認を停止",
"command.workspace.toggle": "ワークスペースを切り替え",
@@ -211,7 +209,6 @@ export const dict = {
"prompt.placeholder.summarizeComments": "コメントを要約…",
"prompt.placeholder.summarizeComment": "コメントを要約…",
"prompt.mode.shell": "シェル",
"prompt.mode.normal": "プロンプト",
"prompt.mode.shell.exit": "escで終了",
"prompt.example.1": "コードベースのTODOを修正",
"prompt.example.2": "このプロジェクトの技術スタックは何ですか?",

View File

@@ -67,8 +67,6 @@ export const dict = {
"command.agent.cycle.reverse.description": "이전 에이전트로 전환",
"command.model.variant.cycle": "생각 수준 순환",
"command.model.variant.cycle.description": "다음 생각 수준으로 전환",
"command.prompt.mode.shell": "셸",
"command.prompt.mode.normal": "프롬프트",
"command.permissions.autoaccept.enable": "편집 자동 수락",
"command.permissions.autoaccept.disable": "편집 자동 수락 중지",
"command.workspace.toggle": "작업 공간 전환",
@@ -215,7 +213,6 @@ export const dict = {
"prompt.placeholder.summarizeComments": "댓글 요약…",
"prompt.placeholder.summarizeComment": "댓글 요약…",
"prompt.mode.shell": "셸",
"prompt.mode.normal": "프롬프트",
"prompt.mode.shell.exit": "종료하려면 esc",
"prompt.example.1": "코드베이스의 TODO 수정",
"prompt.example.2": "이 프로젝트의 기술 스택이 무엇인가요?",

View File

@@ -72,8 +72,6 @@ export const dict = {
"command.agent.cycle.reverse.description": "Bytt til forrige agent",
"command.model.variant.cycle": "Bytt tenkeinnsats",
"command.model.variant.cycle.description": "Bytt til neste innsatsnivå",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Godta endringer automatisk",
"command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk",
"command.workspace.toggle": "Veksle arbeidsområder",
@@ -232,7 +230,6 @@ export const dict = {
"prompt.placeholder.summarizeComments": "Oppsummer kommentarer…",
"prompt.placeholder.summarizeComment": "Oppsummer kommentar…",
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "ESC for å avslutte",
"prompt.example.1": "Fiks en TODO i kodebasen",

View File

@@ -63,8 +63,6 @@ export const dict = {
"command.agent.cycle.reverse.description": "Przełącz na poprzedniego agenta",
"command.model.variant.cycle": "Przełącz wysiłek myślowy",
"command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku",
"command.prompt.mode.shell": "Terminal",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Automatyczne akceptowanie edycji",
"command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie edycji",
"command.workspace.toggle": "Przełącz przestrzenie robocze",
@@ -213,7 +211,6 @@ export const dict = {
"prompt.placeholder.summarizeComments": "Podsumuj komentarze…",
"prompt.placeholder.summarizeComment": "Podsumuj komentarz…",
"prompt.mode.shell": "Terminal",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc aby wyjść",
"prompt.example.1": "Napraw TODO w bazie kodu",
"prompt.example.2": "Jaki jest stos technologiczny tego projektu?",

View File

@@ -69,8 +69,6 @@ export const dict = {
"command.agent.cycle.reverse.description": "Переключиться к предыдущему агенту",
"command.model.variant.cycle": "Цикл режимов мышления",
"command.model.variant.cycle.description": "Переключиться к следующему уровню усилий",
"command.prompt.mode.shell": "Оболочка",
"command.prompt.mode.normal": "Промпт",
"command.permissions.autoaccept.enable": "Авто-принятие изменений",
"command.permissions.autoaccept.disable": "Прекратить авто-принятие изменений",
"command.workspace.toggle": "Переключить рабочие пространства",
@@ -229,7 +227,6 @@ export const dict = {
"prompt.placeholder.summarizeComments": "Суммировать комментарии…",
"prompt.placeholder.summarizeComment": "Суммировать комментарий…",
"prompt.mode.shell": "Оболочка",
"prompt.mode.normal": "Промпт",
"prompt.mode.shell.exit": "esc для выхода",
"prompt.example.1": "Исправить TODO в коде",

View File

@@ -69,8 +69,6 @@ export const dict = {
"command.agent.cycle.reverse.description": "สลับไปยังเอเจนต์ก่อนหน้า",
"command.model.variant.cycle": "เปลี่ยนความพยายามในการคิด",
"command.model.variant.cycle.description": "สลับไปยังระดับความพยายามถัดไป",
"command.prompt.mode.shell": "เชลล์",
"command.prompt.mode.normal": "พรอมต์",
"command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ",
"command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ",
"command.workspace.toggle": "สลับพื้นที่ทำงาน",
@@ -229,7 +227,6 @@ export const dict = {
"prompt.placeholder.summarizeComments": "สรุปความคิดเห็น…",
"prompt.placeholder.summarizeComment": "สรุปความคิดเห็น…",
"prompt.mode.shell": "เชลล์",
"prompt.mode.normal": "พรอมต์",
"prompt.mode.shell.exit": "กด esc เพื่อออก",
"prompt.example.1": "แก้ไข TODO ในโค้ดเบส",

View File

@@ -93,9 +93,6 @@ export const dict = {
"command.model.variant.cycle": "切换思考强度",
"command.model.variant.cycle.description": "切换到下一个强度等级",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "自动接受编辑",
"command.permissions.autoaccept.disable": "停止自动接受编辑",
@@ -251,7 +248,6 @@ export const dict = {
"prompt.placeholder.summarizeComments": "总结评论…",
"prompt.placeholder.summarizeComment": "总结该评论…",
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "按 esc 退出",
"prompt.example.1": "修复代码库中的一个 TODO",
"prompt.example.2": "这个项目的技术栈是什么?",

View File

@@ -73,8 +73,6 @@ export const dict = {
"command.agent.cycle.reverse.description": "切換到上一個代理程式",
"command.model.variant.cycle": "循環思考強度",
"command.model.variant.cycle.description": "切換到下一個強度等級",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "自動接受編輯",
"command.permissions.autoaccept.disable": "停止自動接受編輯",
"command.workspace.toggle": "切換工作區",
@@ -229,7 +227,6 @@ export const dict = {
"prompt.placeholder.summarizeComments": "摘要評論…",
"prompt.placeholder.summarizeComment": "摘要這則評論…",
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "按 esc 退出",
"prompt.example.1": "修復程式碼庫中的一個 TODO",

View File

@@ -1,5 +1,4 @@
export { PlatformProvider, type Platform, type DisplayBackend } from "./context/platform"
export { AppBaseProviders, AppInterface } from "./app"
export { useCommand } from "./context/command"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"

View File

@@ -51,6 +51,7 @@ import { DialogSelectServer } from "@/components/dialog-select-server"
import { DialogSettings } from "@/components/dialog-settings"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { navStart } from "@/utils/perf"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { Titlebar } from "@/components/titlebar"
@@ -177,12 +178,7 @@ export default function Layout(props: ParentProps) {
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
const setHoverProject = (value: string | undefined) => {
setState("hoverProject", value)
if (value !== undefined) return
aim.reset()
}
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
const clearHoverProjectSoon = () => queueMicrotask(() => setState("hoverProject", undefined))
const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
const hoverProjectData = createMemo(() => {
@@ -193,7 +189,13 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
if (!layout.sidebar.opened()) return
setHoverProject(undefined)
aim.reset()
setState("hoverProject", undefined)
})
createEffect(() => {
if (state.hoverProject !== undefined) return
aim.reset()
})
const autoselecting = createMemo(() => {
@@ -224,7 +226,7 @@ export default function Layout(props: ParentProps) {
const clearSidebarHoverState = () => {
if (layout.sidebar.opened()) return
setState("hoverSession", undefined)
setHoverProject(undefined)
setState("hoverProject", undefined)
}
const navigateWithSidebarReset = (href: string) => {
@@ -824,6 +826,14 @@ export default function Layout(props: ParentProps) {
if (next) prefetchSession(next)
}
if (import.meta.env.DEV) {
navStart({
dir: base64Encode(session.directory),
from: params.id,
to: session.id,
trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
})
}
navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
}
@@ -859,6 +869,15 @@ export default function Layout(props: ParentProps) {
if (next) prefetchSession(next)
}
if (import.meta.env.DEV) {
navStart({
dir: base64Encode(session.directory),
from: params.id,
to: session.id,
trigger: offset > 0 ? "shift+alt+arrowdown" : "shift+alt+arrowup",
})
}
navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
return
@@ -1489,7 +1508,7 @@ export default function Layout(props: ParentProps) {
function handleDragStart(event: unknown) {
const id = getDraggableId(event)
if (!id) return
setHoverProject(undefined)
setState("hoverProject", undefined)
setStore("activeProject", id)
}
@@ -1923,7 +1942,7 @@ export default function Layout(props: ParentProps) {
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
navLeave.current = window.setTimeout(() => {
navLeave.current = undefined
setHoverProject(undefined)
setState("hoverProject", undefined)
setState("hoverSession", undefined)
}, 300)
}}

View File

@@ -1,30 +1,57 @@
import { onCleanup, Show, Match, Switch, createMemo, createEffect, on, onMount } from "solid-js"
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore } from "solid-js/store"
import { createStore, produce } from "solid-js/store"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Dialog } from "@opencode-ai/ui/dialog"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Select } from "@opencode-ai/ui/select"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
import { checksum, base64Encode } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import FileTree from "@/components/file-tree"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { useComments } from "@/context/comments"
import { SessionHeader, NewSessionView } from "@/components/session"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { usePermission } from "@/context/permission"
import { showToast } from "@opencode-ai/ui/toast"
import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session"
import { navMark, navParams } from "@/utils/perf"
import { same } from "@/utils/same"
import { createOpenReviewFile } from "@/pages/session/helpers"
import { createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "@/pages/session/helpers"
import { createScrollSpy } from "@/pages/session/scroll-spy"
import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import {
SessionReviewTab,
StickyAddButton,
type DiffStyle,
type SessionReviewTabProps,
} from "@/pages/session/review-tab"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { terminalTabLabel } from "@/pages/session/terminal-label"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { SessionPromptDock } from "@/pages/session/session-prompt-dock"
@@ -32,34 +59,98 @@ import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
type HandoffSession = {
prompt: string
files: Record<string, SelectedLineRange | null>
}
const HANDOFF_MAX = 40
const handoff = {
session: new Map<string, HandoffSession>(),
terminal: new Map<string, string[]>(),
}
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
map.delete(key)
map.set(key, value)
while (map.size > HANDOFF_MAX) {
const first = map.keys().next().value
if (first === undefined) return
map.delete(first)
}
}
const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
const prev = handoff.session.get(key) ?? { prompt: "", files: {} }
touch(handoff.session, key, { ...prev, ...patch })
}
export default function Page() {
const layout = useLayout()
const local = useLocal()
const file = useFile()
const sync = useSync()
const globalSync = useGlobalSync()
const terminal = useTerminal()
const dialog = useDialog()
const codeComponent = useCodeComponent()
const command = useCommand()
const language = useLanguage()
const params = useParams()
const navigate = useNavigate()
const sdk = useSDK()
const prompt = usePrompt()
const comments = useComments()
const permission = usePermission()
const permRequest = createMemo(() => {
const sessionID = params.id
if (!sessionID) return
return sync.data.permission[sessionID]?.[0]
})
const questionRequest = createMemo(() => {
const sessionID = params.id
if (!sessionID) return
return sync.data.question[sessionID]?.[0]
})
const blocked = createMemo(() => !!permRequest() || !!questionRequest())
const [ui, setUi] = createStore({
responding: false,
pendingMessage: undefined as string | undefined,
scrollGesture: 0,
autoCreated: false,
scroll: {
overflow: false,
bottom: true,
},
})
const blocked = createMemo(() => {
const sessionID = params.id
if (!sessionID) return false
return !!sync.data.permission[sessionID]?.[0] || !!sync.data.question[sessionID]?.[0]
})
createEffect(
on(
() => permRequest()?.id,
() => setUi("responding", false),
{ defer: true },
),
)
const decide = (response: "once" | "always" | "reject") => {
const perm = permRequest()
if (!perm) return
if (ui.responding) return
setUi("responding", true)
sdk.client.permission
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => setUi("responding", false))
}
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const workspaceKey = createMemo(() => params.dir ?? "")
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
@@ -102,6 +193,46 @@ export default function Page() {
),
)
if (import.meta.env.DEV) {
createEffect(
on(
() => [params.dir, params.id] as const,
([dir, id], prev) => {
if (!id) return
navParams({ dir, from: prev?.[1], to: id })
},
),
)
createEffect(() => {
const id = params.id
if (!id) return
if (!prompt.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:prompt-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (!terminal.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:terminal-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (!file.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:file-view-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (sync.data.message[id] === undefined) return
navMark({ dir: params.dir, to: id, name: "session:data-ready" })
})
}
const isDesktop = createMediaQuery("(min-width: 768px)")
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
@@ -134,6 +265,16 @@ export default function Page() {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
}
const openTab = (value: string) => {
const next = normalizeTab(value)
tabs().open(next)
const path = file.pathFromTab(next)
if (!path) return
file.load(path)
openReviewPanel()
}
createEffect(() => {
const active = tabs().active()
if (!active) return
@@ -182,6 +323,206 @@ export default function Page() {
return sync.session.history.loading(id)
})
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
})
let titleRef: HTMLInputElement | undefined
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
createEffect(
on(
sessionKey,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!params.id) return
setTitle({ editing: true, draft: info()?.title ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const sessionID = params.id
if (!sessionID) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (info()?.title ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
if (parentID) {
navigate(`/${params.dir}/session/${parentID}`)
return
}
if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`)
return
}
navigate(`/${params.dir}/session`)
}
async function archiveSession(sessionID: string) {
const session = sync.session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk.client.session
.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
async function deleteSession(sessionID: string) {
const session = sync.session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk.client.session
.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return false
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
return true
}
function DialogDeleteSession(props: { sessionID: string }) {
const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: title() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
@@ -214,6 +555,8 @@ export default function Page() {
)
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
messageId: undefined as string | undefined,
turnStart: 0,
mobileTab: "session" as "session" | "changes",
@@ -272,6 +615,33 @@ export default function Page() {
scrollToMessage(msgs[targetIndex], "auto")
}
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b
if (a === b) return a
return "mix" as const
}
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind)
const parts = file.split("/")
for (const [idx] of parts.slice(0, -1).entries()) {
const dir = parts.slice(0, idx + 1).join("/")
if (!dir) continue
out.set(dir, merge(out.get(dir), kind))
}
}
return out
})
const emptyDiffFiles: string[] = []
const diffFiles = createMemo(() => diffs().map((d) => d.file), emptyDiffFiles, { equals: same })
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
@@ -279,6 +649,7 @@ export default function Page() {
return sync.data.session_diff[id] !== undefined
})
const idle = { type: "idle" as const }
let inputRef!: HTMLDivElement
let promptDock: HTMLDivElement | undefined
let dockHeight = 0
@@ -308,6 +679,43 @@ export default function Page() {
void sync.session.todo(id)
})
createEffect(() => {
if (!view().terminal.opened()) {
setUi("autoCreated", false)
return
}
if (!terminal.ready() || terminal.all().length !== 0 || ui.autoCreated) return
terminal.new()
setUi("autoCreated", true)
})
createEffect(
on(
() => terminal.all().length,
(count, prevCount) => {
if (prevCount !== undefined && prevCount > 0 && count === 0) {
if (view().terminal.opened()) {
view().terminal.toggle()
}
}
},
),
)
createEffect(
on(
() => terminal.active(),
(activeId) => {
if (!activeId || !view().terminal.opened()) return
// Immediately remove focus
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
focusTerminalById(activeId)
},
),
)
createEffect(
on(
() => visibleUserMessages().at(-1)?.id,
@@ -320,12 +728,20 @@ export default function Page() {
),
)
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const todos = createMemo(() => {
const id = params.id
if (!id) return []
return globalSync.data.session_todo[id] ?? []
})
createEffect(
on(
sessionKey,
() => {
setStore("messageId", undefined)
setStore("changes", "session")
setUi("autoCreated", false)
},
{ defer: true },
),
@@ -352,6 +768,11 @@ export default function Page() {
return lines.slice(0, 2).join("\n")
}
const addSelectionToContext = (path: string, selection: FileSelection) => {
const preview = selectionPreview(path, selection)
prompt.context.add({ type: "file", path, selection, preview })
}
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
@@ -406,6 +827,53 @@ export default function Page() {
}
}
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setStore("activeDraggable", id)
}
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
const currentTabs = tabs().all()
const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString())
if (toIndex === undefined) return
tabs().move(draggable.id.toString(), toIndex)
}
}
const handleDragEnd = () => {
setStore("activeDraggable", undefined)
}
const handleTerminalDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setStore("activeTerminalDraggable", id)
}
const handleTerminalDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
const terminals = terminal.all()
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
terminal.move(draggable.id.toString(), toIndex)
}
}
}
const handleTerminalDragEnd = () => {
setStore("activeTerminalDraggable", undefined)
const activeId = terminal.active()
if (!activeId) return
setTimeout(() => {
focusTerminalById(activeId)
}, 0)
}
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
const openedTabs = createMemo(() =>
tabs()
@@ -443,8 +911,29 @@ export default function Page() {
const focusInput = () => inputRef?.focus()
useSessionCommands({
command,
dialog,
file,
language,
local,
permission,
prompt,
sdk,
sync,
terminal,
layout,
params,
navigate,
tabs,
view,
info,
status,
userMessages,
visibleUserMessages,
showAllFiles,
navigateMessageByOffset,
setActiveMessage,
addSelectionToContext,
focusInput,
})
@@ -582,6 +1071,11 @@ export default function Page() {
),
)
const setFileTreeTabValue = (value: string) => {
if (value !== "changes" && value !== "all") return
setFileTreeTab(value)
}
const reviewDiffId = (path: string) => {
const sum = checksum(path)
if (!sum) return
@@ -677,6 +1171,12 @@ export default function Page() {
return "empty"
})
const activeFileTab = createMemo(() => {
const active = activeTab()
if (!openedTabs().includes(active)) return
return active
})
createEffect(() => {
if (!layout.ready()) return
if (tabs().active()) return
@@ -981,10 +1481,62 @@ export default function Page() {
consumePendingMessage: layout.pendingMessage.consume,
})
onMount(() => {
createEffect(() => {
document.addEventListener("keydown", handleKeyDown)
})
const previewPrompt = () =>
prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim()
createEffect(() => {
if (!prompt.ready()) return
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
createEffect(() => {
if (!terminal.ready()) return
language.locale()
touch(
handoff.terminal,
params.dir!,
terminal.all().map((pty) =>
terminalTabLabel({
title: pty.title,
titleNumber: pty.titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
}),
),
)
})
createEffect(() => {
if (!file.ready()) return
setSessionHandoff(sessionKey(), {
files: tabs()
.all()
.reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
const path = file.pathFromTab(tab)
if (!path) return acc
const selected = file.selectedLines(path)
acc[path] =
selected && typeof selected === "object" && "start" in selected && "end" in selected
? (selected as SelectedLineRange)
: null
return acc
}, {}),
})
})
onCleanup(() => {
cancelTurnBackfill()
document.removeEventListener("keydown", handleKeyDown)
@@ -1003,6 +1555,7 @@ export default function Page() {
reviewCount={reviewCount()}
onSession={() => setStore("mobileTab", "session")}
onChanges={() => setStore("mobileTab", "changes")}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
/>
{/* Session panel */}
@@ -1042,7 +1595,27 @@ export default function Page() {
isDesktop={isDesktop()}
onScrollSpyScroll={scrollSpy.onScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
showHeader={!!(info()?.title || info()?.parentID)}
centered={centered()}
title={info()?.title}
parentID={info()?.parentID}
openTitleEditor={openTitleEditor}
closeTitleEditor={closeTitleEditor}
saveTitleEditor={saveTitleEditor}
titleRef={(el) => {
titleRef = el
}}
titleState={title}
onTitleDraft={(value) => setTitle("draft", value)}
onTitleMenuOpen={(open) => setTitle("menuOpen", open)}
onTitlePendingRename={(value) => setTitle("pendingRename", value)}
onNavigateParent={() => {
navigate(`/${params.dir}/session/${info()?.parentID}`)
}}
sessionID={params.id!}
onArchiveSession={(sessionID) => void archiveSession(sessionID)}
onDeleteSession={(sessionID) => dialog.show(() => <DialogDeleteSession sessionID={sessionID} />)}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
@@ -1064,6 +1637,11 @@ export default function Page() {
anchor={anchor}
onRegisterMessage={scrollSpy.register}
onUnregisterMessage={scrollSpy.unregister}
onFirstTurnMount={() => {
const id = params.id
if (!id) return
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
}}
lastUserMessageID={lastUserMessage()?.id}
/>
</Show>
@@ -1092,6 +1670,15 @@ export default function Page() {
<SessionPromptDock
centered={centered()}
questionRequest={questionRequest}
permissionRequest={permRequest}
blocked={blocked()}
todos={todos()}
promptReady={prompt.ready()}
handoffPrompt={handoff.session.get(sessionKey())?.prompt}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
responding={ui.responding}
onDecide={decide}
inputRef={(el) => {
inputRef = el
}}
@@ -1101,9 +1688,7 @@ export default function Page() {
comments.clear()
resumeScroll()
}}
setPromptDockRef={(el) => {
promptDock = el
}}
setPromptDockRef={(el) => (promptDock = el)}
/>
<Show when={desktopReviewOpen()}>
@@ -1117,10 +1702,64 @@ export default function Page() {
</Show>
</div>
<SessionSidePanel reviewPanel={reviewPanel} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} />
<SessionSidePanel
open={desktopSidePanelOpen()}
reviewOpen={desktopReviewOpen()}
language={language}
layout={layout}
command={command}
dialog={dialog}
file={file}
comments={comments}
hasReview={hasReview()}
reviewCount={reviewCount()}
reviewTab={reviewTab()}
contextOpen={contextOpen}
openedTabs={openedTabs}
activeTab={activeTab}
activeFileTab={activeFileTab}
tabs={tabs}
openTab={openTab}
showAllFiles={showAllFiles}
reviewPanel={reviewPanel}
vm={{
messages,
visibleUserMessages,
view,
info,
}}
handoffFiles={() => handoff.session.get(sessionKey())?.files}
codeComponent={codeComponent}
addCommentToContext={addCommentToContext}
activeDraggable={() => store.activeDraggable}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
fileTreeTab={fileTreeTab}
setFileTreeTabValue={setFileTreeTabValue}
diffsReady={diffsReady()}
diffFiles={diffFiles()}
kinds={kinds()}
activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff}
/>
</div>
<TerminalPanel />
<TerminalPanel
open={isDesktop() && view().terminal.opened()}
height={layout.terminal.height()}
resize={layout.terminal.resize}
close={view().terminal.close}
terminal={terminal}
language={language}
command={command}
handoff={() => handoff.terminal.get(params.dir!) ?? []}
activeTerminalDraggable={() => store.activeTerminalDraggable}
handleTerminalDragStart={handleTerminalDragStart}
handleTerminalDragOver={handleTerminalDragOver}
handleTerminalDragEnd={handleTerminalDragEnd}
onCloseTab={() => setUi("autoCreated", false)}
/>
</div>
)
}

View File

@@ -1,8 +1,6 @@
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { useParams } from "@solidjs/router"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { sampledChecksum } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
@@ -10,11 +8,9 @@ import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/
import { Mark } from "@opencode-ai/ui/logo"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useLayout } from "@/context/layout"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useComments } from "@/context/comments"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { getSessionHandoff } from "@/pages/session/handoff"
const formatCommentLabel = (range: SelectedLineRange) => {
const start = Math.min(range.start, range.end)
@@ -23,29 +19,34 @@ const formatCommentLabel = (range: SelectedLineRange) => {
return `lines ${start}-${end}`
}
export function FileTabContent(props: { tab: string }) {
const params = useParams()
const layout = useLayout()
const file = useFile()
const comments = useComments()
const language = useLanguage()
const prompt = usePrompt()
const codeComponent = useCodeComponent()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
export function FileTabContent(props: {
tab: string
activeTab: () => string
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
file: ReturnType<typeof useFile>
comments: ReturnType<typeof useComments>
language: ReturnType<typeof useLanguage>
codeComponent: NonNullable<ValidComponent>
addCommentToContext: (input: {
file: string
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => void
}) {
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
const path = createMemo(() => file.pathFromTab(props.tab))
const path = createMemo(() => props.file.pathFromTab(props.tab))
const state = createMemo(() => {
const p = path()
if (!p) return
return file.get(p)
return props.file.get(p)
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents()))
@@ -81,7 +82,7 @@ export function FileTabContent(props: { tab: string }) {
svgToast.shown = true
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
title: props.language.t("toast.file.loadFailed.title"),
})
})
const svgPreviewUrl = createMemo(() => {
@@ -99,57 +100,16 @@ export function FileTabContent(props: { tab: string }) {
const selectedLines = createMemo(() => {
const p = path()
if (!p) return null
if (file.ready()) return file.selectedLines(p) ?? null
return getSessionHandoff(sessionKey())?.files[p] ?? null
if (props.file.ready()) return props.file.selectedLines(p) ?? null
return props.handoffFiles()?.[p] ?? null
})
const selectionPreview = (source: string, selection: FileSelection) => {
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
const end = Math.max(selection.startLine, selection.endLine)
const lines = source.split("\n").slice(start - 1, end)
if (lines.length === 0) return undefined
return lines.slice(0, 2).join("\n")
}
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => {
const selection = selectionFromLines(input.selection)
const preview =
input.preview ??
(() => {
if (input.file === path()) return selectionPreview(contents(), selection)
const source = file.get(input.file)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
})()
const saved = comments.add({
file: input.file,
selection: input.selection,
comment: input.comment,
})
prompt.context.add({
type: "file",
path: input.file,
selection,
comment: input.comment,
commentID: saved.id,
commentOrigin: input.origin,
preview,
})
}
let wrap: HTMLDivElement | undefined
const fileComments = createMemo(() => {
const p = path()
if (!p) return []
return comments.list(p)
return props.comments.list(p)
})
const commentLayout = createMemo(() => {
@@ -168,13 +128,6 @@ export function FileTabContent(props: { tab: string }) {
draftTop: undefined as number | undefined,
})
const setCommenting = (range: SelectedLineRange | null) => {
setNote("commenting", range)
scheduleComments()
if (!range) return
setNote("draft", "")
}
const getRoot = () => {
const el = wrap
if (!el) return
@@ -268,19 +221,26 @@ export function FileTabContent(props: { tab: string }) {
})
createEffect(() => {
const focus = comments.focus()
const range = note.commenting
scheduleComments()
if (!range) return
setNote("draft", "")
})
createEffect(() => {
const focus = props.comments.focus()
const p = path()
if (!focus || !p) return
if (focus.file !== p) return
if (tabs().active() !== props.tab) return
if (props.activeTab() !== props.tab) return
const target = fileComments().find((comment) => comment.id === focus.id)
if (!target) return
setNote("openedComment", target.id)
setCommenting(null)
file.setSelectedLines(p, target.selection)
requestAnimationFrame(() => comments.clearFocus())
setNote("commenting", null)
props.file.setSelectedLines(p, target.selection)
requestAnimationFrame(() => props.comments.clearFocus())
})
const getCodeScroll = () => {
@@ -309,7 +269,7 @@ export function FileTabContent(props: { tab: string }) {
pending = undefined
if (!out) return
view().setScroll(props.tab, out)
props.view().setScroll(props.tab, out)
})
}
@@ -345,7 +305,7 @@ export function FileTabContent(props: { tab: string }) {
const el = scroll
if (!el) return
const s = view().scroll(props.tab)
const s = props.view()?.scroll(props.tab)
if (!s) return
syncCodeScroll()
@@ -383,7 +343,7 @@ export function FileTabContent(props: { tab: string }) {
createEffect(
on(
() => file.ready(),
() => props.file.ready(),
(ready) => {
if (!ready) return
requestAnimationFrame(restoreScroll)
@@ -394,7 +354,7 @@ export function FileTabContent(props: { tab: string }) {
createEffect(
on(
() => tabs().active() === props.tab,
() => props.tabs().active() === props.tab,
(active) => {
if (!active) return
if (!state()?.loaded) return
@@ -421,7 +381,7 @@ export function FileTabContent(props: { tab: string }) {
class={`relative overflow-hidden ${wrapperClass}`}
>
<Dynamic
component={codeComponent}
component={props.codeComponent}
file={{
name: path() ?? "",
contents: source,
@@ -437,17 +397,17 @@ export function FileTabContent(props: { tab: string }) {
onLineSelected={(range: SelectedLineRange | null) => {
const p = path()
if (!p) return
file.setSelectedLines(p, range)
if (!range) setCommenting(null)
props.file.setSelectedLines(p, range)
if (!range) setNote("commenting", null)
}}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) {
setCommenting(null)
setNote("commenting", null)
return
}
setNote("openedComment", null)
setCommenting(range)
setNote("commenting", range)
}}
overflow="scroll"
class="select-text"
@@ -463,14 +423,14 @@ export function FileTabContent(props: { tab: string }) {
onMouseEnter={() => {
const p = path()
if (!p) return
file.setSelectedLines(p, comment.selection)
props.file.setSelectedLines(p, comment.selection)
}}
onClick={() => {
const p = path()
if (!p) return
setCommenting(null)
setNote("commenting", null)
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
file.setSelectedLines(p, comment.selection)
props.file.setSelectedLines(p, comment.selection)
}}
/>
)}
@@ -483,12 +443,17 @@ export function FileTabContent(props: { tab: string }) {
value={note.draft}
selection={formatCommentLabel(range())}
onInput={(value) => setNote("draft", value)}
onCancel={() => setCommenting(null)}
onCancel={() => setNote("commenting", null)}
onSubmit={(value) => {
const p = path()
if (!p) return
addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" })
setCommenting(null)
props.addCommentToContext({
file: p,
selection: range(),
comment: value,
origin: "file",
})
setNote("commenting", null)
}}
onPopoverFocusOut={(e: FocusEvent) => {
const current = e.currentTarget as HTMLDivElement
@@ -497,7 +462,7 @@ export function FileTabContent(props: { tab: string }) {
setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) {
setCommenting(null)
setNote("commenting", null)
}
}, 0)
}}
@@ -544,13 +509,13 @@ export function FileTabContent(props: { tab: string }) {
<Mark class="w-14 opacity-10" />
<div class="flex flex-col gap-2 max-w-md">
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
<div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
<div class="text-14-regular text-text-weak">{props.language.t("session.files.binaryContent")}</div>
</div>
</div>
</Match>
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
<div class="px-6 py-4 text-text-weak">{props.language.t("common.loading")}...</div>
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>

View File

@@ -1,36 +0,0 @@
import type { SelectedLineRange } from "@/context/file"
type HandoffSession = {
prompt: string
files: Record<string, SelectedLineRange | null>
}
const MAX = 40
const store = {
session: new Map<string, HandoffSession>(),
terminal: new Map<string, string[]>(),
}
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
map.delete(key)
map.set(key, value)
while (map.size > MAX) {
const first = map.keys().next().value
if (first === undefined) return
map.delete(first)
}
}
export const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
const prev = store.session.get(key) ?? { prompt: "", files: {} }
touch(store.session, key, { ...prev, ...patch })
}
export const getSessionHandoff = (key: string) => store.session.get(key)
export const setTerminalHandoff = (key: string, value: string[]) => {
touch(store.terminal, key, value)
}
export const getTerminalHandoff = (key: string) => store.terminal.get(key)

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { createOpenReviewFile, createOpenSessionFileTab, focusTerminalById, getTabReorderIndex } from "./helpers"
import { createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "./helpers"
describe("createOpenReviewFile", () => {
test("opens and loads selected review file", () => {
@@ -20,37 +20,6 @@ describe("createOpenReviewFile", () => {
})
})
describe("createOpenSessionFileTab", () => {
test("activates the opened file tab", () => {
const calls: string[] = []
const openTab = createOpenSessionFileTab({
normalizeTab: (value) => {
calls.push(`normalize:${value}`)
return `file://${value}`
},
openTab: (tab) => calls.push(`open:${tab}`),
pathFromTab: (tab) => {
calls.push(`path:${tab}`)
return tab.slice("file://".length)
},
loadFile: (path) => calls.push(`load:${path}`),
openReviewPanel: () => calls.push("review"),
setActive: (tab) => calls.push(`active:${tab}`),
})
openTab("src/a.ts")
expect(calls).toEqual([
"normalize:src/a.ts",
"open:file://src/a.ts",
"path:file://src/a.ts",
"load:src/a.ts",
"review",
"active:file://src/a.ts",
])
})
})
describe("focusTerminalById", () => {
test("focuses textarea when present", () => {
document.body.innerHTML = `<div id="terminal-wrapper-one"><div data-component="terminal"><textarea></textarea></div></div>`

View File

@@ -35,27 +35,6 @@ export const createOpenReviewFile = (input: {
}
}
export const createOpenSessionFileTab = (input: {
normalizeTab: (tab: string) => string
openTab: (tab: string) => void
pathFromTab: (tab: string) => string | undefined
loadFile: (path: string) => void
openReviewPanel: () => void
setActive: (tab: string) => void
}) => {
return (value: string) => {
const next = input.normalizeTab(value)
input.openTab(next)
const path = input.pathFromTab(next)
if (!path) return
input.loadFile(path)
input.openReviewPanel()
input.setActive(next)
}
}
export const getTabReorderIndex = (tabs: readonly string[], from: string, to: string) => {
const fromIndex = tabs.indexOf(from)
const toIndex = tabs.indexOf(to)

View File

@@ -1,21 +1,13 @@
import { For, createEffect, createMemo, on, onCleanup, Show, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
const current = target instanceof Element ? target : undefined
@@ -61,7 +53,29 @@ export function MessageTimeline(props: {
isDesktop: boolean
onScrollSpyScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
showHeader: boolean
centered: boolean
title?: string
parentID?: string
openTitleEditor: () => void
closeTitleEditor: () => void
saveTitleEditor: () => void | Promise<void>
titleRef: (el: HTMLInputElement) => void
titleState: {
draft: string
editing: boolean
saving: boolean
menuOpen: boolean
pendingRename: boolean
}
onTitleDraft: (value: string) => void
onTitleMenuOpen: (open: boolean) => void
onTitlePendingRename: (value: boolean) => void
onNavigateParent: () => void
sessionID: string
onArchiveSession: (sessionID: string) => void
onDeleteSession: (sessionID: string) => void
t: (key: string, vars?: Record<string, string | number | boolean>) => string
setContentRef: (el: HTMLDivElement) => void
turnStart: number
onRenderEarlier: () => void
@@ -72,234 +86,11 @@ export function MessageTimeline(props: {
anchor: (id: string) => string
onRegisterMessage: (el: HTMLDivElement, id: string) => void
onUnregisterMessage: (id: string) => void
onFirstTurnMount?: () => void
lastUserMessageID?: string
}) {
let touchGesture: number | undefined
const params = useParams()
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const dialog = useDialog()
const language = useLanguage()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const sessionID = createMemo(() => params.id)
const info = createMemo(() => {
const id = sessionID()
if (!id) return
return sync.session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const parentID = createMemo(() => info()?.parentID)
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
})
let titleRef: HTMLInputElement | undefined
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
createEffect(
on(
sessionKey,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!sessionID()) return
setTitle({ editing: true, draft: titleValue() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const id = sessionID()
if (!id) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (titleValue() ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID: id, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === id)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
if (parentID) {
navigate(`/${params.dir}/session/${parentID}`)
return
}
if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`)
return
}
navigate(`/${params.dir}/session`)
}
const archiveSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk.client.session
.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const deleteSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk.client.session
.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return false
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
return true
}
const navigateParent = () => {
const id = parentID()
if (!id) return
navigate(`/${params.dir}/session/${id}`)
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
return (
<Show
when={!props.mobileChanges}
@@ -366,110 +157,106 @@ export function MessageTimeline(props: {
}}
onClick={props.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
style={{ "--session-title-height": showHeader() ? "40px" : "0px" }}
style={{ "--session-title-height": props.showHeader ? "40px" : "0px" }}
>
<Show when={showHeader()}>
<Show when={props.showHeader}>
<div
classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"w-full": true,
"pb-4": true,
"pl-2 pr-3 md:pl-4 md:pr-3": true,
"pl-2 pr-4 md:pl-4 md:pr-6": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<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">
<Show when={parentID()}>
<Show when={props.parentID}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={navigateParent}
aria-label={language.t("common.goBack")}
onClick={props.onNavigateParent}
aria-label={props.t("common.goBack")}
/>
</Show>
<Show when={titleValue() || title.editing}>
<Show when={props.title || props.titleState.editing}>
<Show
when={title.editing}
when={props.titleState.editing}
fallback={
<h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
onDblClick={openTitleEditor}
onDblClick={props.openTitleEditor}
>
{titleValue()}
{props.title}
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
value={title.draft}
disabled={title.saving}
ref={props.titleRef}
value={props.titleState.draft}
disabled={props.titleState.saving}
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onInput={(event) => props.onTitleDraft(event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
void props.saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
props.closeTitleEditor()
}
}}
onBlur={closeTitleEditor}
onBlur={props.closeTitleEditor}
/>
</Show>
</Show>
</div>
<Show when={sessionID()}>
<Show when={props.sessionID}>
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<DropdownMenu
gutter={4}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
open={props.titleState.menuOpen}
onOpenChange={props.onTitleMenuOpen}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
aria-label={props.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
if (!props.titleState.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
props.onTitlePendingRename(false)
props.openTitleEditor()
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
props.onTitlePendingRename(true)
props.onTitleMenuOpen(false)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemLabel>{props.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
<DropdownMenu.Item onSelect={() => props.onArchiveSession(id())}>
<DropdownMenu.ItemLabel>{props.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 onSelect={() => props.onDeleteSession(id())}>
<DropdownMenu.ItemLabel>{props.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
@@ -495,7 +282,7 @@ export function MessageTimeline(props: {
<Show when={props.turnStart > 0}>
<div class="w-full flex justify-center">
<Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}>
{language.t("session.messages.renderEarlier")}
{props.t("session.messages.renderEarlier")}
</Button>
</div>
</Show>
@@ -509,37 +296,43 @@ export function MessageTimeline(props: {
onClick={props.onLoadEarlier}
>
{props.historyLoading
? language.t("session.messages.loadingEarlier")
: language.t("session.messages.loadEarlier")}
? props.t("session.messages.loadingEarlier")
: props.t("session.messages.loadEarlier")}
</Button>
</div>
</Show>
<For each={props.renderedUserMessages}>
{(message) => (
<div
id={props.anchor(message.id)}
data-message-id={message.id}
ref={(el) => {
props.onRegisterMessage(el, message.id)
onCleanup(() => props.onUnregisterMessage(message.id))
}}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
}}
>
<SessionTurn
sessionID={sessionID() ?? ""}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
container: "w-full px-4 md:px-6",
{(message) => {
if (import.meta.env.DEV && props.onFirstTurnMount) {
onMount(() => props.onFirstTurnMount?.())
}
return (
<div
id={props.anchor(message.id)}
data-message-id={message.id}
ref={(el) => {
props.onRegisterMessage(el, message.id)
onCleanup(() => props.onUnregisterMessage(message.id))
}}
/>
</div>
)}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
}}
>
<SessionTurn
sessionID={props.sessionID}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
container: "w-full px-4 md:px-6",
}}
/>
</div>
)
}}
</For>
</div>
</div>

View File

@@ -144,8 +144,8 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
onOpenChange={props.view().review.setOpen}
classes={{
root: props.classes?.root ?? "pb-6",
header: props.classes?.header ?? "px-3",
container: props.classes?.container ?? "px-3",
header: props.classes?.header ?? "px-6",
container: props.classes?.container ?? "px-6",
}}
diffs={props.diffs()}
diffStyle={props.diffStyle}

View File

@@ -1,6 +1,5 @@
import { Show } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useLanguage } from "@/context/language"
export function SessionMobileTabs(props: {
open: boolean
@@ -9,9 +8,8 @@ export function SessionMobileTabs(props: {
reviewCount: number
onSession: () => void
onChanges: () => void
t: (key: string, vars?: Record<string, string | number | boolean>) => string
}) {
const language = useLanguage()
return (
<Show when={props.open}>
<Tabs value={props.mobileTab} class="h-auto">
@@ -22,7 +20,7 @@ export function SessionMobileTabs(props: {
classes={{ button: "w-full" }}
onClick={props.onSession}
>
{language.t("session.tab.session")}
{props.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="changes"
@@ -31,8 +29,8 @@ export function SessionMobileTabs(props: {
onClick={props.onChanges}
>
{props.hasReview
? language.t("session.review.filesChanged", { count: props.reviewCount })
: language.t("session.review.change.other")}
? props.t("session.review.filesChanged", { count: props.reviewCount })
: props.t("session.review.change.other")}
</Tabs.Trigger>
</Tabs.List>
</Tabs>

View File

@@ -1,104 +1,34 @@
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import { BasicTool } from "@opencode-ai/ui/basic-tool"
import { PromptInput } from "@/components/prompt-input"
import { QuestionDock } from "@/components/question-dock"
import { SessionTodoDock } from "@/components/session-todo-dock"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
export function SessionPromptDock(props: {
centered: boolean
questionRequest: () => QuestionRequest | undefined
permissionRequest: () => { patterns: string[]; permission: string } | undefined
blocked: boolean
todos: Todo[]
promptReady: boolean
handoffPrompt?: string
t: (key: string, vars?: Record<string, string | number | boolean>) => string
responding: boolean
onDecide: (response: "once" | "always" | "reject") => void
inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string
onNewSessionWorktreeReset: () => void
onSubmit: () => void
setPromptDockRef: (el: HTMLDivElement) => void
}) {
const params = useParams()
const sdk = useSDK()
const sync = useSync()
const globalSync = useGlobalSync()
const prompt = usePrompt()
const language = useLanguage()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
const todos = createMemo((): Todo[] => {
const id = params.id
if (!id) return []
return globalSync.data.session_todo[id] ?? []
})
const questionRequest = createMemo((): QuestionRequest | undefined => {
const sessionID = params.id
if (!sessionID) return
return sync.data.question[sessionID]?.[0]
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
const sessionID = params.id
if (!sessionID) return
return sync.data.permission[sessionID]?.[0]
})
const blocked = createMemo(() => !!permissionRequest() || !!questionRequest())
const previewPrompt = () =>
prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim()
createEffect(() => {
if (!prompt.ready()) return
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
const [responding, setResponding] = createSignal<string | undefined>()
const permissionResponding = () => {
const perm = permissionRequest()
if (!perm) return false
return responding() === perm.id
}
const decide = (response: "once" | "always" | "reject") => {
const perm = permissionRequest()
if (!perm) return
if (responding() === perm.id) return
setResponding(perm.id)
sdk.client.permission
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => {
setResponding((id) => (id === perm.id ? undefined : id))
})
}
const done = createMemo(
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
() =>
props.todos.length > 0 && props.todos.every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
const [dock, setDock] = createSignal(todos().length > 0)
const [dock, setDock] = createSignal(props.todos.length > 0)
const [closing, setClosing] = createSignal(false)
const [opening, setOpening] = createSignal(false)
let timer: number | undefined
@@ -115,7 +45,7 @@ export function SessionPromptDock(props: {
createEffect(
on(
() => [todos().length, done()] as const,
() => [props.todos.length, done()] as const,
([count, complete], prev) => {
if (raf) cancelAnimationFrame(raf)
raf = undefined
@@ -182,7 +112,7 @@ export function SessionPromptDock(props: {
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<Show when={questionRequest()} keyed>
<Show when={props.questionRequest()} keyed>
{(req) => {
return (
<div>
@@ -192,88 +122,72 @@ export function SessionPromptDock(props: {
}}
</Show>
<Show when={permissionRequest()} keyed>
{(perm) => {
const toolDescription = () => {
const key = `settings.permissions.tool.${perm.permission}.description`
const value = language.t(key as Parameters<typeof language.t>[0])
if (value === key) return ""
return value
}
return (
<div>
<DockPrompt
kind="permission"
header={
<div data-slot="permission-row" data-variant="header">
<span data-slot="permission-icon">
<Icon name="warning" size="normal" />
</span>
<div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
</div>
}
footer={
<>
<div />
<div data-slot="permission-footer-actions">
<Button
variant="ghost"
size="normal"
onClick={() => decide("reject")}
disabled={permissionResponding()}
>
{language.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="normal"
onClick={() => decide("always")}
disabled={permissionResponding()}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button
variant="primary"
size="normal"
onClick={() => decide("once")}
disabled={permissionResponding()}
>
{language.t("ui.permission.allowOnce")}
</Button>
</div>
</>
}
>
<Show when={toolDescription()}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-hint">{toolDescription()}</div>
</div>
</Show>
<Show when={perm.patterns.length > 0}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-patterns">
<For each={perm.patterns}>
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
</For>
</div>
</div>
</Show>
</DockPrompt>
<Show when={props.permissionRequest()} keyed>
{(perm) => (
<div data-component="tool-part-wrapper" data-permission="true" class="mb-3">
<BasicTool
icon="checklist"
locked
defaultOpen
trigger={{
title: props.t("notification.permission.title"),
subtitle:
perm.permission === "doom_loop"
? props.t("settings.permissions.tool.doom_loop.title")
: perm.permission,
}}
>
<Show when={perm.patterns.length > 0}>
<div class="flex flex-col gap-1 py-2 px-3 max-h-40 overflow-y-auto no-scrollbar">
<For each={perm.patterns}>
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
</For>
</div>
</Show>
<Show when={perm.permission === "doom_loop"}>
<div class="text-12-regular text-text-weak pb-2 px-3">
{props.t("settings.permissions.tool.doom_loop.description")}
</div>
</Show>
</BasicTool>
<div data-component="permission-prompt">
<div data-slot="permission-actions">
<Button
variant="ghost"
size="small"
onClick={() => props.onDecide("reject")}
disabled={props.responding}
>
{props.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="small"
onClick={() => props.onDecide("always")}
disabled={props.responding}
>
{props.t("ui.permission.allowAlways")}
</Button>
<Button
variant="primary"
size="small"
onClick={() => props.onDecide("once")}
disabled={props.responding}
>
{props.t("ui.permission.allowOnce")}
</Button>
</div>
</div>
)
}}
</div>
)}
</Show>
<Show when={!blocked()}>
<Show when={!props.blocked}>
<Show
when={prompt.ready()}
when={props.promptReady}
fallback={
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{handoffPrompt() || language.t("prompt.loading")}
{props.handoffPrompt || props.t("prompt.loading")}
</div>
}
>
@@ -288,10 +202,10 @@ export function SessionPromptDock(props: {
}}
>
<SessionTodoDock
todos={todos()}
title={language.t("session.todo.title")}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
todos={props.todos}
title={props.t("session.todo.title")}
collapseLabel={props.t("session.todo.collapse")}
expandLabel={props.t("session.todo.expand")}
/>
</div>
</Show>

View File

@@ -1,268 +1,156 @@
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { useParams } from "@solidjs/router"
import { For, Match, Show, Switch, createMemo, onCleanup, type JSX, type ValidComponent } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import FileTree from "@/components/file-tree"
import { SessionContextUsage } from "@/components/session-context-usage"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { StickyAddButton } from "@/pages/session/review-tab"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import { ConstrainDragYAxis } from "@/utils/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useComments } from "@/context/comments"
import { useCommand } from "@/context/command"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, getTabReorderIndex } from "@/pages/session/helpers"
import { StickyAddButton } from "@/pages/session/review-tab"
import { setSessionHandoff } from "@/pages/session/handoff"
import type { Message, UserMessage } from "@opencode-ai/sdk/v2/client"
type SessionSidePanelViewModel = {
messages: () => Message[]
visibleUserMessages: () => UserMessage[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
}
export function SessionSidePanel(props: {
open: boolean
reviewOpen: boolean
language: ReturnType<typeof useLanguage>
layout: ReturnType<typeof useLayout>
command: ReturnType<typeof useCommand>
dialog: ReturnType<typeof useDialog>
file: ReturnType<typeof useFile>
comments: ReturnType<typeof useComments>
hasReview: boolean
reviewCount: number
reviewTab: boolean
contextOpen: () => boolean
openedTabs: () => string[]
activeTab: () => string
activeFileTab: () => string | undefined
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
openTab: (value: string) => void
showAllFiles: () => void
reviewPanel: () => JSX.Element
vm: SessionSidePanelViewModel
handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
codeComponent: NonNullable<ValidComponent>
addCommentToContext: (input: {
file: string
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => void
activeDraggable: () => string | undefined
onDragStart: (event: unknown) => void
onDragEnd: () => void
onDragOver: (event: DragEvent) => void
fileTreeTab: () => "changes" | "all"
setFileTreeTabValue: (value: string) => void
diffsReady: boolean
diffFiles: string[]
kinds: Map<string, "add" | "del" | "mix">
activeDiff?: string
focusReviewDiff: (path: string) => void
}) {
const params = useParams()
const layout = useLayout()
const sync = useSync()
const file = useFile()
const language = useLanguage()
const command = useCommand()
const dialog = useDialog()
const isDesktop = createMediaQuery("(min-width: 768px)")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened()))
const reviewTab = createMemo(() => isDesktop() && !layout.fileTree.opened())
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b
if (a === b) return a
return "mix" as const
}
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind)
const parts = file.split("/")
for (const [idx] of parts.slice(0, -1).entries()) {
const dir = parts.slice(0, idx + 1).join("/")
if (!dir) continue
out.set(dir, merge(out.get(dir), kind))
}
}
return out
})
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
}
const openReviewPanel = () => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
}
const openTab = createOpenSessionFileTab({
normalizeTab,
openTab: tabs().open,
pathFromTab: file.pathFromTab,
loadFile: file.load,
openReviewPanel,
setActive: tabs().setActive,
})
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
const openedTabs = createMemo(() =>
tabs()
.all()
.filter((tab) => tab !== "context" && tab !== "review"),
)
const activeTab = createMemo(() => {
const active = tabs().active()
if (active === "context") return "context"
if (active === "review" && reviewTab()) return "review"
if (active && file.pathFromTab(active)) return normalizeTab(active)
const first = openedTabs()[0]
if (first) return first
if (contextOpen()) return "context"
if (reviewTab() && hasReview()) return "review"
return "empty"
})
const activeFileTab = createMemo(() => {
const active = activeTab()
if (!openedTabs().includes(active)) return
return active
})
const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTabValue = (value: string) => {
if (value !== "changes" && value !== "all") return
layout.fileTree.setTab(value)
}
const showAllFiles = () => {
if (fileTreeTab() !== "changes") return
layout.fileTree.setTab("all")
}
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
})
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setStore("activeDraggable", id)
}
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (!draggable || !droppable) return
const currentTabs = tabs().all()
const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString())
if (toIndex === undefined) return
tabs().move(draggable.id.toString(), toIndex)
}
const handleDragEnd = () => {
setStore("activeDraggable", undefined)
}
createEffect(() => {
if (!file.ready()) return
setSessionHandoff(sessionKey(), {
files: tabs()
.all()
.reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
const path = file.pathFromTab(tab)
if (!path) return acc
const selected = file.selectedLines(path)
acc[path] =
selected && typeof selected === "object" && "start" in selected && "end" in selected
? (selected as SelectedLineRange)
: null
return acc
}, {}),
})
})
const openedTabs = createMemo(() => props.openedTabs())
return (
<Show when={open()}>
<Show when={props.open}>
<aside
id="review-panel"
aria-label={language.t("session.panel.reviewAndFiles")}
aria-label={props.language.t("session.panel.reviewAndFiles")}
class="relative min-w-0 h-full border-l border-border-weak-base flex"
classList={{
"flex-1": reviewOpen(),
"shrink-0": !reviewOpen(),
"flex-1": props.reviewOpen,
"shrink-0": !props.reviewOpen,
}}
style={{ width: reviewOpen() ? undefined : `${layout.fileTree.width()}px` }}
style={{ width: props.reviewOpen ? undefined : `${props.layout.fileTree.width()}px` }}
>
<Show when={reviewOpen()}>
<Show when={props.reviewOpen}>
<div class="flex-1 min-w-0 h-full">
<Show
when={layout.fileTree.opened() && fileTreeTab() === "changes"}
when={props.layout.fileTree.opened() && props.fileTreeTab() === "changes"}
fallback={
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragStart={props.onDragStart}
onDragEnd={props.onDragEnd}
onDragOver={props.onDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={openTab}>
<Tabs value={props.activeTab()} onChange={props.openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
const stop = createFileTabListSync({ el, contextOpen: props.contextOpen })
onCleanup(stop)
}}
>
<Show when={reviewTab()}>
<Show when={props.reviewTab}>
<Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={hasReview()}>
<div>{props.language.t("session.tab.review")}</div>
<Show when={props.hasReview}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{reviewCount()}
{props.reviewCount}
</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Show when={props.contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<Tooltip value={language.t("common.closeTab")} placement="bottom">
<Tooltip value={props.language.t("common.closeTab")} placement="bottom">
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
onClick={() => props.tabs().close("context")}
aria-label={props.language.t("common.closeTab")}
/>
</Tooltip>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
onMiddleClick={() => props.tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
<div>{props.language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
<For each={openedTabs()}>
{(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />}
</For>
</SortableProvider>
<StickyAddButton>
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
title={props.language.t("command.file.open")}
keybind={props.command.keybind("file.open")}
class="flex items-center"
>
<IconButton
@@ -270,52 +158,72 @@ export function SessionSidePanel(props: {
variant="ghost"
iconSize="large"
onClick={() =>
dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
props.dialog.show(() => (
<DialogSelectFile mode="files" onOpenFile={props.showAllFiles} />
))
}
aria-label={language.t("command.file.open")}
aria-label={props.language.t("command.file.open")}
/>
</TooltipKeybind>
</StickyAddButton>
</Tabs.List>
</div>
<Show when={reviewTab()}>
<Show when={props.reviewTab}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
<Show when={props.activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
</Show>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "empty"}>
<Show when={props.activeTab() === "empty"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
{props.language.t("session.files.selectToOpen")}
</div>
</div>
</div>
</Show>
</Tabs.Content>
<Show when={contextOpen()}>
<Show when={props.contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "context"}>
<Show when={props.activeTab() === "context"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
<SessionContextTab
messages={props.vm.messages}
visibleUserMessages={props.vm.visibleUserMessages}
view={props.vm.view}
info={props.vm.info}
/>
</div>
</Show>
</Tabs.Content>
</Show>
<Show when={activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
<Show when={props.activeFileTab()} keyed>
{(tab) => (
<FileTabContent
tab={tab}
activeTab={props.activeTab}
tabs={props.tabs}
view={props.vm.view}
handoffFiles={props.handoffFiles}
file={props.file}
comments={props.comments}
language={props.language}
codeComponent={props.codeComponent}
addCommentToContext={props.addCommentToContext}
/>
)}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
<Show when={props.activeDraggable()}>
{(tab) => {
const path = createMemo(() => file.pathFromTab(tab))
const path = createMemo(() => props.file.pathFromTab(tab()))
return (
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
@@ -332,44 +240,50 @@ export function SessionSidePanel(props: {
</div>
</Show>
<Show when={layout.fileTree.opened()}>
<div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
<Show when={props.layout.fileTree.opened()}>
<div
id="file-tree-panel"
class="relative shrink-0 h-full"
style={{ width: `${props.layout.fileTree.width()}px` }}
>
<div
class="h-full flex flex-col overflow-hidden group/filetree"
classList={{ "border-l border-border-weak-base": reviewOpen() }}
classList={{ "border-l border-border-weak-base": props.reviewOpen }}
>
<Tabs
variant="pill"
value={fileTreeTab()}
onChange={setFileTreeTabValue}
value={props.fileTreeTab()}
onChange={props.setFileTreeTabValue}
class="h-full"
data-scope="filetree"
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{reviewCount()}{" "}
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
{props.reviewCount}{" "}
{props.language.t(
props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other",
)}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")}
{props.language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Tabs.Content value="changes" class="bg-background-base px-3 py-0">
<Switch>
<Match when={hasReview()}>
<Match when={props.hasReview}>
<Show
when={diffsReady()}
when={props.diffsReady}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
{props.language.t("common.loading")}
{props.language.t("common.loading.ellipsis")}
</div>
}
>
<FileTree
path=""
allowed={diffFiles()}
kinds={kinds()}
allowed={props.diffFiles}
kinds={props.kinds}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
@@ -378,17 +292,17 @@ export function SessionSidePanel(props: {
</Match>
<Match when={true}>
<div class="mt-8 text-center text-12-regular text-text-weak">
{language.t("session.review.noChanges")}
{props.language.t("session.review.noChanges")}
</div>
</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Tabs.Content value="all" class="bg-background-base px-3 py-0">
<FileTree
path=""
modified={diffFiles()}
kinds={kinds()}
onFileClick={(node) => openTab(file.tab(node.path))}
modified={props.diffFiles}
kinds={props.kinds}
onFileClick={(node) => props.openTab(props.file.tab(node.path))}
/>
</Tabs.Content>
</Tabs>
@@ -396,12 +310,12 @@ export function SessionSidePanel(props: {
<ResizeHandle
direction="horizontal"
edge="start"
size={layout.fileTree.width()}
size={props.layout.fileTree.width()}
min={200}
max={480}
collapseThreshold={160}
onResize={layout.fileTree.resize}
onCollapse={layout.fileTree.close}
onResize={props.layout.fileTree.resize}
onCollapse={props.layout.fileTree.close}
/>
</div>
</Show>

View File

@@ -1,161 +1,61 @@
import { For, Show, createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { useParams } from "@solidjs/router"
import { For, Show, createMemo } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { ConstrainDragYAxis } from "@/utils/solid-dnd"
import { SortableTerminalTab } from "@/components/session"
import { Terminal } from "@/components/terminal"
import { useCommand } from "@/context/command"
import { useTerminal } from "@/context/terminal"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useCommand } from "@/context/command"
import { terminalTabLabel } from "@/pages/session/terminal-label"
import { focusTerminalById } from "@/pages/session/helpers"
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
export function TerminalPanel() {
const params = useParams()
const layout = useLayout()
const terminal = useTerminal()
const language = useLanguage()
const command = useCommand()
const isDesktop = createMediaQuery("(min-width: 768px)")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const opened = createMemo(() => view().terminal.opened())
const open = createMemo(() => isDesktop() && opened())
const height = createMemo(() => layout.terminal.height())
const close = () => view().terminal.close()
const [store, setStore] = createStore({
autoCreated: false,
activeDraggable: undefined as string | undefined,
})
createEffect(() => {
if (!opened()) {
setStore("autoCreated", false)
return
}
if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return
terminal.new()
setStore("autoCreated", true)
})
createEffect(
on(
() => terminal.all().length,
(count, prevCount) => {
if (prevCount !== undefined && prevCount > 0 && count === 0) {
if (opened()) view().terminal.toggle()
}
},
),
)
createEffect(
on(
() => terminal.active(),
(activeId) => {
if (!activeId || !opened()) return
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
focusTerminalById(activeId)
},
),
)
createEffect(() => {
const dir = params.dir
if (!dir) return
if (!terminal.ready()) return
language.locale()
setTerminalHandoff(
dir,
terminal.all().map((pty) =>
terminalTabLabel({
title: pty.title,
titleNumber: pty.titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
}),
),
)
})
const handoff = createMemo(() => {
const dir = params.dir
if (!dir) return []
return getTerminalHandoff(dir) ?? []
})
const all = createMemo(() => terminal.all())
export function TerminalPanel(props: {
open: boolean
height: number
resize: (value: number) => void
close: () => void
terminal: ReturnType<typeof useTerminal>
language: ReturnType<typeof useLanguage>
command: ReturnType<typeof useCommand>
handoff: () => string[]
activeTerminalDraggable: () => string | undefined
handleTerminalDragStart: (event: unknown) => void
handleTerminalDragOver: (event: DragEvent) => void
handleTerminalDragEnd: () => void
onCloseTab: () => void
}) {
const all = createMemo(() => props.terminal.all())
const ids = createMemo(() => all().map((pty) => pty.id))
const byId = createMemo(() => new Map(all().map((pty) => [pty.id, pty])))
const handleTerminalDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setStore("activeDraggable", id)
}
const handleTerminalDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (!draggable || !droppable) return
const terminals = terminal.all()
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
terminal.move(draggable.id.toString(), toIndex)
}
}
const handleTerminalDragEnd = () => {
setStore("activeDraggable", undefined)
const activeId = terminal.active()
if (!activeId) return
setTimeout(() => {
focusTerminalById(activeId)
}, 0)
}
return (
<Show when={open()}>
<Show when={props.open}>
<div
id="terminal-panel"
role="region"
aria-label={language.t("terminal.title")}
aria-label={props.language.t("terminal.title")}
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
style={{ height: `${height()}px` }}
style={{ height: `${props.height}px` }}
>
<ResizeHandle
direction="vertical"
size={height()}
size={props.height}
min={100}
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={layout.terminal.resize}
onCollapse={close}
onResize={props.resize}
onCollapse={props.close}
/>
<Show
when={terminal.ready()}
when={props.terminal.ready()}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
<For each={handoff()}>
<For each={props.handoff()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title}
@@ -164,18 +64,20 @@ export function TerminalPanel() {
</For>
<div class="flex-1" />
<div class="text-text-weak pr-2">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
{props.language.t("common.loading")}
{props.language.t("common.loading.ellipsis")}
</div>
</div>
<div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
<div class="flex-1 flex items-center justify-center text-text-weak">
{props.language.t("terminal.loading")}
</div>
</div>
}
>
<DragDropProvider
onDragStart={handleTerminalDragStart}
onDragEnd={handleTerminalDragEnd}
onDragOver={handleTerminalDragOver}
onDragStart={props.handleTerminalDragStart}
onDragEnd={props.handleTerminalDragEnd}
onDragOver={props.handleTerminalDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
@@ -183,26 +85,36 @@ export function TerminalPanel() {
<div class="flex flex-col h-full">
<Tabs
variant="alt"
value={terminal.active()}
onChange={(id) => terminal.open(id)}
value={props.terminal.active()}
onChange={(id) => props.terminal.open(id)}
class="!h-auto !flex-none"
>
<Tabs.List class="h-10">
<SortableProvider ids={ids()}>
<For each={all()}>{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}</For>
<For each={all()}>
{(pty) => (
<SortableTerminalTab
terminal={pty}
onClose={() => {
props.close()
props.onCloseTab()
}}
/>
)}
</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<TooltipKeybind
title={language.t("command.terminal.new")}
keybind={command.keybind("terminal.new")}
title={props.language.t("command.terminal.new")}
keybind={props.command.keybind("terminal.new")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={terminal.new}
aria-label={language.t("command.terminal.new")}
onClick={props.terminal.new}
aria-label={props.language.t("command.terminal.new")}
/>
</TooltipKeybind>
</div>
@@ -215,11 +127,15 @@ export function TerminalPanel() {
id={`terminal-wrapper-${pty.id}`}
class="absolute inset-0"
style={{
display: terminal.active() === pty.id ? "block" : "none",
display: props.terminal.active() === pty.id ? "block" : "none",
}}
>
<Show when={pty.id} keyed>
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
<Terminal
pty={pty}
onCleanup={props.terminal.update}
onConnectError={() => props.terminal.clone(pty.id)}
/>
</Show>
</div>
)}
@@ -227,20 +143,25 @@ export function TerminalPanel() {
</div>
</div>
<DragOverlay>
<Show when={store.activeDraggable}>
{(draggedId) => (
<Show when={byId().get(draggedId())}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{terminalTabLabel({
title: t().title,
titleNumber: t().titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
})}
</div>
)}
</Show>
)}
<Show when={props.activeTerminalDraggable()}>
{(draggedId) => {
return (
<Show when={byId().get(draggedId())}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{terminalTabLabel({
title: t().title,
titleNumber: t().titleNumber,
t: props.language.t as (
key: string,
vars?: Record<string, string | number | boolean>,
) => string,
})}
</div>
)}
</Show>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>

View File

@@ -22,8 +22,29 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
export type SessionCommandContext = {
command: ReturnType<typeof useCommand>
dialog: ReturnType<typeof useDialog>
file: ReturnType<typeof useFile>
language: ReturnType<typeof useLanguage>
local: ReturnType<typeof useLocal>
permission: ReturnType<typeof usePermission>
prompt: ReturnType<typeof usePrompt>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
terminal: ReturnType<typeof useTerminal>
layout: ReturnType<typeof useLayout>
params: ReturnType<typeof useParams>
navigate: ReturnType<typeof useNavigate>
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined
status: () => { type: string }
userMessages: () => UserMessage[]
visibleUserMessages: () => UserMessage[]
showAllFiles: () => void
navigateMessageByOffset: (offset: number) => void
setActiveMessage: (message: UserMessage | undefined) => void
addSelectionToContext: (path: string, selection: FileSelection) => void
focusInput: () => void
}
@@ -34,98 +55,45 @@ const withCategory = (category: string) => {
})
}
export const useSessionCommands = (actions: SessionCommandContext) => {
const command = useCommand()
const dialog = useDialog()
const file = useFile()
const language = useLanguage()
const local = useLocal()
const permission = usePermission()
const prompt = usePrompt()
const sdk = useSDK()
const sync = useSync()
const terminal = useTerminal()
const layout = useLayout()
const params = useParams()
const navigate = useNavigate()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const idle = { type: "idle" as const }
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[])
const visibleUserMessages = createMemo(() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
})
const showAllFiles = () => {
if (layout.fileTree.tab() !== "changes") return
layout.fileTree.setTab("all")
}
const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content
if (!content) return undefined
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
const end = Math.max(selection.startLine, selection.endLine)
const lines = content.split("\n").slice(start - 1, end)
if (lines.length === 0) return undefined
return lines.slice(0, 2).join("\n")
}
const addSelectionToContext = (path: string, selection: FileSelection) => {
const preview = selectionPreview(path, selection)
prompt.context.add({ type: "file", path, selection, preview })
}
const navigateMessageByOffset = actions.navigateMessageByOffset
const setActiveMessage = actions.setActiveMessage
const focusInput = actions.focusInput
const sessionCommand = withCategory(language.t("command.category.session"))
const fileCommand = withCategory(language.t("command.category.file"))
const contextCommand = withCategory(language.t("command.category.context"))
const viewCommand = withCategory(language.t("command.category.view"))
const terminalCommand = withCategory(language.t("command.category.terminal"))
const modelCommand = withCategory(language.t("command.category.model"))
const mcpCommand = withCategory(language.t("command.category.mcp"))
const agentCommand = withCategory(language.t("command.category.agent"))
const permissionsCommand = withCategory(language.t("command.category.permissions"))
export const useSessionCommands = (input: SessionCommandContext) => {
const sessionCommand = withCategory(input.language.t("command.category.session"))
const fileCommand = withCategory(input.language.t("command.category.file"))
const contextCommand = withCategory(input.language.t("command.category.context"))
const viewCommand = withCategory(input.language.t("command.category.view"))
const terminalCommand = withCategory(input.language.t("command.category.terminal"))
const modelCommand = withCategory(input.language.t("command.category.model"))
const mcpCommand = withCategory(input.language.t("command.category.mcp"))
const agentCommand = withCategory(input.language.t("command.category.agent"))
const permissionsCommand = withCategory(input.language.t("command.category.permissions"))
const sessionCommands = createMemo(() => [
sessionCommand({
id: "session.new",
title: language.t("command.session.new"),
title: input.language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
onSelect: () => input.navigate(`/${input.params.dir}/session`),
}),
])
const fileCommands = createMemo(() => [
fileCommand({
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
title: input.language.t("command.file.open"),
description: input.language.t("palette.search.placeholder"),
keybind: "mod+p",
slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
}),
fileCommand({
id: "tab.close",
title: language.t("command.tab.close"),
title: input.language.t("command.tab.close"),
keybind: "mod+w",
disabled: !tabs().active(),
disabled: !input.tabs().active(),
onSelect: () => {
const active = tabs().active()
const active = input.tabs().active()
if (!active) return
tabs().close(active)
input.tabs().close(active)
},
}),
])
@@ -133,30 +101,30 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const contextCommands = createMemo(() => [
contextCommand({
id: "context.addSelection",
title: language.t("command.context.addSelection"),
description: language.t("command.context.addSelection.description"),
title: input.language.t("command.context.addSelection"),
description: input.language.t("command.context.addSelection.description"),
keybind: "mod+shift+l",
disabled: !canAddSelectionContext({
active: tabs().active(),
pathFromTab: file.pathFromTab,
selectedLines: file.selectedLines,
active: input.tabs().active(),
pathFromTab: input.file.pathFromTab,
selectedLines: input.file.selectedLines,
}),
onSelect: () => {
const active = tabs().active()
const active = input.tabs().active()
if (!active) return
const path = file.pathFromTab(active)
const path = input.file.pathFromTab(active)
if (!path) return
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
const range = input.file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) {
showToast({
title: language.t("toast.context.noLineSelection.title"),
description: language.t("toast.context.noLineSelection.description"),
title: input.language.t("toast.context.noLineSelection.title"),
description: input.language.t("toast.context.noLineSelection.description"),
})
return
}
addSelectionToContext(path, selectionFromLines(range))
input.addSelectionToContext(path, selectionFromLines(range))
},
}),
])
@@ -164,37 +132,37 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const viewCommands = createMemo(() => [
viewCommand({
id: "terminal.toggle",
title: language.t("command.terminal.toggle"),
title: input.language.t("command.terminal.toggle"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => view().terminal.toggle(),
onSelect: () => input.view().terminal.toggle(),
}),
viewCommand({
id: "review.toggle",
title: language.t("command.review.toggle"),
title: input.language.t("command.review.toggle"),
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
onSelect: () => input.view().reviewPanel.toggle(),
}),
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
title: input.language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
onSelect: () => input.layout.fileTree.toggle(),
}),
viewCommand({
id: "input.focus",
title: language.t("command.input.focus"),
title: input.language.t("command.input.focus"),
keybind: "ctrl+l",
onSelect: () => focusInput(),
onSelect: () => input.focusInput(),
}),
terminalCommand({
id: "terminal.new",
title: language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"),
title: input.language.t("command.terminal.new"),
description: input.language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t",
onSelect: () => {
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
if (input.terminal.all().length > 0) input.terminal.new()
input.view().terminal.open()
},
}),
])
@@ -202,61 +170,61 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const messageCommands = createMemo(() => [
sessionCommand({
id: "message.previous",
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
title: input.language.t("command.message.previous"),
description: input.language.t("command.message.previous.description"),
keybind: "mod+arrowup",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
disabled: !input.params.id,
onSelect: () => input.navigateMessageByOffset(-1),
}),
sessionCommand({
id: "message.next",
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
title: input.language.t("command.message.next"),
description: input.language.t("command.message.next.description"),
keybind: "mod+arrowdown",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
disabled: !input.params.id,
onSelect: () => input.navigateMessageByOffset(1),
}),
])
const agentCommands = createMemo(() => [
modelCommand({
id: "model.choose",
title: language.t("command.model.choose"),
description: language.t("command.model.choose.description"),
title: input.language.t("command.model.choose"),
description: input.language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel />),
onSelect: () => input.dialog.show(() => <DialogSelectModel />),
}),
mcpCommand({
id: "mcp.toggle",
title: language.t("command.mcp.toggle"),
description: language.t("command.mcp.toggle.description"),
title: input.language.t("command.mcp.toggle"),
description: input.language.t("command.mcp.toggle.description"),
keybind: "mod+;",
slash: "mcp",
onSelect: () => dialog.show(() => <DialogSelectMcp />),
onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
}),
agentCommand({
id: "agent.cycle",
title: language.t("command.agent.cycle"),
description: language.t("command.agent.cycle.description"),
title: input.language.t("command.agent.cycle"),
description: input.language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
onSelect: () => local.agent.move(1),
onSelect: () => input.local.agent.move(1),
}),
agentCommand({
id: "agent.cycle.reverse",
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
title: input.language.t("command.agent.cycle.reverse"),
description: input.language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
onSelect: () => input.local.agent.move(-1),
}),
modelCommand({
id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
title: input.language.t("command.model.variant.cycle"),
description: input.language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => {
local.model.variant.cycle()
input.local.model.variant.cycle()
},
}),
])
@@ -265,22 +233,22 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
permissionsCommand({
id: "permissions.autoaccept",
title:
params.id && permission.isAutoAccepting(params.id, sdk.directory)
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory)
? input.language.t("command.permissions.autoaccept.disable")
: input.language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
disabled: !params.id || !permission.permissionsEnabled(),
disabled: !input.params.id || !input.permission.permissionsEnabled(),
onSelect: () => {
const sessionID = params.id
const sessionID = input.params.id
if (!sessionID) return
permission.toggleAutoAccept(sessionID, sdk.directory)
input.permission.toggleAutoAccept(sessionID, input.sdk.directory)
showToast({
title: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
title: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
? input.language.t("toast.permissions.autoaccept.on.title")
: input.language.t("toast.permissions.autoaccept.off.title"),
description: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
? input.language.t("toast.permissions.autoaccept.on.description")
: input.language.t("toast.permissions.autoaccept.off.description"),
})
},
}),
@@ -289,71 +257,71 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionActionCommands = createMemo(() => [
sessionCommand({
id: "session.undo",
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
title: input.language.t("command.session.undo"),
description: input.language.t("command.session.undo.description"),
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
const sessionID = input.params.id
if (!sessionID) return
if (status()?.type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
if (input.status()?.type !== "idle") {
await input.sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
const revert = input.info()?.revert?.messageID
const message = findLast(input.userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = sync.data.part[message.id]
await input.sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = input.sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
const restored = extractPromptFromParts(parts, { directory: input.sdk.directory })
input.prompt.set(restored)
}
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
setActiveMessage(priorMessage)
const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
input.setActiveMessage(priorMessage)
},
}),
sessionCommand({
id: "session.redo",
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
title: input.language.t("command.session.redo"),
description: input.language.t("command.session.redo.description"),
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
disabled: !input.params.id || !input.info()?.revert?.messageID,
onSelect: async () => {
const sessionID = params.id
const sessionID = input.params.id
if (!sessionID) return
const revertMessageID = info()?.revert?.messageID
const revertMessageID = input.info()?.revert?.messageID
if (!revertMessageID) return
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
const nextMessage = input.userMessages().find((x) => x.id > revertMessageID)
if (!nextMessage) {
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(lastMsg)
await input.sdk.client.session.unrevert({ sessionID })
input.prompt.reset()
const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID)
input.setActiveMessage(lastMsg)
return
}
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
setActiveMessage(priorMsg)
await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
input.setActiveMessage(priorMsg)
},
}),
sessionCommand({
id: "session.compact",
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
title: input.language.t("command.session.compact"),
description: input.language.t("command.session.compact.description"),
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
const sessionID = input.params.id
if (!sessionID) return
const model = local.model.current()
const model = input.local.model.current()
if (!model) {
showToast({
title: language.t("toast.model.none.title"),
description: language.t("toast.model.none.description"),
title: input.language.t("toast.model.none.title"),
description: input.language.t("toast.model.none.description"),
})
return
}
await sdk.client.session.summarize({
await input.sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
@@ -362,27 +330,29 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}),
sessionCommand({
id: "session.fork",
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
title: input.language.t("command.session.fork"),
description: input.language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: () => input.dialog.show(() => <DialogFork />),
}),
])
const shareCommands = createMemo(() => {
if (sync.data.config.share === "disabled") return []
if (input.sync.data.config.share === "disabled") return []
return [
sessionCommand({
id: "session.share",
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
description: info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
title: input.info()?.share?.url
? input.language.t("session.share.copy.copyLink")
: input.language.t("command.session.share"),
description: input.info()?.share?.url
? input.language.t("toast.session.share.success.description")
: input.language.t("command.session.share.description"),
slash: "share",
disabled: !params.id,
disabled: !input.params.id,
onSelect: async () => {
if (!params.id) return
if (!input.params.id) return
const write = (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
@@ -412,7 +382,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const ok = await write(url)
if (!ok) {
showToast({
title: language.t("toast.session.share.copyFailed.title"),
title: input.language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
@@ -420,27 +390,27 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
showToast({
title: existing
? language.t("session.share.copy.copied")
: language.t("toast.session.share.success.title"),
description: language.t("toast.session.share.success.description"),
? input.language.t("session.share.copy.copied")
: input.language.t("toast.session.share.success.title"),
description: input.language.t("toast.session.share.success.description"),
variant: "success",
})
}
const existing = info()?.share?.url
const existing = input.info()?.share?.url
if (existing) {
await copy(existing, true)
return
}
const url = await sdk.client.session
.share({ sessionID: params.id })
const url = await input.sdk.client.session
.share({ sessionID: input.params.id })
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
showToast({
title: language.t("toast.session.share.failed.title"),
description: language.t("toast.session.share.failed.description"),
title: input.language.t("toast.session.share.failed.title"),
description: input.language.t("toast.session.share.failed.description"),
variant: "error",
})
return
@@ -451,25 +421,25 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}),
sessionCommand({
id: "session.unshare",
title: language.t("command.session.unshare"),
description: language.t("command.session.unshare.description"),
title: input.language.t("command.session.unshare"),
description: input.language.t("command.session.unshare.description"),
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
disabled: !input.params.id || !input.info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.unshare({ sessionID: params.id })
if (!input.params.id) return
await input.sdk.client.session
.unshare({ sessionID: input.params.id })
.then(() =>
showToast({
title: language.t("toast.session.unshare.success.title"),
description: language.t("toast.session.unshare.success.description"),
title: input.language.t("toast.session.unshare.success.title"),
description: input.language.t("toast.session.unshare.success.description"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: language.t("toast.session.unshare.failed.title"),
description: language.t("toast.session.unshare.failed.description"),
title: input.language.t("toast.session.unshare.failed.title"),
description: input.language.t("toast.session.unshare.failed.description"),
variant: "error",
}),
)
@@ -478,7 +448,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
]
})
command.register("session", () =>
input.command.register("session", () =>
[
sessionCommands(),
fileCommands(),

View File

@@ -1,8 +1,6 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* biome-ignore-all lint: auto-generated */
/// <reference types="vite/client" />
interface ImportMetaEnv {

View File

@@ -0,0 +1,135 @@
import { uuid } from "@/utils/uuid"
type Nav = {
id: string
dir?: string
from?: string
to: string
trigger?: string
start: number
marks: Record<string, number>
logged: boolean
timer?: ReturnType<typeof setTimeout>
}
const dev = import.meta.env.DEV
const key = (dir: string | undefined, to: string) => `${dir ?? ""}:${to}`
const now = () => performance.now()
const navs = new Map<string, Nav>()
const pending = new Map<string, string>()
const active = new Map<string, string>()
const required = [
"session:params",
"session:data-ready",
"session:first-turn-mounted",
"storage:prompt-ready",
"storage:terminal-ready",
"storage:file-view-ready",
]
function flush(id: string, reason: "complete" | "timeout") {
if (!dev) return
const nav = navs.get(id)
if (!nav) return
if (nav.logged) return
nav.logged = true
if (nav.timer) clearTimeout(nav.timer)
const baseName = nav.marks["navigate:start"] !== undefined ? "navigate:start" : "session:params"
const base = nav.marks[baseName] ?? nav.start
const ms = Object.fromEntries(
Object.entries(nav.marks)
.slice()
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, t]) => [name, Math.round((t - base) * 100) / 100]),
)
console.log(
"perf.session-nav " +
JSON.stringify({
type: "perf.session-nav.v0",
id: nav.id,
dir: nav.dir,
from: nav.from,
to: nav.to,
trigger: nav.trigger,
base: baseName,
reason,
ms,
}),
)
navs.delete(id)
}
function maybeFlush(id: string) {
if (!dev) return
const nav = navs.get(id)
if (!nav) return
if (nav.logged) return
if (!required.every((name) => nav.marks[name] !== undefined)) return
flush(id, "complete")
}
function ensure(id: string, data: Omit<Nav, "marks" | "logged" | "timer">) {
const existing = navs.get(id)
if (existing) return existing
const nav: Nav = {
...data,
marks: {},
logged: false,
}
nav.timer = setTimeout(() => flush(id, "timeout"), 5000)
navs.set(id, nav)
return nav
}
export function navStart(input: { dir?: string; from?: string; to: string; trigger?: string }) {
if (!dev) return
const id = uuid()
const start = now()
const nav = ensure(id, { ...input, id, start })
nav.marks["navigate:start"] = start
pending.set(key(input.dir, input.to), id)
return id
}
export function navParams(input: { dir?: string; from?: string; to: string }) {
if (!dev) return
const k = key(input.dir, input.to)
const pendingId = pending.get(k)
if (pendingId) pending.delete(k)
const id = pendingId ?? uuid()
const start = now()
const nav = ensure(id, { ...input, id, start, trigger: pendingId ? "key" : "route" })
nav.marks["session:params"] = start
active.set(k, id)
maybeFlush(id)
return id
}
export function navMark(input: { dir?: string; to: string; name: string }) {
if (!dev) return
const id = active.get(key(input.dir, input.to))
if (!id) return
const nav = navs.get(id)
if (!nav) return
if (nav.marks[input.name] !== undefined) return
nav.marks[input.name] = now()
maybeFlush(id)
}

View File

@@ -1,11 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { ServerConnection } from "@/context/server"
import { checkServerHealth } from "./server-health"
const server: ServerConnection.HttpBase = {
url: "http://localhost:4096",
}
function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
if (init?.signal) return init.signal
if (input instanceof Request) return input.signal
@@ -20,7 +15,7 @@ describe("checkServerHealth", () => {
headers: { "content-type": "application/json" },
})) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch)
const result = await checkServerHealth("http://localhost:4096", fetch)
expect(result).toEqual({ healthy: true, version: "1.2.3" })
})
@@ -30,7 +25,7 @@ describe("checkServerHealth", () => {
throw new Error("network")
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch)
const result = await checkServerHealth("http://localhost:4096", fetch)
expect(result).toEqual({ healthy: false })
})
@@ -56,9 +51,7 @@ describe("checkServerHealth", () => {
)
})) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch, {
timeoutMs: 10,
}).finally(() => {
const result = await checkServerHealth("http://localhost:4096", fetch, { timeoutMs: 10 }).finally(() => {
if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout)
if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout")
})
@@ -78,9 +71,7 @@ describe("checkServerHealth", () => {
}) as unknown as typeof globalThis.fetch
const abort = new AbortController()
await checkServerHealth(server, fetch, {
signal: abort.signal,
})
await checkServerHealth("http://localhost:4096", fetch, { signal: abort.signal })
expect(signal).toBe(abort.signal)
})
@@ -96,7 +87,7 @@ describe("checkServerHealth", () => {
})
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch, {
const result = await checkServerHealth("http://localhost:4096", fetch, {
retryCount: 2,
retryDelayMs: 1,
})
@@ -112,7 +103,7 @@ describe("checkServerHealth", () => {
throw new TypeError("network")
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch, {
const result = await checkServerHealth("http://localhost:4096", fetch, {
retryCount: 2,
retryDelayMs: 1,
})

View File

@@ -1,5 +1,4 @@
import type { ServerConnection } from "@/context/server"
import { createSdkForServer } from "./server"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
export type ServerHealth = { healthy: boolean; version?: string }
@@ -18,10 +17,7 @@ function timeoutSignal(timeoutMs: number) {
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
if (timeout) {
try {
return {
signal: timeout.call(AbortSignal, timeoutMs),
clear: undefined as (() => void) | undefined,
}
return { signal: timeout.call(AbortSignal, timeoutMs), clear: undefined as (() => void) | undefined }
} catch {}
}
const controller = new AbortController()
@@ -56,7 +52,7 @@ function retryable(error: unknown, signal?: AbortSignal) {
}
export async function checkServerHealth(
server: ServerConnection.HttpBase,
url: string,
fetch: typeof globalThis.fetch,
opts?: CheckServerHealthOptions,
): Promise<ServerHealth> {
@@ -71,8 +67,8 @@ export async function checkServerHealth(
.catch(() => ({ healthy: false }))
}
const attempt = (count: number): Promise<ServerHealth> =>
createSdkForServer({
server,
createOpencodeClient({
baseUrl: url,
fetch,
signal,
})

View File

@@ -1,22 +0,0 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { ServerConnection } from "@/context/server"
export function createSdkForServer({
server,
...config
}: Omit<NonNullable<Parameters<typeof createOpencodeClient>[0]>, "baseUrl"> & {
server: ServerConnection.HttpBase
}) {
const auth = (() => {
if (!server.password) return
return {
Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`,
}
})()
return createOpencodeClient({
...config,
headers: { ...config.headers, ...auth },
baseUrl: server.url,
})
}

View File

@@ -6,10 +6,7 @@ describe("terminalWriter", () => {
const calls: string[] = []
const scheduled: VoidFunction[] = []
const writer = terminalWriter(
(data, done) => {
calls.push(data)
done?.()
},
(data) => calls.push(data),
(flush) => scheduled.push(flush),
)
@@ -27,38 +24,10 @@ describe("terminalWriter", () => {
test("flush is a no-op when empty", () => {
const calls: string[] = []
const writer = terminalWriter(
(data, done) => {
calls.push(data)
done?.()
},
(data) => calls.push(data),
(flush) => flush(),
)
writer.flush()
expect(calls).toEqual([])
})
test("flush waits for pending write completion", () => {
const calls: string[] = []
let done: VoidFunction | undefined
const writer = terminalWriter(
(data, finish) => {
calls.push(data)
done = finish
},
(flush) => flush(),
)
writer.push("a")
let settled = false
writer.flush(() => {
settled = true
})
expect(calls).toEqual(["a"])
expect(settled).toBe(false)
done?.()
expect(settled).toBe(true)
})
})

View File

@@ -1,42 +1,16 @@
export function terminalWriter(
write: (data: string, done?: VoidFunction) => void,
write: (data: string) => void,
schedule: (flush: VoidFunction) => void = queueMicrotask,
) {
let chunks: string[] | undefined
let waits: VoidFunction[] | undefined
let scheduled = false
let writing = false
const settle = () => {
if (scheduled || writing || chunks?.length) return
const list = waits
if (!list?.length) return
waits = undefined
for (const fn of list) {
fn()
}
}
const run = () => {
if (writing) return
const flush = () => {
scheduled = false
const items = chunks
if (!items?.length) {
settle()
return
}
if (!items?.length) return
chunks = undefined
writing = true
write(items.join(""), () => {
writing = false
if (chunks?.length) {
if (scheduled) return
scheduled = true
schedule(run)
return
}
settle()
})
write(items.join(""))
}
const push = (data: string) => {
@@ -44,21 +18,9 @@ export function terminalWriter(
if (chunks) chunks.push(data)
else chunks = [data]
if (scheduled || writing) return
if (scheduled) return
scheduled = true
schedule(run)
}
const flush = (done?: VoidFunction) => {
if (!scheduled && !writing && !chunks?.length) {
done?.()
return
}
if (done) {
if (waits) waits.push(done)
else waits = [done]
}
run()
schedule(flush)
}
return { push, flush }

View File

@@ -2,7 +2,6 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />

View File

@@ -22,6 +22,5 @@
}
},
"include": ["src", "package.json"],
"exclude": ["dist", "ts-dist"],
"references": [{ "path": "../sdk/js" }]
"exclude": ["dist", "ts-dist"]
}

View File

@@ -2,7 +2,6 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../../sst-env.d.ts" />

View File

@@ -2,7 +2,6 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
import "sst"
declare module "sst" {

View File

@@ -2,7 +2,6 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
import "sst"
declare module "sst" {

View File

@@ -2,7 +2,6 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../../sst-env.d.ts" />

View File

@@ -2,7 +2,6 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
import "sst"
declare module "sst" {

View File

@@ -3136,7 +3136,6 @@ dependencies = [
"tracing-subscriber",
"uuid",
"webkit2gtk",
"windows 0.62.2",
]
[[package]]

View File

@@ -54,9 +54,6 @@ chrono = "0.4"
tokio-stream = { version = "0.1.18", features = ["sync"] }
process-wrap = { version = "9.0.3", features = ["tokio1"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = ["Win32_System_Threading"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
webkit2gtk = "=2.0.2"

View File

@@ -3,39 +3,20 @@ use process_wrap::tokio::CommandWrap;
#[cfg(unix)]
use process_wrap::tokio::ProcessGroup;
#[cfg(windows)]
use process_wrap::tokio::{CommandWrapper, JobObject, KillOnDrop};
use process_wrap::tokio::{JobObject, KillOnDrop};
#[cfg(unix)]
use std::os::unix::process::ExitStatusExt;
use std::sync::Arc;
use std::{process::Stdio, time::Duration};
use tauri::{AppHandle, Manager, path::BaseDirectory};
use tauri_plugin_store::StoreExt;
use tauri_specta::Event;
use tokio::{
io::{AsyncBufRead, AsyncBufReadExt, BufReader},
process::Command,
sync::{mpsc, oneshot},
task::JoinHandle,
};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::{mpsc, oneshot};
use tokio_stream::wrappers::ReceiverStream;
use tracing::Instrument;
#[cfg(windows)]
use windows::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED};
use crate::server::get_wsl_config;
#[cfg(windows)]
#[derive(Clone, Copy, Debug)]
// Keep this as a custom wrapper instead of process_wrap::CreationFlags.
// JobObject pre_spawn rewrites creation flags, so this must run after it.
struct WinCreationFlags;
#[cfg(windows)]
impl CommandWrapper for WinCreationFlags {
fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> std::io::Result<()> {
command.creation_flags((CREATE_NO_WINDOW | CREATE_SUSPENDED).0);
Ok(())
}
}
use crate::constants::{SETTINGS_STORE, WSL_ENABLED_KEY};
const CLI_INSTALL_DIR: &str = ".opencode/bin";
const CLI_BINARY_NAME: &str = "opencode";
@@ -53,8 +34,8 @@ pub struct Config {
#[derive(Clone, Debug)]
pub enum CommandEvent {
Stdout(String),
Stderr(String),
Stdout(Vec<u8>),
Stderr(Vec<u8>),
Error(String),
Terminated(TerminatedPayload),
}
@@ -83,11 +64,10 @@ pub async fn get_config(app: &AppHandle) -> Option<Config> {
events
.fold(String::new(), async |mut config_str, event| {
if let CommandEvent::Stdout(s) = &event {
config_str += s.as_str()
}
if let CommandEvent::Stderr(s) = &event {
config_str += s.as_str()
if let CommandEvent::Stdout(stdout) = event
&& let Ok(s) = str::from_utf8(&stdout)
{
config_str += s
}
config_str
@@ -217,8 +197,16 @@ fn get_user_shell() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
}
fn is_wsl_enabled(_app: &tauri::AppHandle) -> bool {
get_wsl_config(_app.clone()).is_ok_and(|v| v.enabled)
fn is_wsl_enabled(app: &tauri::AppHandle) -> bool {
let Ok(store) = app.store(SETTINGS_STORE) else {
return false;
};
store
.get(WSL_ENABLED_KEY)
.as_ref()
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
fn shell_escape(input: &str) -> String {
@@ -329,9 +317,12 @@ pub fn spawn_command(
cmd
};
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.stdin(Stdio::null());
#[cfg(windows)]
cmd.creation_flags(0x0800_0000);
let mut wrap = CommandWrap::from(cmd);
@@ -342,28 +333,36 @@ pub fn spawn_command(
#[cfg(windows)]
{
wrap.wrap(JobObject).wrap(WinCreationFlags).wrap(KillOnDrop);
wrap.wrap(JobObject).wrap(KillOnDrop);
}
let mut child = wrap.spawn()?;
let guard = Arc::new(tokio::sync::RwLock::new(()));
let stdout = child.stdout().take();
let stderr = child.stderr().take();
let (tx, rx) = mpsc::channel(256);
let (kill_tx, mut kill_rx) = mpsc::channel(1);
let stdout = spawn_pipe_reader(
tx.clone(),
guard.clone(),
BufReader::new(child.stdout().take().unwrap()),
CommandEvent::Stdout,
);
let stderr = spawn_pipe_reader(
tx.clone(),
guard.clone(),
BufReader::new(child.stderr().take().unwrap()),
CommandEvent::Stderr,
);
if let Some(stdout) = stdout {
let tx = tx.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = tx.send(CommandEvent::Stdout(line.into_bytes())).await;
}
});
}
tokio::task::spawn(async move {
if let Some(stderr) = stderr {
let tx = tx.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = tx.send(CommandEvent::Stderr(line.into_bytes())).await;
}
});
}
tokio::spawn(async move {
let mut kill_open = true;
let status = loop {
match child.try_wait() {
@@ -395,9 +394,6 @@ pub fn spawn_command(
let _ = tx.send(CommandEvent::Error(err.to_string())).await;
}
}
stdout.abort();
stderr.abort();
});
let event_stream = ReceiverStream::new(rx);
@@ -408,7 +404,9 @@ pub fn spawn_command(
fn signal_from_status(status: std::process::ExitStatus) -> Option<i32> {
#[cfg(unix)]
return status.signal();
{
return status.signal();
}
#[cfg(not(unix))]
{
@@ -444,10 +442,12 @@ pub fn serve(
events
.for_each(move |event| {
match event {
CommandEvent::Stdout(line) => {
CommandEvent::Stdout(line_bytes) => {
let line = String::from_utf8_lossy(&line_bytes);
tracing::info!("{line}");
}
CommandEvent::Stderr(line) => {
CommandEvent::Stderr(line_bytes) => {
let line = String::from_utf8_lossy(&line_bytes);
tracing::info!("{line}");
}
CommandEvent::Error(err) => {
@@ -499,7 +499,11 @@ pub mod sqlite_migration {
}
future::ready(match &event {
CommandEvent::Stdout(s) | CommandEvent::Stderr(s) => {
CommandEvent::Stdout(stdout) => {
let Ok(s) = str::from_utf8(stdout) else {
return future::ready(None);
};
if let Some(s) = s.strip_prefix("sqlite-migration:").map(|s| s.trim()) {
if let Ok(progress) = s.parse::<u8>() {
let _ = SqliteMigrationProgress::InProgress(progress).emit(&app);
@@ -518,41 +522,3 @@ pub mod sqlite_migration {
})
}
}
fn spawn_pipe_reader<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
tx: mpsc::Sender<CommandEvent>,
guard: Arc<tokio::sync::RwLock<()>>,
pipe_reader: impl AsyncBufRead + Send + Unpin + 'static,
wrapper: F,
) -> JoinHandle<()> {
tokio::spawn(async move {
let _lock = guard.read().await;
let reader = BufReader::new(pipe_reader);
read_line(reader, tx, wrapper).await;
})
}
async fn read_line<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
reader: BufReader<impl AsyncBufRead + Unpin>,
tx: mpsc::Sender<CommandEvent>,
wrapper: F,
) {
let mut lines = reader.lines();
loop {
let line = lines.next_line().await;
match line {
Ok(s) => {
if let Some(s) = s {
let _ = tx.clone().send(wrapper(s)).await;
}
}
Err(e) => {
let tx_ = tx.clone();
let _ = tx_.send(CommandEvent::Error(e.to_string())).await;
break;
}
}
}
}

View File

@@ -55,18 +55,18 @@ pub async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Resu
#[tauri::command]
#[specta::specta]
pub fn get_wsl_config(_app: AppHandle) -> Result<WslConfig, String> {
// let store = app
// .store(SETTINGS_STORE)
// .map_err(|e| format!("Failed to open settings store: {}", e))?;
pub fn get_wsl_config(app: AppHandle) -> Result<WslConfig, String> {
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("Failed to open settings store: {}", e))?;
// let enabled = store
// .get(WSL_ENABLED_KEY)
// .as_ref()
// .and_then(|v| v.as_bool())
// .unwrap_or(false);
let enabled = store
.get(WSL_ENABLED_KEY)
.as_ref()
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(WslConfig { enabled: false })
Ok(WslConfig { enabled })
}
#[tauri::command]

View File

@@ -1,37 +1,36 @@
// @refresh reload
import { webviewZoom } from "./webview-zoom"
import { render } from "solid-js/web"
import {
AppBaseProviders,
AppInterface,
handleNotificationClick,
type Platform,
PlatformProvider,
ServerConnection,
Platform,
useCommand,
handleNotificationClick,
} from "@opencode-ai/app"
import { Splash } from "@opencode-ai/ui/logo"
import type { AsyncStorage } from "@solid-primitives/storage"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { readImage } from "@tauri-apps/plugin-clipboard-manager"
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
import { open, save } from "@tauri-apps/plugin-dialog"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener"
import { type as ostype } from "@tauri-apps/plugin-os"
import { relaunch } from "@tauri-apps/plugin-process"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { type as ostype } from "@tauri-apps/plugin-os"
import { check, Update } from "@tauri-apps/plugin-updater"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { relaunch } from "@tauri-apps/plugin-process"
import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
import { check, type Update } from "@tauri-apps/plugin-updater"
import { type Accessor, createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../package.json"
import { initI18n, t } from "./i18n"
import { Splash } from "@opencode-ai/ui/logo"
import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js"
import { readImage } from "@tauri-apps/plugin-clipboard-manager"
import { UPDATER_ENABLED } from "./updater"
import { webviewZoom } from "./webview-zoom"
import { initI18n, t } from "./i18n"
import pkg from "../package.json"
import "./styles.css"
import { commands, InitStep } from "./bindings"
import { Channel } from "@tauri-apps/api/core"
import { commands, type InitStep } from "./bindings"
import { createMenu } from "./menu"
const root = document.getElementById("root")
@@ -59,7 +58,7 @@ const listenForDeepLinks = async () => {
await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined)
}
const createPlatform = (): Platform => {
const createPlatform = (password: Accessor<string | null>): Platform => {
const os = (() => {
const type = ostype()
if (type === "macos" || type === "windows" || type === "linux") return type
@@ -345,10 +344,22 @@ const createPlatform = (): Platform => {
},
fetch: (input, init) => {
const pw = password()
const addHeader = (headers: Headers, password: string) => {
headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
}
if (input instanceof Request) {
if (pw) addHeader(input.headers, pw)
return tauriFetch(input)
} else {
return tauriFetch(input, init)
const headers = new Headers(init?.headers)
if (pw) addHeader(headers, pw)
return tauriFetch(input, {
...(init as any),
headers: headers,
})
}
},
@@ -406,11 +417,7 @@ const createPlatform = (): Platform => {
return new Promise<File | null>((resolve) => {
canvas.toBlob((blob) => {
if (!blob) return resolve(null)
resolve(
new File([blob], `pasted-image-${Date.now()}.png`, {
type: "image/png",
}),
)
resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" }))
}, "image/png")
})
},
@@ -424,7 +431,9 @@ createMenu((id) => {
void listenForDeepLinks()
render(() => {
const platform = createPlatform()
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
const platform = createPlatform(() => serverPassword())
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
@@ -446,16 +455,9 @@ render(() => {
<AppBaseProviders>
<ServerGate>
{(data) => {
const server: ServerConnection.Sidecar = {
displayName: "Local Server",
type: "sidecar",
variant: "base",
http: {
url: data().url,
username: "opencode",
password: data().password ?? undefined,
},
}
setServerPassword(data().password)
window.__OPENCODE__ ??= {}
window.__OPENCODE__.serverPassword = data().password ?? undefined
function Inner() {
const cmd = useCommand()
@@ -466,7 +468,7 @@ render(() => {
}
return (
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]}>
<AppInterface defaultUrl={data().url} isSidecar>
<Inner />
</AppInterface>
)

View File

@@ -2,7 +2,6 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />

View File

@@ -2,7 +2,6 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
import "sst"
declare module "sst" {

View File

@@ -2,7 +2,6 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
import "sst"
declare module "sst" {

View File

@@ -40,7 +40,6 @@
"@tsconfig/bun": "catalog:",
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
@@ -75,7 +74,7 @@
"@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/gitlab-ai-provider": "3.5.1",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
@@ -111,7 +110,6 @@
"hono-openapi": "catalog:",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"open": "10.1.2",
"opentui-spinner": "0.0.6",

View File

@@ -214,8 +214,7 @@ export default {
},
{
filetype: "clojure",
// temporarily using fork to fix issues
wasm: "https://github.com/anomalyco/tree-sitter-clojure/releases/download/v0.0.1/tree-sitter-clojure.wasm",
wasm: "https://github.com/sogaiu/tree-sitter-clojure/releases/download/v0.0.13/tree-sitter-clojure.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/clojure/highlights.scm",

View File

@@ -30,7 +30,6 @@ import {
import { Log } from "../util/log"
import { pathToFileURL } from "bun"
import { Filesystem } from "../util/filesystem"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
@@ -229,7 +228,8 @@ export namespace ACP {
const metadata = permission.metadata || {}
const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
const content = (await Filesystem.exists(filepath)) ? await Filesystem.readText(filepath) : ""
const file = Bun.file(filepath)
const content = (await file.exists()) ? await file.text() : ""
const newContent = getNewContent(content, diff)
if (newContent) {

View File

@@ -52,13 +52,13 @@ export namespace Agent {
const cfg = await Config.get()
const skillDirs = await Skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
const defaults = PermissionNext.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
[Truncate.GLOB]: "allow",
...Object.fromEntries(skillDirs.map((dir) => [path.join(dir, "*"), "allow"])),
},
question: "deny",
plan_enter: "deny",
@@ -142,8 +142,7 @@ export namespace Agent {
codesearch: "allow",
read: "allow",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
[Truncate.GLOB]: "allow",
},
}),
user,

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