mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-09 18:34:21 +00:00
Compare commits
176 Commits
fix-tool-o
...
redesign-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b3b4c8725 | ||
|
|
0a3f558de2 | ||
|
|
2c2b1ea90a | ||
|
|
ac7eaf21dc | ||
|
|
651f173a2f | ||
|
|
6402c2ab19 | ||
|
|
7bcf5e40ec | ||
|
|
f21f8bb4c7 | ||
|
|
714ea57952 | ||
|
|
81ec3e0723 | ||
|
|
0bb2c77bb8 | ||
|
|
48db3981e4 | ||
|
|
2b4fe1c320 | ||
|
|
2aaf6ca27d | ||
|
|
54eb8ff6c7 | ||
|
|
1c07b14545 | ||
|
|
38efdf82a1 | ||
|
|
176f26850d | ||
|
|
8047f2052a | ||
|
|
2a38007101 | ||
|
|
d3d261d37e | ||
|
|
2a7869dbf8 | ||
|
|
666ffa2832 | ||
|
|
390f90ef47 | ||
|
|
06d63ca54c | ||
|
|
423778c93a | ||
|
|
8de9e47a5b | ||
|
|
d63ed3bbe3 | ||
|
|
4369d79636 | ||
|
|
3408f1a6ae | ||
|
|
34c58af796 | ||
|
|
37979ea44f | ||
|
|
50b5168c16 | ||
|
|
6b17645f2e | ||
|
|
52006c2fd9 | ||
|
|
26197ec95b | ||
|
|
43bb389e35 | ||
|
|
985090ef3c | ||
|
|
52eb8a7a8c | ||
|
|
1cabeb00d0 | ||
|
|
9564c1d6be | ||
|
|
1832eeffc9 | ||
|
|
e6d8315e29 | ||
|
|
784a17f7b3 | ||
|
|
04aef44fc3 | ||
|
|
c02dd067b2 | ||
|
|
141fdef588 | ||
|
|
3982c7d99a | ||
|
|
76745d0594 | ||
|
|
4850ecc419 | ||
|
|
43354eeabd | ||
|
|
7a9290dc9b | ||
|
|
cfbe9d329f | ||
|
|
f02499fa44 | ||
|
|
bd9d7b3221 | ||
|
|
c69474846f | ||
|
|
0dc80df6fd | ||
|
|
8e985e0a75 | ||
|
|
a4d31b6f95 | ||
|
|
c5dc075a88 | ||
|
|
eade8ee07b | ||
|
|
8fbba8de73 | ||
|
|
826664b559 | ||
|
|
d1f884033f | ||
|
|
0f3630d936 | ||
|
|
83d0e48e38 | ||
|
|
6c9b2c37a5 | ||
|
|
3ab41d548f | ||
|
|
d3d783e23d | ||
|
|
7aad2ee9ae | ||
|
|
f390ac251d | ||
|
|
7837bbc639 | ||
|
|
372dcc033c | ||
|
|
4158d7cda8 | ||
|
|
425abe2fbf | ||
|
|
744fb6aed0 | ||
|
|
e9f8e6aeec | ||
|
|
5dee3328d4 | ||
|
|
2f63152af3 | ||
|
|
c9891fe071 | ||
|
|
d35956fd92 | ||
|
|
7417e6eb38 | ||
|
|
5db089070a | ||
|
|
612b656d36 | ||
|
|
cb6ec0a564 | ||
|
|
12b8c42387 | ||
|
|
fa75d922ed | ||
|
|
e445dc0746 | ||
|
|
e84d441b82 | ||
|
|
377bf7ff21 | ||
|
|
b39c1f158f | ||
|
|
f23d8d343b | ||
|
|
91f2ac3cb2 | ||
|
|
ec720145fa | ||
|
|
f6948d0ffa | ||
|
|
d52ee41b3a | ||
|
|
ca5e85d6ea | ||
|
|
01cec84789 | ||
|
|
e62a15d421 | ||
|
|
d29dfe31e4 | ||
|
|
f15755684f | ||
|
|
16145af480 | ||
|
|
eace76e525 | ||
|
|
cc1d3732bc | ||
|
|
1798af72b0 | ||
|
|
2c82e6c6ae | ||
|
|
3577d829c2 | ||
|
|
29d02d643b | ||
|
|
23c803707d | ||
|
|
b51005ec4a | ||
|
|
dfbe553626 | ||
|
|
2af1ca7290 | ||
|
|
3e67104257 | ||
|
|
d1d7447493 | ||
|
|
c3faeae9d0 | ||
|
|
94baf1f721 | ||
|
|
9b8b9e28e2 | ||
|
|
2a56a1d6ef | ||
|
|
9e45313b0a | ||
|
|
d4c90b2dfb | ||
|
|
5b784871f0 | ||
|
|
e5f677dfb5 | ||
|
|
6a96810249 | ||
|
|
8b7fe7c09f | ||
|
|
0961632a9c | ||
|
|
abbf60080d | ||
|
|
33252a65b4 | ||
|
|
e70d984320 | ||
|
|
da7c874808 | ||
|
|
a19ef17bcb | ||
|
|
121d6a72c0 | ||
|
|
35f64b80fa | ||
|
|
feca42b025 | ||
|
|
53f118c57a | ||
|
|
786ae0a584 | ||
|
|
f73f88fb56 | ||
|
|
ac254fb442 | ||
|
|
597ae57bb1 | ||
|
|
a552652fcc | ||
|
|
511c7abaca | ||
|
|
f834915d3f | ||
|
|
65c21f8fe4 | ||
|
|
6b972329fd | ||
|
|
6ecd011e51 | ||
|
|
8e5db3083c | ||
|
|
d005e70f50 | ||
|
|
46122d9a0a | ||
|
|
81ac41e089 | ||
|
|
c0e71c4261 | ||
|
|
507f13a30c | ||
|
|
90f39bf672 | ||
|
|
95bf01a757 | ||
|
|
b6bbb95704 | ||
|
|
d713026a6a | ||
|
|
73c4d3644c | ||
|
|
571f5b31c9 | ||
|
|
644f0d4e92 | ||
|
|
d9f18e4006 | ||
|
|
2f4374c829 | ||
|
|
3542f3e406 | ||
|
|
f1caf84064 | ||
|
|
85126556b8 | ||
|
|
252b2c450d | ||
|
|
0c32afbc35 | ||
|
|
aef0e58ad7 | ||
|
|
1a6461e8bc | ||
|
|
e834a2e6c9 | ||
|
|
9d3f32065b | ||
|
|
e7ff7143b6 | ||
|
|
2c36cbb87c | ||
|
|
77fa8ddc88 | ||
|
|
4a56491e42 | ||
|
|
f51bd28ed8 | ||
|
|
6cd2a68851 | ||
|
|
9259d2bf52 | ||
|
|
e94ae550ea |
@@ -23,6 +23,7 @@ runs:
|
||||
with:
|
||||
app-id: ${{ inputs.opencode-app-id }}
|
||||
private-key: ${{ inputs.opencode-app-secret }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Configure git user
|
||||
run: |
|
||||
|
||||
11
.github/workflows/beta.yml
vendored
11
.github/workflows/beta.yml
vendored
@@ -1,17 +1,12 @@
|
||||
name: beta
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
pull_request:
|
||||
types: [opened, synchronize, labeled, unlabeled]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'contributor'))
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
94
.github/workflows/close-stale-prs.yml
vendored
94
.github/workflows/close-stale-prs.yml
vendored
@@ -28,40 +28,98 @@ jobs:
|
||||
const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
|
||||
const { owner, repo } = context.repo
|
||||
const dryRun = context.payload.inputs?.dryRun === "true"
|
||||
const stalePrs = []
|
||||
|
||||
core.info(`Dry run mode: ${dryRun}`)
|
||||
core.info(`Cutoff date: ${cutoff.toISOString()}`)
|
||||
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
per_page: 100,
|
||||
sort: "updated",
|
||||
direction: "asc",
|
||||
})
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $cursor: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(first: 100, states: OPEN, after: $cursor) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
number
|
||||
title
|
||||
author {
|
||||
login
|
||||
}
|
||||
createdAt
|
||||
commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
committedDate
|
||||
}
|
||||
}
|
||||
}
|
||||
comments(last: 1) {
|
||||
nodes {
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
reviews(last: 1) {
|
||||
nodes {
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
for (const pr of prs) {
|
||||
const lastUpdated = new Date(pr.updated_at)
|
||||
if (lastUpdated > cutoff) {
|
||||
core.info(`PR ${pr.number} is fresh`)
|
||||
continue
|
||||
const allPrs = []
|
||||
let cursor = null
|
||||
let hasNextPage = true
|
||||
|
||||
while (hasNextPage) {
|
||||
const result = await github.graphql(query, {
|
||||
owner,
|
||||
repo,
|
||||
cursor,
|
||||
})
|
||||
|
||||
allPrs.push(...result.repository.pullRequests.nodes)
|
||||
hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
|
||||
cursor = result.repository.pullRequests.pageInfo.endCursor
|
||||
}
|
||||
|
||||
core.info(`Found ${allPrs.length} open pull requests`)
|
||||
|
||||
const stalePrs = allPrs.filter((pr) => {
|
||||
const dates = [
|
||||
new Date(pr.createdAt),
|
||||
pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
|
||||
pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
|
||||
pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
|
||||
].filter((d) => d !== null)
|
||||
|
||||
const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
|
||||
|
||||
if (!lastActivity || lastActivity > cutoff) {
|
||||
core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
|
||||
return false
|
||||
}
|
||||
|
||||
stalePrs.push(pr)
|
||||
}
|
||||
core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
|
||||
return true
|
||||
})
|
||||
|
||||
if (!stalePrs.length) {
|
||||
core.info("No stale pull requests found.")
|
||||
return
|
||||
}
|
||||
|
||||
core.info(`Found ${stalePrs.length} stale pull requests`)
|
||||
|
||||
for (const pr of stalePrs) {
|
||||
const issue_number = pr.number
|
||||
const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
|
||||
|
||||
if (dryRun) {
|
||||
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.user.login}`)
|
||||
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -79,5 +137,5 @@ jobs:
|
||||
state: "closed",
|
||||
})
|
||||
|
||||
core.info(`Closed PR #${issue_number} from ${pr.user.login}`)
|
||||
core.info(`Closed PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
|
||||
}
|
||||
|
||||
9
.github/workflows/deploy.yml
vendored
9
.github/workflows/deploy.yml
vendored
@@ -21,6 +21,15 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
# Workaround for Pulumi version conflict:
|
||||
# GitHub runners have Pulumi 3.212.0+ pre-installed, which removed the -root flag
|
||||
# from pulumi-language-nodejs (see https://github.com/pulumi/pulumi/pull/21065).
|
||||
# SST 3.17.x uses Pulumi SDK 3.210.0 which still passes -root, causing a conflict.
|
||||
# Removing the system language plugin forces SST to use its bundled compatible version.
|
||||
# TODO: Remove when sst supports Pulumi >3.210.0
|
||||
- name: Fix Pulumi version conflict
|
||||
run: sudo rm -f /usr/local/bin/pulumi-language-nodejs
|
||||
|
||||
- run: bun sst deploy --stage=${{ github.ref_name }}
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
177
.github/workflows/nix-hashes.yml
vendored
177
.github/workflows/nix-hashes.yml
vendored
@@ -6,13 +6,7 @@ permissions:
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- "bun.lock"
|
||||
- "package.json"
|
||||
- "packages/*/package.json"
|
||||
- "flake.lock"
|
||||
- ".github/workflows/nix-hashes.yml"
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
paths:
|
||||
- "bun.lock"
|
||||
- "package.json"
|
||||
@@ -21,120 +15,131 @@ on:
|
||||
- ".github/workflows/nix-hashes.yml"
|
||||
|
||||
jobs:
|
||||
nix-hashes:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
env:
|
||||
TITLE: node_modules hashes
|
||||
# Native runners required: bun install cross-compilation flags (--os/--cpu)
|
||||
# do not produce byte-identical node_modules as native installs.
|
||||
compute-hash:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- system: x86_64-linux
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- system: aarch64-linux
|
||||
runner: blacksmith-4vcpu-ubuntu-2404-arm
|
||||
- system: x86_64-darwin
|
||||
runner: macos-15-intel
|
||||
- system: aarch64-darwin
|
||||
runner: macos-latest
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Nix
|
||||
uses: nixbuild/nix-quick-install-action@v34
|
||||
|
||||
- name: Compute node_modules hash
|
||||
id: hash
|
||||
env:
|
||||
SYSTEM: ${{ matrix.system }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_LOG=$(mktemp)
|
||||
trap 'rm -f "$BUILD_LOG"' EXIT
|
||||
|
||||
# Build with fakeHash to trigger hash mismatch and reveal correct hash
|
||||
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
||||
|
||||
# Extract hash from build log with portability
|
||||
HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
||||
|
||||
if [ -z "$HASH" ]; then
|
||||
echo "::error::Failed to compute hash for ${SYSTEM}"
|
||||
cat "$BUILD_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$HASH" > hash.txt
|
||||
echo "Computed hash for ${SYSTEM}: $HASH"
|
||||
|
||||
- name: Upload hash
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
name: hash-${{ matrix.system }}
|
||||
path: hash.txt
|
||||
retention-days: 1
|
||||
|
||||
update-hashes:
|
||||
needs: compute-hash
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.head_ref || github.ref_name }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Setup Nix
|
||||
uses: nixbuild/nix-quick-install-action@v34
|
||||
|
||||
- name: Pull latest changes
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
|
||||
git pull --rebase --autostash origin "$BRANCH"
|
||||
git pull --rebase --autostash origin "$GITHUB_REF_NAME"
|
||||
|
||||
- name: Compute all node_modules hashes
|
||||
- name: Download hash artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: hashes
|
||||
pattern: hash-*
|
||||
|
||||
- name: Update hashes.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
HASH_FILE="nix/hashes.json"
|
||||
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
|
||||
|
||||
if [ ! -f "$HASH_FILE" ]; then
|
||||
mkdir -p "$(dirname "$HASH_FILE")"
|
||||
echo '{"nodeModules":{}}' > "$HASH_FILE"
|
||||
fi
|
||||
[ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE"
|
||||
|
||||
for SYSTEM in $SYSTEMS; do
|
||||
echo "Computing hash for ${SYSTEM}..."
|
||||
BUILD_LOG=$(mktemp)
|
||||
trap 'rm -f "$BUILD_LOG"' EXIT
|
||||
|
||||
# The updater derivations use fakeHash, so they will fail and reveal the correct hash
|
||||
UPDATER_ATTR=".#packages.x86_64-linux.${SYSTEM}_node_modules"
|
||||
|
||||
nix build "$UPDATER_ATTR" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
||||
|
||||
CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)"
|
||||
|
||||
if [ -z "$CORRECT_HASH" ]; then
|
||||
CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)"
|
||||
for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do
|
||||
FILE="hashes/hash-${SYSTEM}/hash.txt"
|
||||
if [ -f "$FILE" ]; then
|
||||
HASH="$(tr -d '[:space:]' < "$FILE")"
|
||||
echo "${SYSTEM}: ${HASH}"
|
||||
jq --arg sys "$SYSTEM" --arg h "$HASH" '.nodeModules[$sys] = $h' "$HASH_FILE" > tmp.json
|
||||
mv tmp.json "$HASH_FILE"
|
||||
else
|
||||
echo "::warning::Missing hash for ${SYSTEM}"
|
||||
fi
|
||||
|
||||
if [ -z "$CORRECT_HASH" ]; then
|
||||
echo "Failed to determine correct node_modules hash for ${SYSTEM}."
|
||||
cat "$BUILD_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " ${SYSTEM}: ${CORRECT_HASH}"
|
||||
jq --arg sys "$SYSTEM" --arg h "$CORRECT_HASH" \
|
||||
'.nodeModules[$sys] = $h' "$HASH_FILE" > "${HASH_FILE}.tmp"
|
||||
mv "${HASH_FILE}.tmp" "$HASH_FILE"
|
||||
done
|
||||
|
||||
echo "All hashes computed:"
|
||||
cat "$HASH_FILE"
|
||||
|
||||
- name: Commit ${{ env.TITLE }} changes
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
- name: Commit changes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
HASH_FILE="nix/hashes.json"
|
||||
echo "Checking for changes..."
|
||||
|
||||
summarize() {
|
||||
local status="$1"
|
||||
{
|
||||
echo "### Nix $TITLE"
|
||||
echo ""
|
||||
echo "- ref: ${GITHUB_REF_NAME}"
|
||||
echo "- status: ${status}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then
|
||||
echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
FILES=("$HASH_FILE")
|
||||
STATUS="$(git status --short -- "${FILES[@]}" || true)"
|
||||
if [ -z "$STATUS" ]; then
|
||||
echo "No changes detected."
|
||||
summarize "no changes"
|
||||
if [ -z "$(git status --short -- "$HASH_FILE")" ]; then
|
||||
echo "No changes to commit"
|
||||
echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Status: no changes" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Changes detected:"
|
||||
echo "$STATUS"
|
||||
git add "${FILES[@]}"
|
||||
git add "$HASH_FILE"
|
||||
git commit -m "chore: update nix node_modules hashes"
|
||||
|
||||
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
|
||||
git pull --rebase --autostash origin "$BRANCH"
|
||||
git push origin HEAD:"$BRANCH"
|
||||
echo "Changes pushed successfully"
|
||||
git pull --rebase --autostash origin "$GITHUB_REF_NAME"
|
||||
git push origin HEAD:"$GITHUB_REF_NAME"
|
||||
|
||||
summarize "committed $(git rev-parse --short HEAD)"
|
||||
echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Status: committed $(git rev-parse --short HEAD)" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -103,7 +103,7 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc
|
||||
- host: blacksmith-4vcpu-ubuntu-2404
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- host: blacksmith-4vcpu-ubuntu-2404-arm
|
||||
- host: blacksmith-8vcpu-ubuntu-2404-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
steps:
|
||||
|
||||
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
@@ -1,6 +1,9 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
|
||||
2
.github/workflows/typecheck.yml
vendored
2
.github/workflows/typecheck.yml
vendored
@@ -1,6 +1,8 @@
|
||||
name: typecheck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: git commit and push
|
||||
model: opencode/glm-4.7
|
||||
model: opencode/kimi-k2.5
|
||||
subtask: true
|
||||
---
|
||||
|
||||
|
||||
@@ -9,12 +9,7 @@
|
||||
"options": {},
|
||||
},
|
||||
},
|
||||
"mcp": {
|
||||
"context7": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
},
|
||||
},
|
||||
"mcp": {},
|
||||
"tools": {
|
||||
"github-triage": false,
|
||||
"github-pr-search": false,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
sst-env.d.ts
|
||||
sst-env.d.ts
|
||||
desktop/src/bindings.ts
|
||||
|
||||
97
AGENTS.md
97
AGENTS.md
@@ -5,78 +5,107 @@
|
||||
|
||||
## Style Guide
|
||||
|
||||
### General Principles
|
||||
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
|
||||
- Avoid `try`/`catch` where possible
|
||||
- Avoid using the `any` type
|
||||
- Prefer single word variable names where possible
|
||||
- Use Bun APIs when possible, like `Bun.file()`
|
||||
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
|
||||
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
|
||||
|
||||
### Avoid let statements
|
||||
### Naming
|
||||
|
||||
We don't like `let` statements, especially combined with if/else statements.
|
||||
Prefer `const`.
|
||||
|
||||
Good:
|
||||
Prefer single word names for variables and functions. Only use multiple words if necessary.
|
||||
|
||||
```ts
|
||||
const foo = condition ? 1 : 2
|
||||
// Good
|
||||
const foo = 1
|
||||
function journal(dir: string) {}
|
||||
|
||||
// Bad
|
||||
const fooBar = 1
|
||||
function prepareJournal(dir: string) {}
|
||||
```
|
||||
|
||||
Bad:
|
||||
Reduce total variable count by inlining when a value is only used once.
|
||||
|
||||
```ts
|
||||
let foo
|
||||
// Good
|
||||
const journal = await Bun.file(path.join(dir, "journal.json")).json()
|
||||
|
||||
// Bad
|
||||
const journalPath = path.join(dir, "journal.json")
|
||||
const journal = await Bun.file(journalPath).json()
|
||||
```
|
||||
|
||||
### Destructuring
|
||||
|
||||
Avoid unnecessary destructuring. Use dot notation to preserve context.
|
||||
|
||||
```ts
|
||||
// Good
|
||||
obj.a
|
||||
obj.b
|
||||
|
||||
// Bad
|
||||
const { a, b } = obj
|
||||
```
|
||||
|
||||
### Variables
|
||||
|
||||
Prefer `const` over `let`. Use ternaries or early returns instead of reassignment.
|
||||
|
||||
```ts
|
||||
// Good
|
||||
const foo = condition ? 1 : 2
|
||||
|
||||
// Bad
|
||||
let foo
|
||||
if (condition) foo = 1
|
||||
else foo = 2
|
||||
```
|
||||
|
||||
### Avoid else statements
|
||||
### Control Flow
|
||||
|
||||
Prefer early returns or using an `iife` to avoid else statements.
|
||||
|
||||
Good:
|
||||
Avoid `else` statements. Prefer early returns.
|
||||
|
||||
```ts
|
||||
// Good
|
||||
function foo() {
|
||||
if (condition) return 1
|
||||
return 2
|
||||
}
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
// Bad
|
||||
function foo() {
|
||||
if (condition) return 1
|
||||
else return 2
|
||||
}
|
||||
```
|
||||
|
||||
### Prefer single word naming
|
||||
### Schema Definitions (Drizzle)
|
||||
|
||||
Try your best to find a single word name for your variables, functions, etc.
|
||||
Only use multiple words if you cannot.
|
||||
|
||||
Good:
|
||||
Use snake_case for field names so column names don't need to be redefined as strings.
|
||||
|
||||
```ts
|
||||
const foo = 1
|
||||
const bar = 2
|
||||
const baz = 3
|
||||
```
|
||||
// Good
|
||||
const table = sqliteTable("session", {
|
||||
id: text().primaryKey(),
|
||||
project_id: text().notNull(),
|
||||
created_at: integer().notNull(),
|
||||
})
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
const fooBar = 1
|
||||
const barBaz = 2
|
||||
const bazFoo = 3
|
||||
// Bad
|
||||
const table = sqliteTable("session", {
|
||||
id: text("id").primaryKey(),
|
||||
projectID: text("project_id").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
})
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
You MUST avoid using `mocks` as much as possible.
|
||||
Tests MUST test actual implementation, do not duplicate logic into a test.
|
||||
- Avoid mocks as much as possible
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a>
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a>
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
135
README.tr.md
Normal file
135
README.tr.md
Normal file
@@ -0,0 +1,135 @@
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai">
|
||||
<picture>
|
||||
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">Açık kaynaklı yapay zeka kodlama asistanı.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> |
|
||||
<a href="README.zh.md">简体中文</a> |
|
||||
<a href="README.zht.md">繁體中文</a> |
|
||||
<a href="README.ko.md">한국어</a> |
|
||||
<a href="README.de.md">Deutsch</a> |
|
||||
<a href="README.es.md">Español</a> |
|
||||
<a href="README.fr.md">Français</a> |
|
||||
<a href="README.it.md">Italiano</a> |
|
||||
<a href="README.da.md">Dansk</a> |
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
---
|
||||
|
||||
### Kurulum
|
||||
|
||||
```bash
|
||||
# YOLO
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Paket yöneticileri
|
||||
npm i -g opencode-ai@latest # veya bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
|
||||
brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir)
|
||||
paru -S opencode-bin # Arch Linux
|
||||
mise use -g opencode # Tüm işletim sistemleri
|
||||
nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Kurulumdan önce 0.1.x'ten eski sürümleri kaldırın.
|
||||
|
||||
### Masaüstü Uygulaması (BETA)
|
||||
|
||||
OpenCode ayrıca masaüstü uygulaması olarak da mevcuttur. Doğrudan [sürüm sayfasından](https://github.com/anomalyco/opencode/releases) veya [opencode.ai/download](https://opencode.ai/download) adresinden indirebilirsiniz.
|
||||
|
||||
| Platform | İndirme |
|
||||
| --------------------- | ------------------------------------- |
|
||||
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
|
||||
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
|
||||
| Windows | `opencode-desktop-windows-x64.exe` |
|
||||
| Linux | `.deb`, `.rpm` veya AppImage |
|
||||
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install --cask opencode-desktop
|
||||
# Windows (Scoop)
|
||||
scoop bucket add extras; scoop install extras/opencode-desktop
|
||||
```
|
||||
|
||||
#### Kurulum Dizini (Installation Directory)
|
||||
|
||||
Kurulum betiği (install script), kurulum yolu (installation path) için aşağıdaki öncelik sırasını takip eder:
|
||||
|
||||
1. `$OPENCODE_INSTALL_DIR` - Özel kurulum dizini
|
||||
2. `$XDG_BIN_DIR` - XDG Base Directory Specification uyumlu yol
|
||||
3. `$HOME/bin` - Standart kullanıcı binary dizini (varsa veya oluşturulabiliyorsa)
|
||||
4. `$HOME/.opencode/bin` - Varsayılan yedek konum
|
||||
|
||||
```bash
|
||||
# Örnekler
|
||||
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
```
|
||||
|
||||
### Ajanlar
|
||||
|
||||
OpenCode, `Tab` tuşuyla aralarında geçiş yapabileceğiniz iki yerleşik (built-in) ajan içerir.
|
||||
|
||||
- **build** - Varsayılan, geliştirme çalışmaları için tam erişimli ajan
|
||||
- **plan** - Analiz ve kod keşfi için salt okunur ajan
|
||||
- Varsayılan olarak dosya düzenlemelerini reddeder
|
||||
- Bash komutlarını çalıştırmadan önce izin ister
|
||||
- Tanımadığınız kod tabanlarını keşfetmek veya değişiklikleri planlamak için ideal
|
||||
|
||||
Ayrıca, karmaşık aramalar ve çok adımlı görevler için bir **genel** alt ajan bulunmaktadır.
|
||||
Bu dahili olarak kullanılır ve mesajlarda `@general` ile çağrılabilir.
|
||||
|
||||
[Ajanlar](https://opencode.ai/docs/agents) hakkında daha fazla bilgi edinin.
|
||||
|
||||
### Dokümantasyon
|
||||
|
||||
OpenCode'u nasıl yapılandıracağınız hakkında daha fazla bilgi için [**dokümantasyonumuza göz atın**](https://opencode.ai/docs).
|
||||
|
||||
### Katkıda Bulunma
|
||||
|
||||
OpenCode'a katkıda bulunmak istiyorsanız, lütfen bir pull request göndermeden önce [katkıda bulunma dokümanlarımızı](./CONTRIBUTING.md) okuyun.
|
||||
|
||||
### OpenCode Üzerine Geliştirme
|
||||
|
||||
OpenCode ile ilgili bir proje üzerinde çalışıyorsanız ve projenizin adının bir parçası olarak "opencode" kullanıyorsanız (örneğin, "opencode-dashboard" veya "opencode-mobile"), lütfen README dosyanıza projenin OpenCode ekibi tarafından geliştirilmediğini ve bizimle hiçbir şekilde bağlantılı olmadığını belirten bir not ekleyin.
|
||||
|
||||
### SSS
|
||||
|
||||
#### Bu Claude Code'dan nasıl farklı?
|
||||
|
||||
Yetenekler açısından Claude Code'a çok benzer. İşte temel farklar:
|
||||
|
||||
- %100 açık kaynak
|
||||
- Herhangi bir sağlayıcıya bağlı değil. [OpenCode Zen](https://opencode.ai/zen) üzerinden sunduğumuz modelleri önermekle birlikte; OpenCode, Claude, OpenAI, Google veya hatta yerel modellerle kullanılabilir. Modeller geliştikçe aralarındaki farklar kapanacak ve fiyatlar düşecek, bu nedenle sağlayıcıdan bağımsız olmak önemlidir.
|
||||
- Kurulum gerektirmeyen hazır LSP desteği
|
||||
- TUI odaklı yaklaşım. OpenCode, neovim kullanıcıları ve [terminal.shop](https://terminal.shop)'un geliştiricileri tarafından geliştirilmektedir; terminalde olabileceklerin sınırlarını zorlayacağız.
|
||||
- İstemci/sunucu (client/server) mimarisi. Bu, örneğin OpenCode'un bilgisayarınızda çalışması ve siz onu bir mobil uygulamadan uzaktan yönetmenizi sağlar. TUI arayüzü olası istemcilerden sadece biridir.
|
||||
|
||||
---
|
||||
|
||||
**Topluluğumuza katılın** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a>
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
@@ -124,7 +126,7 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
||||
- 100% 開源。
|
||||
- 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。
|
||||
- 內建 LSP (語言伺服器協定) 支援。
|
||||
- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造;我們將不斷挑戰終端機介面的極限。
|
||||
- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造。我們將不斷挑戰終端機介面的極限。
|
||||
- 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。
|
||||
|
||||
---
|
||||
|
||||
116
bun.lock
116
bun.lock
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -73,7 +73,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -107,7 +107,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -134,7 +134,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -158,7 +158,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -182,7 +182,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -213,7 +213,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -242,7 +242,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -258,7 +258,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.48",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -266,25 +266,25 @@
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.13.0",
|
||||
"@ai-sdk/amazon-bedrock": "3.0.73",
|
||||
"@ai-sdk/anthropic": "2.0.57",
|
||||
"@ai-sdk/amazon-bedrock": "3.0.74",
|
||||
"@ai-sdk/anthropic": "2.0.58",
|
||||
"@ai-sdk/azure": "2.0.91",
|
||||
"@ai-sdk/cerebras": "1.0.34",
|
||||
"@ai-sdk/cerebras": "1.0.36",
|
||||
"@ai-sdk/cohere": "2.0.22",
|
||||
"@ai-sdk/deepinfra": "1.0.31",
|
||||
"@ai-sdk/gateway": "2.0.25",
|
||||
"@ai-sdk/deepinfra": "1.0.33",
|
||||
"@ai-sdk/gateway": "2.0.30",
|
||||
"@ai-sdk/google": "2.0.52",
|
||||
"@ai-sdk/google-vertex": "3.0.97",
|
||||
"@ai-sdk/google-vertex": "3.0.98",
|
||||
"@ai-sdk/groq": "2.0.34",
|
||||
"@ai-sdk/mistral": "2.0.27",
|
||||
"@ai-sdk/openai": "2.0.89",
|
||||
"@ai-sdk/openai-compatible": "1.0.30",
|
||||
"@ai-sdk/openai-compatible": "1.0.32",
|
||||
"@ai-sdk/perplexity": "2.0.23",
|
||||
"@ai-sdk/provider": "2.0.1",
|
||||
"@ai-sdk/provider-utils": "3.0.20",
|
||||
"@ai-sdk/togetherai": "1.0.31",
|
||||
"@ai-sdk/vercel": "1.0.31",
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@ai-sdk/togetherai": "1.0.34",
|
||||
"@ai-sdk/vercel": "1.0.33",
|
||||
"@ai-sdk/xai": "2.0.56",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.3.1",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
@@ -297,9 +297,9 @@
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||
"@opentui/core": "0.1.75",
|
||||
"@opentui/solid": "0.1.75",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.77",
|
||||
"@opentui/solid": "0.1.77",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -362,7 +362,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -382,7 +382,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.48",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -393,7 +393,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -406,7 +406,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -448,7 +448,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -459,7 +459,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -521,7 +521,7 @@
|
||||
"@types/node": "22.13.9",
|
||||
"@types/semver": "7.7.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"ai": "5.0.119",
|
||||
"ai": "5.0.124",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"fuzzysort": "3.1.0",
|
||||
@@ -559,23 +559,23 @@
|
||||
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.13.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.73", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EAAGJ/dfbAZaqIhK3w52hq6cftSLZwXdC6uHKh8Cls1T0N4MxS6ykDf54UyFO3bZWkQxR+Mdw1B3qireGOxtJQ=="],
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="],
|
||||
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
|
||||
|
||||
"@ai-sdk/azure": ["@ai-sdk/azure@2.0.91", "", { "dependencies": { "@ai-sdk/openai": "2.0.89", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9tznVSs6LGQNKKxb8pKd7CkBV9yk+a/ENpFicHCj2CmBUKefxzwJ9JbUqrlK3VF6dGZw3LXq0dWxt7/Yekaj1w=="],
|
||||
|
||||
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XOK0dJsAGoPYi/lfR4KFBi8xhvJ46oCpAxUD6FmJAuJ4eh0qlj5zDt+myvzM8gvN7S6K7zHD+mdWlOPKGQT8Vg=="],
|
||||
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zoJYL33+ieyd86FSP0Whm86D79d1lKPR7wUzh1SZ1oTxwYmsGyvIrmMf2Ll0JA9Ds2Es6qik4VaFCrjwGYRTIQ=="],
|
||||
|
||||
"@ai-sdk/cohere": ["@ai-sdk/cohere@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yJ9kP5cEDJwo8qpITq5TQFD8YNfNtW+HbyvWwrKMbFzmiMvIZuk95HIaFXE7PCTuZsqMA05yYu+qX/vQ3rNKjA=="],
|
||||
|
||||
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-87qFcYNvDF/89hB//MQjYTb3tlsAfmgeZrZ34RESeBTZpSgs0EzYOMqPMwFTHUNp4wteoifikDJbaS/9Da8cfw=="],
|
||||
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="],
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.25", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rq+FX55ne7lMiqai7NcvvDZj4HLsr+hg77WayqmySqc6zhw3tIOLxd4Ty6OpwNj0C0bVMi3iCl2zvJIEirh9XA=="],
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5Nrkj8B4MzkkOfjjA+Cs5pamkbkK4lI11bx80QV7TFcen/hWA8wEC+UVzwuM5H2zpekoNMjvl6GonHnR62XIZw=="],
|
||||
|
||||
"@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="],
|
||||
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.97", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-s4tI7Z15i6FlbtCvS4SBRal8wRfkOXJzKxlS6cU4mJW/QfUfoVy4b22836NVNJwDvkG/HkDSfzwm/X8mn46MhA=="],
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.98", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uuv0RHkdJ5vTzeH1+iuBlv7GAjRcOPd2jiqtGLz6IKOUDH+PRQoE3ExrvOysVnKuhhTBMqvawkktDhMDQE6sVQ=="],
|
||||
|
||||
"@ai-sdk/groq": ["@ai-sdk/groq@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wfCYkVgmVjxNA32T57KbLabVnv9aFUflJ4urJ7eWgTwbnmGQHElCTu+rJ3ydxkXSqxOkXPwMOttDm7XNrvPjmg=="],
|
||||
|
||||
@@ -591,11 +591,11 @@
|
||||
|
||||
"@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-sdk/togetherai": ["@ai-sdk/togetherai@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RlYubjStoZQxna4Ng91Vvo8YskvL7lW9zj68IwZfCnaDBSAp1u6Nhc5BR4ZtKnY6PA3XEtu4bATIQl7yiiQ+Lw=="],
|
||||
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jjJmJms6kdEc4nC3MDGFJfhV8F1ifY4nolV2dbnT7BM4ab+Wkskc0GwCsJ7G7WdRMk7xDbFh4he3DPL8KJ/cyA=="],
|
||||
|
||||
"@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ggvwAMt/KsbqcdR6ILQrjwrRONLV/8aG6rOLbjcOGvV0Ai+WdZRRKQj5nOeQ06PvwVQtKdkp7S4IinpXIhCiHg=="],
|
||||
"@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Qwjm+HdwKasu7L9bDUryBMGKDMscIEzMUkjw/33uGdJpktzyNW13YaNIObOZ2HkskqDMIQJSd4Ao2BBT8fEYLw=="],
|
||||
|
||||
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.51", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AI3le03qiegkZvn9hpnpDwez49lOvQLj4QUBT8H41SMbrdTYOxn3ktTwrsSu90cNDdzKGMvoH0u2GHju1EdnCg=="],
|
||||
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.56", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FGlqwWc3tAYqDHE8r8hQGQLcMiPUwgz90oU2QygUH930OWtCLapFkSu114DgVaIN/qoM1DUX+inv0Ee74Fgp5g=="],
|
||||
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
@@ -1221,27 +1221,27 @@
|
||||
|
||||
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
||||
|
||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.2", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "@toon-format/toon": "^2.0.0", "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" }, "optionalPeers": ["@toon-format/toon"] }, "sha512-3Th0vmJ9pjnwcPc2H1f59Mb0LFvwaREZAScfOQIpUxAHjZ7ZawVKDP27qgsteZPmMYqccNMy4r4Y3kgUnNcKAg=="],
|
||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.4", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw=="],
|
||||
|
||||
"@openrouter/sdk": ["@openrouter/sdk@0.1.27", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.1.75", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.75", "@opentui/core-darwin-x64": "0.1.75", "@opentui/core-linux-arm64": "0.1.75", "@opentui/core-linux-x64": "0.1.75", "@opentui/core-win32-arm64": "0.1.75", "@opentui/core-win32-x64": "0.1.75", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8ARRZxSG+BXkJmEVtM2DQ4se7DAF1ZCKD07d+AklgTr2mxCzmdxxPbOwRzboSQ6FM7qGuTVPVbV4O2W9DpUmoA=="],
|
||||
"@opentui/core": ["@opentui/core@0.1.77", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.77", "@opentui/core-darwin-x64": "0.1.77", "@opentui/core-linux-arm64": "0.1.77", "@opentui/core-linux-x64": "0.1.77", "@opentui/core-win32-arm64": "0.1.77", "@opentui/core-win32-x64": "0.1.77", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-lE3kabm6jdqK3AuBq+O0zZrXdxt6ulmibTc57sf+AsPny6cmwYHnWI4wD6hcreFiYoQVNVvdiJchVgPtowMlEg=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.75", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gGaGZjkFpqcXJk6321JzhRl66pM2VxBlI470L8W4DQUW4S6iDT1R9L7awSzGB4Cn9toUl7DTV8BemaXZYXV4SA=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.77", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SNqmygCMEsPCW7xWjzCZ5caBf36xaprwVdAnFijGDOuIzLA4iaDa6um8cj3TJh7awenN3NTRsuRc7OuH42UH+g=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.75", "", { "os": "darwin", "cpu": "x64" }, "sha512-tPlvqQI0whZ76amHydpJs5kN+QeWAIcFbI8RAtlAo9baj2EbxTDC+JGwgb9Fnt0/YQx831humbtaNDhV2Jt1bw=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.77", "", { "os": "darwin", "cpu": "x64" }, "sha512-/8fsa03swEHTQt/9NrGm98kemlU+VuTURI/OFZiH53vPDRrOYIYoa4Jyga/H7ZMcG+iFhkq97zIe+0Kw95LGmA=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.75", "", { "os": "linux", "cpu": "arm64" }, "sha512-nVxIQ4Hqf84uBergDpWiVzU6pzpjy6tqBHRQpySxZ2flkJ/U6/aMEizVrQ1jcgIdxZtvqWDETZhzxhG0yDx+cw=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.77", "", { "os": "linux", "cpu": "arm64" }, "sha512-QfUXZJPc69OvqoMu+AlLgjqXrwu4IeqcBuUWYMuH8nPTeLsVUc3CBbXdV2lv9UDxWzxzrxdS4ALPaxvmEv9lsQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.75", "", { "os": "linux", "cpu": "x64" }, "sha512-1CnApef4kxA+ORyLfbuCLgZfEjp4wr3HjFnt7FAfOb73kIZH82cb7JYixeqRyy9eOcKfKqxLmBYy3o8IDkc4Rg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.77", "", { "os": "linux", "cpu": "x64" }, "sha512-Kmfx0yUKnPj67AoXYIgL7qQo0QVsUG5Iw8aRtv6XFzXqa5SzBPhaKkKZ9yHPjOmTalZquUs+9zcCRNKpYYuL7A=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.75", "", { "os": "win32", "cpu": "arm64" }, "sha512-j0UB95nmkYGNzmOrs6GqaddO1S90R0YC6IhbKnbKBdjchFPNVLz9JpexAs6MBDXPZwdKAywMxtwG2h3aTJtxng=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.77", "", { "os": "win32", "cpu": "arm64" }, "sha512-HGTscPXc7gdd23Nh1DbzUNjog1I+5IZp95XPtLftGTpjrWs60VcetXcyJqK2rQcXNxewJK5yDyaa5QyMjfEhCQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.75", "", { "os": "win32", "cpu": "x64" }, "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.77", "", { "os": "win32", "cpu": "x64" }, "sha512-c7GijsbvVgnlzd2murIbwuwrGbcv76KdUw6WlVv7a0vex50z6xJCpv1keGzpe0QfxrZ/6fFEFX7JnwGLno0wjA=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.1.75", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.75", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-WjKsZIfrm29znfRlcD9w3uUn/+uvoy2MmeoDwTvg1YOa0OjCTCmjZ43L9imp0m9S4HmVU8ma6o2bR4COzcyDdg=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.1.77", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.77", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-JY+hUbXVV+XCk6bC8dvcwawWCEmC3Gid6GDs23AJWBgHZ3TU2kRKrgwTdltm45DOq2cZXrYCt690/yE8bP+Gxg=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -1909,7 +1909,7 @@
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="],
|
||||
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
@@ -1947,7 +1947,7 @@
|
||||
|
||||
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||
|
||||
"ai": ["ai@5.0.119", "", { "dependencies": { "@ai-sdk/gateway": "2.0.25", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HUOwhc17fl2SZTJGZyA/99aNu706qKfXaUBCy9vgZiXBwrxg2eTzn2BCz7kmYDsfx6Fg2ACBy2icm41bsDXCTw=="],
|
||||
"ai": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -3975,7 +3975,9 @@
|
||||
|
||||
"@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
|
||||
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
||||
|
||||
@@ -3983,11 +3985,11 @@
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
|
||||
|
||||
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
|
||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
||||
|
||||
@@ -3997,11 +3999,11 @@
|
||||
|
||||
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
|
||||
|
||||
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
|
||||
@@ -4381,11 +4383,11 @@
|
||||
|
||||
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
|
||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
|
||||
|
||||
|
||||
23
flake.nix
23
flake.nix
@@ -42,28 +42,15 @@
|
||||
desktop = pkgs.callPackage ./nix/desktop.nix {
|
||||
inherit opencode;
|
||||
};
|
||||
# nixpkgs cpu naming to bun cpu naming
|
||||
cpuMap = { x86_64 = "x64"; aarch64 = "arm64"; };
|
||||
# matrix of node_modules builds - these will always fail due to fakeHash usage
|
||||
# but allow computation of the correct hash from any build machine for any cpu/os
|
||||
# see the update-nix-hashes workflow for usage
|
||||
moduleUpdaters = pkgs.lib.listToAttrs (
|
||||
pkgs.lib.concatMap (cpu:
|
||||
map (os: {
|
||||
name = "${cpu}-${os}_node_modules";
|
||||
value = node_modules.override {
|
||||
bunCpu = cpuMap.${cpu};
|
||||
bunOs = os;
|
||||
hash = pkgs.lib.fakeHash;
|
||||
};
|
||||
}) [ "linux" "darwin" ]
|
||||
) [ "x86_64" "aarch64" ]
|
||||
);
|
||||
in
|
||||
{
|
||||
default = opencode;
|
||||
inherit opencode desktop;
|
||||
} // moduleUpdaters
|
||||
# Updater derivation with fakeHash - build fails and reveals correct hash
|
||||
node_modules_updater = node_modules.override {
|
||||
hash = pkgs.lib.fakeHash;
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,8 +45,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
|
||||
rustc
|
||||
jq
|
||||
makeWrapper
|
||||
]
|
||||
++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
|
||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
|
||||
|
||||
buildInputs = lib.optionals stdenv.isLinux [
|
||||
dbus
|
||||
@@ -61,6 +60,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
|
||||
gst_all_1.gstreamer
|
||||
gst_all_1.gst-plugins-base
|
||||
gst_all_1.gst-plugins-good
|
||||
gst_all_1.gst-plugins-bad
|
||||
];
|
||||
|
||||
strictDeps = true;
|
||||
@@ -97,4 +97,4 @@ rustPlatform.buildRustPackage (finalAttrs: {
|
||||
mainProgram = "opencode-desktop";
|
||||
inherit (opencode.meta) platforms;
|
||||
};
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-gUWzUsk81miIrjg0fZQmsIQG4pZYmEHgzN6BaXI+lfc=",
|
||||
"aarch64-linux": "sha256-gwEG75ha/ojTO2iAObTmLTtEkXIXJ7BThzfI5CqlJh8=",
|
||||
"aarch64-darwin": "sha256-20RGG2GkUItCzD67gDdoSLfexttM8abS//FKO9bfjoM=",
|
||||
"x86_64-darwin": "sha256-i2VawFuR1UbjPVYoybU6aJDJfFo0tcvtl1aM31Y2mTQ="
|
||||
"x86_64-linux": "sha256-4I0lpBnbAi7IZMURTMLysjrqdsNvXJf8802NrJnpdks=",
|
||||
"aarch64-linux": "sha256-WOGKsPlcQVSbL8TDr1JYO/2ucPTV2Hy0TXJKWv8EoVw=",
|
||||
"aarch64-darwin": "sha256-LuvjwGm1QsHoLxuvSSp4VsDIv02Z/rTONsU32arQMuw=",
|
||||
"x86_64-darwin": "sha256-AbglfgCWj/r+wHfle+e+D3b/xPcwwg4IK7j5iwn9nzw="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
lib,
|
||||
stdenvNoCC,
|
||||
bun,
|
||||
bunCpu ? if stdenvNoCC.hostPlatform.isAarch64 then "arm64" else "x64",
|
||||
bunOs ? if stdenvNoCC.hostPlatform.isLinux then "linux" else "darwin",
|
||||
rev ? "dirty",
|
||||
hash ?
|
||||
(lib.pipe ./hashes.json [
|
||||
@@ -16,6 +14,9 @@ let
|
||||
builtins.readFile
|
||||
builtins.fromJSON
|
||||
];
|
||||
platform = stdenvNoCC.hostPlatform;
|
||||
bunCpu = if platform.isAarch64 then "arm64" else "x64";
|
||||
bunOs = if platform.isLinux then "linux" else "darwin";
|
||||
in
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "opencode-node_modules";
|
||||
@@ -39,23 +40,22 @@ stdenvNoCC.mkDerivation {
|
||||
"SOCKS_SERVER"
|
||||
];
|
||||
|
||||
nativeBuildInputs = [
|
||||
bun
|
||||
];
|
||||
nativeBuildInputs = [ bun ];
|
||||
|
||||
dontConfigure = true;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
export HOME=$(mktemp -d)
|
||||
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
|
||||
bun install \
|
||||
--cpu="${bunCpu}" \
|
||||
--os="${bunOs}" \
|
||||
--filter '!./' \
|
||||
--filter './packages/opencode' \
|
||||
--filter './packages/desktop' \
|
||||
--frozen-lockfile \
|
||||
--ignore-scripts \
|
||||
--no-progress \
|
||||
--linker=isolated
|
||||
--no-progress
|
||||
bun --bun ${./scripts/canonicalize-node-modules.ts}
|
||||
bun --bun ${./scripts/normalize-bun-binaries.ts}
|
||||
runHook postBuild
|
||||
@@ -63,10 +63,8 @@ stdenvNoCC.mkDerivation {
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out
|
||||
find . -type d -name node_modules -exec cp -R --parents {} $out \;
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"ai": "5.0.119",
|
||||
"ai": "5.0.124",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
"fuzzysort": "3.1.0",
|
||||
|
||||
176
packages/app/e2e/AGENTS.md
Normal file
176
packages/app/e2e/AGENTS.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# E2E Testing Guide
|
||||
|
||||
## Build/Lint/Test Commands
|
||||
|
||||
```bash
|
||||
# Run all e2e tests
|
||||
bun test:e2e
|
||||
|
||||
# Run specific test file
|
||||
bun test:e2e -- app/home.spec.ts
|
||||
|
||||
# Run single test by title
|
||||
bun test:e2e -- -g "home renders and shows core entrypoints"
|
||||
|
||||
# Run tests with UI mode (for debugging)
|
||||
bun test:e2e:ui
|
||||
|
||||
# Run tests locally with full server setup
|
||||
bun test:e2e:local
|
||||
|
||||
# View test report
|
||||
bun test:e2e:report
|
||||
|
||||
# Typecheck
|
||||
bun typecheck
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
All tests live in `packages/app/e2e/`:
|
||||
|
||||
```
|
||||
e2e/
|
||||
├── fixtures.ts # Test fixtures (test, expect, gotoSession, sdk)
|
||||
├── actions.ts # Reusable action helpers
|
||||
├── selectors.ts # DOM selectors
|
||||
├── utils.ts # Utilities (serverUrl, modKey, path helpers)
|
||||
└── [feature]/
|
||||
└── *.spec.ts # Test files
|
||||
```
|
||||
|
||||
## Test Patterns
|
||||
|
||||
### Basic Test Structure
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { withSession } from "../actions"
|
||||
|
||||
test("test description", async ({ page, sdk, gotoSession }) => {
|
||||
await gotoSession() // or gotoSession(sessionID)
|
||||
|
||||
// Your test code
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
```
|
||||
|
||||
### Using Fixtures
|
||||
|
||||
- `page` - Playwright page
|
||||
- `sdk` - OpenCode SDK client for API calls
|
||||
- `gotoSession(sessionID?)` - Navigate to session
|
||||
|
||||
### Helper Functions
|
||||
|
||||
**Actions** (`actions.ts`):
|
||||
|
||||
- `openPalette(page)` - Open command palette
|
||||
- `openSettings(page)` - Open settings dialog
|
||||
- `closeDialog(page, dialog)` - Close any dialog
|
||||
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
||||
- `withSession(sdk, title, callback)` - Create temp session
|
||||
- `clickListItem(container, filter)` - Click list item by key/text
|
||||
|
||||
**Selectors** (`selectors.ts`):
|
||||
|
||||
- `promptSelector` - Prompt input
|
||||
- `terminalSelector` - Terminal panel
|
||||
- `sessionItemSelector(id)` - Session in sidebar
|
||||
- `listItemSelector` - Generic list items
|
||||
|
||||
**Utils** (`utils.ts`):
|
||||
|
||||
- `modKey` - Meta (Mac) or Control (Linux/Win)
|
||||
- `serverUrl` - Backend server URL
|
||||
- `sessionPath(dir, id?)` - Build session URL
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Imports
|
||||
|
||||
Always import from `../fixtures`, not `@playwright/test`:
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
import { test, expect } from "../fixtures"
|
||||
|
||||
// ❌ Bad
|
||||
import { test, expect } from "@playwright/test"
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- Test files: `feature-name.spec.ts`
|
||||
- Test names: lowercase, descriptive: `"sidebar can be toggled"`
|
||||
- Variables: camelCase
|
||||
- Constants: SCREAMING_SNAKE_CASE
|
||||
|
||||
### Error Handling
|
||||
|
||||
Tests should clean up after themselves:
|
||||
|
||||
```typescript
|
||||
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
||||
await withSession(sdk, "test session", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
// Test code...
|
||||
}) // Auto-deletes session
|
||||
})
|
||||
```
|
||||
|
||||
### Timeouts
|
||||
|
||||
Default: 60s per test, 10s per assertion. Override when needed:
|
||||
|
||||
```typescript
|
||||
test.setTimeout(120_000) // For long LLM operations
|
||||
test("slow test", async () => {
|
||||
await expect.poll(() => check(), { timeout: 90_000 }).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
### Selectors
|
||||
|
||||
Use `data-component`, `data-action`, or semantic roles:
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
await page.locator('[data-component="prompt-input"]').click()
|
||||
await page.getByRole("button", { name: "Open settings" }).click()
|
||||
|
||||
// ❌ Bad
|
||||
await page.locator(".css-class-name").click()
|
||||
await page.locator("#id-name").click()
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
Use `modKey` for cross-platform compatibility:
|
||||
|
||||
```typescript
|
||||
import { modKey } from "../utils"
|
||||
|
||||
await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
|
||||
await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||
```
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
1. Choose appropriate folder or create new one
|
||||
2. Import from `../fixtures`
|
||||
3. Use helper functions from `../actions` and `../selectors`
|
||||
4. Clean up any created resources
|
||||
5. Use specific selectors (avoid CSS classes)
|
||||
6. Test one feature per test file
|
||||
|
||||
## Local Development
|
||||
|
||||
For UI debugging, use:
|
||||
|
||||
```bash
|
||||
bun test:e2e:ui
|
||||
```
|
||||
|
||||
This opens Playwright's interactive UI for step-through debugging.
|
||||
363
packages/app/e2e/actions.ts
Normal file
363
packages/app/e2e/actions.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { execSync } from "node:child_process"
|
||||
import { modKey, serverUrl } from "./utils"
|
||||
import {
|
||||
sessionItemSelector,
|
||||
dropdownMenuTriggerSelector,
|
||||
dropdownMenuContentSelector,
|
||||
projectMenuTriggerSelector,
|
||||
projectWorkspacesToggleSelector,
|
||||
titlebarRightSelector,
|
||||
popoverBodySelector,
|
||||
listItemSelector,
|
||||
listItemKeySelector,
|
||||
listItemKeyStartsWithSelector,
|
||||
workspaceItemSelector,
|
||||
workspaceMenuTriggerSelector,
|
||||
} from "./selectors"
|
||||
import type { createSdk } from "./utils"
|
||||
|
||||
export async function defocus(page: Page) {
|
||||
await page.mouse.click(5, 5)
|
||||
}
|
||||
|
||||
export async function openPalette(page: Page) {
|
||||
await defocus(page)
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole("textbox").first()).toBeVisible()
|
||||
return dialog
|
||||
}
|
||||
|
||||
export async function closeDialog(page: Page, dialog: Locator) {
|
||||
await page.keyboard.press("Escape")
|
||||
const closed = await dialog
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (closed) return
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
const closedSecond = await dialog
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (closedSecond) return
|
||||
|
||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
||||
await expect(dialog).toHaveCount(0)
|
||||
}
|
||||
|
||||
export async function isSidebarClosed(page: Page) {
|
||||
const main = page.locator("main")
|
||||
const classes = (await main.getAttribute("class")) ?? ""
|
||||
return classes.includes("xl:border-l")
|
||||
}
|
||||
|
||||
export async function toggleSidebar(page: Page) {
|
||||
await defocus(page)
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
}
|
||||
|
||||
export async function openSidebar(page: Page) {
|
||||
if (!(await isSidebarClosed(page))) return
|
||||
await toggleSidebar(page)
|
||||
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
||||
}
|
||||
|
||||
export async function closeSidebar(page: Page) {
|
||||
if (await isSidebarClosed(page)) return
|
||||
await toggleSidebar(page)
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
}
|
||||
|
||||
export async function openSettings(page: Page) {
|
||||
await defocus(page)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
||||
|
||||
const opened = await dialog
|
||||
.waitFor({ state: "visible", timeout: 3000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (opened) return dialog
|
||||
|
||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||
await expect(dialog).toBeVisible()
|
||||
return dialog
|
||||
}
|
||||
|
||||
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
|
||||
await page.addInitScript(
|
||||
(args: { directory: string; serverUrl: string; extra: string[] }) => {
|
||||
const key = "opencode.global.dat:server"
|
||||
const raw = localStorage.getItem(key)
|
||||
const parsed = (() => {
|
||||
if (!raw) return undefined
|
||||
try {
|
||||
return JSON.parse(raw) as unknown
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
|
||||
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
||||
const list = Array.isArray(store.list) ? store.list : []
|
||||
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
||||
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
||||
const nextProjects = { ...(projects as Record<string, unknown>) }
|
||||
|
||||
const add = (origin: string, directory: string) => {
|
||||
const current = nextProjects[origin]
|
||||
const items = Array.isArray(current) ? current : []
|
||||
const existing = items.filter(
|
||||
(p): p is { worktree: string; expanded?: boolean } =>
|
||||
!!p &&
|
||||
typeof p === "object" &&
|
||||
"worktree" in p &&
|
||||
typeof (p as { worktree?: unknown }).worktree === "string",
|
||||
)
|
||||
|
||||
if (existing.some((p) => p.worktree === directory)) return
|
||||
nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
|
||||
}
|
||||
|
||||
const directories = [args.directory, ...args.extra]
|
||||
for (const directory of directories) {
|
||||
add("local", directory)
|
||||
add(args.serverUrl, directory)
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
list,
|
||||
projects: nextProjects,
|
||||
lastProject,
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
|
||||
)
|
||||
}
|
||||
|
||||
export async function createTestProject() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||
|
||||
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
||||
|
||||
execSync("git init", { cwd: root, stdio: "ignore" })
|
||||
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
||||
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||
cwd: root,
|
||||
stdio: "ignore",
|
||||
})
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
export async function cleanupTestProject(directory: string) {
|
||||
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
|
||||
export function sessionIDFromUrl(url: string) {
|
||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||
return match?.[1]
|
||||
}
|
||||
|
||||
export async function hoverSessionItem(page: Page, sessionID: string) {
|
||||
const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
|
||||
await expect(sessionEl).toBeVisible()
|
||||
await sessionEl.hover()
|
||||
return sessionEl
|
||||
}
|
||||
|
||||
export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||
const sessionEl = await hoverSessionItem(page, sessionID)
|
||||
|
||||
const menuTrigger = sessionEl.locator(dropdownMenuTriggerSelector).first()
|
||||
await expect(menuTrigger).toBeVisible()
|
||||
await menuTrigger.click()
|
||||
|
||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||
await expect(menu).toBeVisible()
|
||||
return menu
|
||||
}
|
||||
|
||||
export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) {
|
||||
const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first()
|
||||
await expect(item).toBeVisible()
|
||||
await item.click({ force: options?.force })
|
||||
}
|
||||
|
||||
export async function confirmDialog(page: Page, buttonName: string | RegExp) {
|
||||
const dialog = page.getByRole("dialog").first()
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const button = dialog.getByRole("button").filter({ hasText: buttonName }).first()
|
||||
await expect(button).toBeVisible()
|
||||
await button.click()
|
||||
}
|
||||
|
||||
export async function openSharePopover(page: Page) {
|
||||
const rightSection = page.locator(titlebarRightSelector)
|
||||
const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
|
||||
await expect(shareButton).toBeVisible()
|
||||
|
||||
const popoverBody = page
|
||||
.locator(popoverBodySelector)
|
||||
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
|
||||
.first()
|
||||
|
||||
const opened = await popoverBody
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
|
||||
if (!opened) {
|
||||
await shareButton.click()
|
||||
await expect(popoverBody).toBeVisible()
|
||||
}
|
||||
return { rightSection, popoverBody }
|
||||
}
|
||||
|
||||
export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
|
||||
const button = page.getByRole("button").filter({ hasText: buttonName }).first()
|
||||
await expect(button).toBeVisible()
|
||||
await button.click()
|
||||
}
|
||||
|
||||
export async function clickListItem(
|
||||
container: Locator | Page,
|
||||
filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string },
|
||||
): Promise<Locator> {
|
||||
let item: Locator
|
||||
|
||||
if (typeof filter === "string" || filter instanceof RegExp) {
|
||||
item = container.locator(listItemSelector).filter({ hasText: filter }).first()
|
||||
} else if (filter.keyStartsWith) {
|
||||
item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first()
|
||||
} else if (filter.key) {
|
||||
item = container.locator(listItemKeySelector(filter.key)).first()
|
||||
} else if (filter.text) {
|
||||
item = container.locator(listItemSelector).filter({ hasText: filter.text }).first()
|
||||
} else {
|
||||
throw new Error("Invalid filter provided to clickListItem")
|
||||
}
|
||||
|
||||
await expect(item).toBeVisible()
|
||||
await item.click()
|
||||
return item
|
||||
}
|
||||
|
||||
export async function withSession<T>(
|
||||
sdk: ReturnType<typeof createSdk>,
|
||||
title: string,
|
||||
callback: (session: { id: string; title: string }) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const session = await sdk.session.create({ title }).then((r) => r.data)
|
||||
if (!session?.id) throw new Error("Session create did not return an id")
|
||||
|
||||
try {
|
||||
return await callback(session)
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
export async function openStatusPopover(page: Page) {
|
||||
await defocus(page)
|
||||
|
||||
const rightSection = page.locator(titlebarRightSelector)
|
||||
const trigger = rightSection.getByRole("button", { name: /status/i }).first()
|
||||
|
||||
const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') })
|
||||
|
||||
const opened = await popoverBody
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
|
||||
if (!opened) {
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(popoverBody).toBeVisible()
|
||||
}
|
||||
|
||||
return { rightSection, popoverBody }
|
||||
}
|
||||
|
||||
export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
||||
await expect(trigger).toHaveCount(1)
|
||||
|
||||
await trigger.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||
const opened = await menu
|
||||
.waitFor({ state: "visible", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (opened) {
|
||||
const viewport = page.viewportSize()
|
||||
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
||||
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
||||
await page.mouse.move(x, y)
|
||||
return menu
|
||||
}
|
||||
|
||||
await trigger.click({ force: true })
|
||||
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const viewport = page.viewportSize()
|
||||
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
||||
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
||||
await page.mouse.move(x, y)
|
||||
return menu
|
||||
}
|
||||
|
||||
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
||||
const current = await page
|
||||
.getByRole("button", { name: "New workspace" })
|
||||
.first()
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
|
||||
if (current === enabled) return
|
||||
|
||||
await openProjectMenu(page, projectSlug)
|
||||
|
||||
const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||
await expect(toggle).toBeVisible()
|
||||
await toggle.click({ force: true })
|
||||
|
||||
const expected = enabled ? "New workspace" : "New session"
|
||||
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
||||
}
|
||||
|
||||
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
||||
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
||||
await expect(item).toBeVisible()
|
||||
await item.hover()
|
||||
|
||||
const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click({ force: true })
|
||||
|
||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||
await expect(menu).toBeVisible()
|
||||
return menu
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { dirPath, promptSelector } from "../utils"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { dirPath } from "../utils"
|
||||
|
||||
test("project route redirects to /session", async ({ page, directory, slug }) => {
|
||||
await page.goto(dirPath(directory))
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey } from "../utils"
|
||||
import { openPalette } from "../actions"
|
||||
|
||||
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole("textbox").first()).toBeVisible()
|
||||
const dialog = await openPalette(page)
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { serverName, serverUrl } from "../utils"
|
||||
import { clickListItem, closeDialog, clickMenuItem } from "../actions"
|
||||
|
||||
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
||||
|
||||
@@ -33,31 +34,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
|
||||
const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
const menu = row.locator('[data-component="icon-button"]').last()
|
||||
await menu.click()
|
||||
await page.getByRole("menuitem", { name: "Set as default" }).click()
|
||||
const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first()
|
||||
await expect(menuTrigger).toBeVisible()
|
||||
await menuTrigger.click({ force: true })
|
||||
|
||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||
await expect(menu).toBeVisible()
|
||||
await clickMenuItem(menu, /set as default/i)
|
||||
|
||||
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
|
||||
await expect(row.getByText("Default", { exact: true })).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
const closed = await dialog
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (!closed) {
|
||||
await page.keyboard.press("Escape")
|
||||
const closedSecond = await dialog
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (!closedSecond) {
|
||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
||||
await expect(dialog).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
await ensurePopoverOpen()
|
||||
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../utils"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { withSession } from "../actions"
|
||||
|
||||
test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
|
||||
const title = `e2e smoke ${Date.now()}`
|
||||
const created = await sdk.session.create({ title }).then((r) => r.data)
|
||||
|
||||
if (!created?.id) throw new Error("Session create did not return an id")
|
||||
const sessionID = created.id
|
||||
|
||||
try {
|
||||
await gotoSession(sessionID)
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await prompt.click()
|
||||
await page.keyboard.type("hello from e2e")
|
||||
await expect(prompt).toContainText("hello from e2e")
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,52 +1,42 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey, promptSelector } from "../utils"
|
||||
import { openSidebar, withSession } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const stamp = Date.now()
|
||||
const one = await sdk.session.create({ title: `e2e titlebar history 1 ${stamp}` }).then((r) => r.data)
|
||||
const two = await sdk.session.create({ title: `e2e titlebar history 2 ${stamp}` }).then((r) => r.data)
|
||||
|
||||
if (!one?.id) throw new Error("Session create did not return an id")
|
||||
if (!two?.id) throw new Error("Session create did not return an id")
|
||||
await withSession(sdk, `e2e titlebar history 1 ${stamp}`, async (one) => {
|
||||
await withSession(sdk, `e2e titlebar history 2 ${stamp}`, async (two) => {
|
||||
await gotoSession(one.id)
|
||||
|
||||
try {
|
||||
await gotoSession(one.id)
|
||||
await openSidebar(page)
|
||||
|
||||
const main = page.locator("main")
|
||||
const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l")
|
||||
if (collapsed) {
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
await expect(main).not.toHaveClass(/xl:border-l/)
|
||||
}
|
||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||
await expect(link).toBeVisible()
|
||||
await link.scrollIntoViewIfNeeded()
|
||||
await link.click()
|
||||
|
||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||
await expect(link).toBeVisible()
|
||||
await link.scrollIntoViewIfNeeded()
|
||||
await link.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
const back = page.getByRole("button", { name: "Back" })
|
||||
const forward = page.getByRole("button", { name: "Forward" })
|
||||
|
||||
const back = page.getByRole("button", { name: "Back" })
|
||||
const forward = page.getByRole("button", { name: "Forward" })
|
||||
await expect(back).toBeVisible()
|
||||
await expect(back).toBeEnabled()
|
||||
await back.click()
|
||||
|
||||
await expect(back).toBeVisible()
|
||||
await expect(back).toBeEnabled()
|
||||
await back.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expect(forward).toBeVisible()
|
||||
await expect(forward).toBeEnabled()
|
||||
await forward.click()
|
||||
|
||||
await expect(forward).toBeVisible()
|
||||
await expect(forward).toBeEnabled()
|
||||
await forward.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
||||
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
||||
}
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey } from "../utils"
|
||||
import { openPalette, clickListItem } from "../actions"
|
||||
|
||||
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
const dialog = await openPalette(page)
|
||||
|
||||
const input = dialog.getByRole("textbox").first()
|
||||
await input.fill("package.json")
|
||||
|
||||
const fileItem = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
||||
await expect(fileItem).toBeVisible()
|
||||
await fileItem.click()
|
||||
await clickListItem(dialog, { keyStartsWith: "file:" })
|
||||
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey } from "../utils"
|
||||
import { openPalette, clickListItem } from "../actions"
|
||||
|
||||
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
@@ -7,21 +7,12 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
|
||||
const sep = process.platform === "win32" ? "\\" : "/"
|
||||
const file = ["packages", "app", "package.json"].join(sep)
|
||||
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
const dialog = await openPalette(page)
|
||||
|
||||
const input = dialog.getByRole("textbox").first()
|
||||
await input.fill(file)
|
||||
|
||||
const fileItem = dialog
|
||||
.locator(
|
||||
'[data-slot="list-item"][data-key^="file:"][data-key*="packages"][data-key*="app"][data-key$="package.json"]',
|
||||
)
|
||||
.first()
|
||||
await expect(fileItem).toBeVisible()
|
||||
await fileItem.click()
|
||||
await clickListItem(dialog, { text: /packages.*app.*package.json/ })
|
||||
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { test as base, expect } from "@playwright/test"
|
||||
import { createSdk, dirSlug, getWorktree, promptSelector, serverUrl, sessionPath } from "./utils"
|
||||
import { seedProjects } from "./actions"
|
||||
import { promptSelector } from "./selectors"
|
||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||
|
||||
export const settingsKey = "settings.v3"
|
||||
|
||||
type TestFixtures = {
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
@@ -29,54 +33,17 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
await use(createSdk(directory))
|
||||
},
|
||||
gotoSession: async ({ page, directory }, use) => {
|
||||
await page.addInitScript(
|
||||
(input: { directory: string; serverUrl: string }) => {
|
||||
const key = "opencode.global.dat:server"
|
||||
const raw = localStorage.getItem(key)
|
||||
const parsed = (() => {
|
||||
if (!raw) return undefined
|
||||
try {
|
||||
return JSON.parse(raw) as unknown
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
|
||||
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
||||
const list = Array.isArray(store.list) ? store.list : []
|
||||
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
||||
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
||||
const nextProjects = { ...(projects as Record<string, unknown>) }
|
||||
|
||||
const add = (origin: string) => {
|
||||
const current = nextProjects[origin]
|
||||
const items = Array.isArray(current) ? current : []
|
||||
const existing = items.filter(
|
||||
(p): p is { worktree: string; expanded?: boolean } =>
|
||||
!!p &&
|
||||
typeof p === "object" &&
|
||||
"worktree" in p &&
|
||||
typeof (p as { worktree?: unknown }).worktree === "string",
|
||||
)
|
||||
|
||||
if (existing.some((p) => p.worktree === input.directory)) return
|
||||
nextProjects[origin] = [{ worktree: input.directory, expanded: true }, ...existing]
|
||||
}
|
||||
|
||||
add("local")
|
||||
add(input.serverUrl)
|
||||
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
list,
|
||||
projects: nextProjects,
|
||||
lastProject,
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ directory, serverUrl },
|
||||
)
|
||||
await seedProjects(page, { directory })
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:model",
|
||||
JSON.stringify({
|
||||
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
|
||||
user: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../utils"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { clickListItem } from "../actions"
|
||||
|
||||
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
@@ -32,9 +33,7 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
|
||||
|
||||
await input.fill(model)
|
||||
|
||||
const item = dialog.locator(`[data-slot="list-item"][data-key="${key}"]`)
|
||||
await expect(item).toBeVisible()
|
||||
await item.click()
|
||||
await clickListItem(dialog, { key })
|
||||
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey, promptSelector } from "../utils"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { closeDialog, openSettings, clickListItem } from "../actions"
|
||||
|
||||
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
@@ -27,18 +28,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(picker).toHaveCount(0)
|
||||
|
||||
const settings = page.getByRole("dialog")
|
||||
|
||||
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
||||
const opened = await settings
|
||||
.waitFor({ state: "visible", timeout: 3000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (!opened) {
|
||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||
await expect(settings).toBeVisible()
|
||||
}
|
||||
const settings = await openSettings(page)
|
||||
|
||||
await settings.getByRole("tab", { name: "Models" }).click()
|
||||
const search = settings.getByPlaceholder("Search models")
|
||||
@@ -52,22 +42,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi
|
||||
await toggle.locator('[data-slot="switch-control"]').click()
|
||||
await expect(input).toHaveAttribute("aria-checked", "false")
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
const closed = await settings
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!closed) {
|
||||
await page.keyboard.press("Escape")
|
||||
const closedSecond = await settings
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!closedSecond) {
|
||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
||||
await expect(settings).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
await closeDialog(page, settings)
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type("/model")
|
||||
|
||||
52
packages/app/e2e/projects/project-edit.spec.ts
Normal file
52
packages/app/e2e/projects/project-edit.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSidebar } from "../actions"
|
||||
|
||||
test("dialog edit project updates name and startup script", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
const open = async () => {
|
||||
const header = page.locator(".group\\/project").first()
|
||||
await header.hover()
|
||||
const trigger = header.getByRole("button", { name: "More options" }).first()
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click({ force: true })
|
||||
|
||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
|
||||
await expect(editItem).toBeVisible()
|
||||
await editItem.click({ force: true })
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
|
||||
return dialog
|
||||
}
|
||||
|
||||
const name = `e2e project ${Date.now()}`
|
||||
const startup = `echo e2e_${Date.now()}`
|
||||
|
||||
const dialog = await open()
|
||||
|
||||
const nameInput = dialog.getByLabel("Name")
|
||||
await nameInput.fill(name)
|
||||
|
||||
const startupInput = dialog.getByLabel("Workspace startup script")
|
||||
await startupInput.fill(startup)
|
||||
|
||||
await dialog.getByRole("button", { name: "Save" }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
const header = page.locator(".group\\/project").first()
|
||||
await expect(header).toContainText(name)
|
||||
|
||||
const reopened = await open()
|
||||
await expect(reopened.getByLabel("Name")).toHaveValue(name)
|
||||
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
|
||||
await reopened.getByRole("button", { name: "Cancel" }).click()
|
||||
await expect(reopened).toHaveCount(0)
|
||||
})
|
||||
70
packages/app/e2e/projects/projects-close.spec.ts
Normal file
70
packages/app/e2e/projects/projects-close.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { createTestProject, seedProjects, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
|
||||
import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
test("can close a project via hover card close button", async ({ page, directory, gotoSession }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherSlug = dirSlug(other)
|
||||
await seedProjects(page, { directory, extra: [other] })
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.hover()
|
||||
|
||||
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
|
||||
await expect(close).toBeVisible()
|
||||
await close.click()
|
||||
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
||||
test("can close a project via project header more options menu", async ({ page, directory, gotoSession }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherName = other.split("/").pop() ?? other
|
||||
const otherSlug = dirSlug(other)
|
||||
await seedProjects(page, { directory, extra: [other] })
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||
|
||||
const header = page
|
||||
.locator(".group\\/project")
|
||||
.filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
|
||||
.first()
|
||||
await expect(header).toContainText(otherName)
|
||||
|
||||
const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
|
||||
await expect(trigger).toHaveCount(1)
|
||||
await trigger.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||
await expect(menu).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
34
packages/app/e2e/projects/projects-switch.spec.ts
Normal file
34
packages/app/e2e/projects/projects-switch.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { defocus, createTestProject, seedProjects, cleanupTestProject } from "../actions"
|
||||
import { projectSwitchSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
test("can switch between projects from sidebar", async ({ page, directory, gotoSession }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherSlug = dirSlug(other)
|
||||
|
||||
await seedProjects(page, { directory, extra: [other] })
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
|
||||
await defocus(page)
|
||||
|
||||
const currentSlug = dirSlug(directory)
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||
|
||||
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
|
||||
await expect(currentButton).toBeVisible()
|
||||
await currentButton.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
391
packages/app/e2e/projects/workspaces.spec.ts
Normal file
391
packages/app/e2e/projects/workspaces.spec.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import type { Page } from "@playwright/test"
|
||||
|
||||
import { test, expect } from "../fixtures"
|
||||
|
||||
test.describe.configure({ mode: "serial" })
|
||||
import {
|
||||
cleanupTestProject,
|
||||
clickMenuItem,
|
||||
confirmDialog,
|
||||
createTestProject,
|
||||
openSidebar,
|
||||
openWorkspaceMenu,
|
||||
seedProjects,
|
||||
setWorkspacesEnabled,
|
||||
} from "../actions"
|
||||
import { inlineInputSelector, projectSwitchSelector, workspaceItemSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () => Promise<void>) {
|
||||
const project = await createTestProject()
|
||||
const rootSlug = dirSlug(project)
|
||||
await seedProjects(page, { directory, extra: [project] })
|
||||
|
||||
await gotoSession()
|
||||
await openSidebar(page)
|
||||
|
||||
const target = page.locator(projectSwitchSelector(rootSlug)).first()
|
||||
await expect(target).toBeVisible()
|
||||
await target.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, rootSlug, true)
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
return slug.length > 0 && slug !== rootSlug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const slug = slugFromUrl(page.url())
|
||||
const dir = base64Decode(slug)
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
return { project, rootSlug, slug, directory: dir }
|
||||
}
|
||||
|
||||
test("can enable and disable workspaces from project menu", async ({ page, directory, gotoSession }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const project = await createTestProject()
|
||||
const slug = dirSlug(project)
|
||||
await seedProjects(page, { directory, extra: [project] })
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
await openSidebar(page)
|
||||
|
||||
const target = page.locator(projectSwitchSelector(slug)).first()
|
||||
await expect(target).toBeVisible()
|
||||
await target.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
|
||||
|
||||
await setWorkspacesEnabled(page, slug, false)
|
||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
||||
} finally {
|
||||
await cleanupTestProject(project)
|
||||
}
|
||||
})
|
||||
|
||||
test("can create a workspace", async ({ page, directory, gotoSession }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const project = await createTestProject()
|
||||
const slug = dirSlug(project)
|
||||
await seedProjects(page, { directory, extra: [project] })
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
await openSidebar(page)
|
||||
|
||||
const target = page.locator(projectSwitchSelector(slug)).first()
|
||||
await expect(target).toBeVisible()
|
||||
await target.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
|
||||
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const currentSlug = slugFromUrl(page.url())
|
||||
return currentSlug.length > 0 && currentSlug !== slug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const workspaceSlug = slugFromUrl(page.url())
|
||||
const workspaceDir = base64Decode(workspaceSlug)
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
|
||||
|
||||
await cleanupTestProject(workspaceDir)
|
||||
} finally {
|
||||
await cleanupTestProject(project)
|
||||
}
|
||||
})
|
||||
|
||||
test("can rename a workspace", async ({ page, directory, gotoSession }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const { project, slug } = await setupWorkspaceTest(page, directory, gotoSession)
|
||||
|
||||
try {
|
||||
const rename = `e2e workspace ${Date.now()}`
|
||||
const menu = await openWorkspaceMenu(page, slug)
|
||||
await clickMenuItem(menu, /^Rename$/i, { force: true })
|
||||
|
||||
await expect(menu).toHaveCount(0)
|
||||
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
await expect(item).toBeVisible()
|
||||
const input = item.locator(inlineInputSelector).first()
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(rename)
|
||||
await input.press("Enter")
|
||||
await expect(item).toContainText(rename)
|
||||
} finally {
|
||||
await cleanupTestProject(project)
|
||||
}
|
||||
})
|
||||
|
||||
test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const { project, slug, directory: createdDir } = await setupWorkspaceTest(page, directory, gotoSession)
|
||||
|
||||
try {
|
||||
const readme = path.join(createdDir, "README.md")
|
||||
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
|
||||
const original = await fs.readFile(readme, "utf8")
|
||||
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
|
||||
await fs.writeFile(readme, dirty, "utf8")
|
||||
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await fs
|
||||
.stat(extra)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const files = await sdk.file
|
||||
.status({ directory: createdDir })
|
||||
.then((r) => r.data ?? [])
|
||||
.catch(() => [])
|
||||
return files.length
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const menu = await openWorkspaceMenu(page, slug)
|
||||
await clickMenuItem(menu, /^Reset$/i, { force: true })
|
||||
await confirmDialog(page, /^Reset workspace$/i)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const files = await sdk.file
|
||||
.status({ directory: createdDir })
|
||||
.then((r) => r.data ?? [])
|
||||
.catch(() => [])
|
||||
return files.length
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await fs
|
||||
.stat(extra)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
})
|
||||
.toBe(false)
|
||||
} finally {
|
||||
await cleanupTestProject(project)
|
||||
}
|
||||
})
|
||||
|
||||
test("can delete a workspace", async ({ page, directory, gotoSession }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const { project, rootSlug, slug } = await setupWorkspaceTest(page, directory, gotoSession)
|
||||
|
||||
try {
|
||||
const menu = await openWorkspaceMenu(page, slug)
|
||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||
await confirmDialog(page, /^Delete workspace$/i)
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
||||
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
||||
} finally {
|
||||
await cleanupTestProject(project)
|
||||
}
|
||||
})
|
||||
|
||||
test("can reorder workspaces by drag and drop", async ({ page, directory, gotoSession }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const project = await createTestProject()
|
||||
const rootSlug = dirSlug(project)
|
||||
await seedProjects(page, { directory, extra: [project] })
|
||||
|
||||
const workspaces = [] as { directory: string; slug: string }[]
|
||||
|
||||
const listSlugs = async () => {
|
||||
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
|
||||
const slugs = await nodes.evaluateAll((els) => {
|
||||
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
|
||||
})
|
||||
return slugs
|
||||
}
|
||||
|
||||
const waitReady = async (slug: string) => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
const drag = async (from: string, to: string) => {
|
||||
const src = page.locator(workspaceItemSelector(from)).first()
|
||||
const dst = page.locator(workspaceItemSelector(to)).first()
|
||||
|
||||
await src.scrollIntoViewIfNeeded()
|
||||
await dst.scrollIntoViewIfNeeded()
|
||||
|
||||
const a = await src.boundingBox()
|
||||
const b = await dst.boundingBox()
|
||||
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
||||
|
||||
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
await openSidebar(page)
|
||||
|
||||
const target = page.locator(projectSwitchSelector(rootSlug)).first()
|
||||
await expect(target).toBeVisible()
|
||||
await target.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, rootSlug, true)
|
||||
|
||||
for (const _ of [0, 1]) {
|
||||
const prev = slugFromUrl(page.url())
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
return slug.length > 0 && slug !== rootSlug && slug !== prev
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const slug = slugFromUrl(page.url())
|
||||
const dir = base64Decode(slug)
|
||||
workspaces.push({ slug, directory: dir })
|
||||
|
||||
await openSidebar(page)
|
||||
}
|
||||
|
||||
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
|
||||
|
||||
const a = workspaces[0].slug
|
||||
const b = workspaces[1].slug
|
||||
|
||||
await waitReady(a)
|
||||
await waitReady(b)
|
||||
|
||||
const list = async () => {
|
||||
const slugs = await listSlugs()
|
||||
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const slugs = await list()
|
||||
return slugs.length === 2
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
const before = await list()
|
||||
const from = before[1]
|
||||
const to = before[0]
|
||||
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
|
||||
|
||||
await drag(from, to)
|
||||
|
||||
await expect.poll(async () => await list()).toEqual([from, to])
|
||||
} finally {
|
||||
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
|
||||
await cleanupTestProject(project)
|
||||
}
|
||||
})
|
||||
@@ -1,16 +1,13 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../utils"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { withSession } from "../actions"
|
||||
|
||||
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
|
||||
const title = `e2e smoke context ${Date.now()}`
|
||||
const created = await sdk.session.create({ title }).then((r) => r.data)
|
||||
|
||||
if (!created?.id) throw new Error("Session create did not return an id")
|
||||
const sessionID = created.id
|
||||
|
||||
try {
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await sdk.session.promptAsync({
|
||||
sessionID,
|
||||
sessionID: session.id,
|
||||
noReply: true,
|
||||
parts: [
|
||||
{
|
||||
@@ -22,12 +19,12 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
|
||||
const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? [])
|
||||
return messages.length
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await gotoSession(sessionID)
|
||||
await gotoSession(session.id)
|
||||
|
||||
const contextButton = page
|
||||
.locator('[data-component="button"]')
|
||||
@@ -39,7 +36,5 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess
|
||||
|
||||
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../utils"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../utils"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../utils"
|
||||
|
||||
function sessionIDFromUrl(url: string) {
|
||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||
return match?.[1]
|
||||
}
|
||||
import { promptSelector } from "../selectors"
|
||||
import { sessionIDFromUrl, withSession } from "../actions"
|
||||
|
||||
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
57
packages/app/e2e/selectors.ts
Normal file
57
packages/app/e2e/selectors.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export const promptSelector = '[data-component="prompt-input"]'
|
||||
export const terminalSelector = '[data-component="terminal"]'
|
||||
|
||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
||||
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
|
||||
export const settingsThemeSelector = '[data-action="settings-theme"]'
|
||||
export const settingsFontSelector = '[data-action="settings-font"]'
|
||||
export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
|
||||
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
|
||||
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
|
||||
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
|
||||
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
|
||||
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
|
||||
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
||||
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
||||
|
||||
export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
||||
|
||||
export const projectSwitchSelector = (slug: string) =>
|
||||
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
||||
|
||||
export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]`
|
||||
|
||||
export const projectMenuTriggerSelector = (slug: string) =>
|
||||
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
|
||||
|
||||
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
||||
|
||||
export const projectWorkspacesToggleSelector = (slug: string) =>
|
||||
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
||||
|
||||
export const titlebarRightSelector = "#opencode-titlebar-right"
|
||||
|
||||
export const popoverBodySelector = '[data-slot="popover-body"]'
|
||||
|
||||
export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
|
||||
|
||||
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
|
||||
|
||||
export const inlineInputSelector = '[data-component="inline-input"]'
|
||||
|
||||
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
|
||||
|
||||
export const workspaceItemSelector = (slug: string) =>
|
||||
`${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]`
|
||||
|
||||
export const workspaceMenuTriggerSelector = (slug: string) =>
|
||||
`${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
|
||||
|
||||
export const listItemSelector = '[data-slot="list-item"]'
|
||||
|
||||
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
|
||||
|
||||
export const listItemKeySelector = (key: string) => `${listItemSelector}[data-key="${key}"]`
|
||||
|
||||
export const keybindButtonSelector = (id: string) => `[data-keybind-id="${id}"]`
|
||||
115
packages/app/e2e/session/session.spec.ts
Normal file
115
packages/app/e2e/session/session.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import {
|
||||
openSidebar,
|
||||
openSessionMoreMenu,
|
||||
clickMenuItem,
|
||||
confirmDialog,
|
||||
openSharePopover,
|
||||
withSession,
|
||||
} from "../actions"
|
||||
import { sessionItemSelector, inlineInputSelector } from "../selectors"
|
||||
|
||||
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||
|
||||
test("sidebar session can be renamed", async ({ page, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
const originalTitle = `e2e rename test ${stamp}`
|
||||
const newTitle = `e2e renamed ${stamp}`
|
||||
|
||||
await withSession(sdk, originalTitle, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await openSidebar(page)
|
||||
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /rename/i)
|
||||
|
||||
const input = page.locator(sessionItemSelector(session.id)).locator(inlineInputSelector).first()
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(newTitle)
|
||||
await input.press("Enter")
|
||||
|
||||
await expect(page.locator(sessionItemSelector(session.id)).locator("a").first()).toContainText(newTitle)
|
||||
})
|
||||
})
|
||||
|
||||
test("sidebar session can be archived", async ({ page, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
const title = `e2e archive test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await openSidebar(page)
|
||||
|
||||
const sessionEl = page.locator(sessionItemSelector(session.id))
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /archive/i)
|
||||
|
||||
await expect(sessionEl).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test("sidebar session can be deleted", async ({ page, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
const title = `e2e delete test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await openSidebar(page)
|
||||
|
||||
const sessionEl = page.locator(sessionItemSelector(session.id))
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /delete/i)
|
||||
await confirmDialog(page, /delete/i)
|
||||
|
||||
await expect(sessionEl).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
|
||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||
|
||||
const stamp = Date.now()
|
||||
const title = `e2e share test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const { rightSection, popoverBody } = await openSharePopover(page)
|
||||
await popoverBody.getByRole("button", { name: "Publish" }).first().click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
|
||||
const copyButton = rightSection.locator('button[aria-label="Copy link"]').first()
|
||||
await expect(copyButton).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const sharedPopover = await openSharePopover(page)
|
||||
const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
||||
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
||||
await unpublish.click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
|
||||
await expect(copyButton).not.toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const unsharedPopover = await openSharePopover(page)
|
||||
await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
})
|
||||
})
|
||||
317
packages/app/e2e/settings/settings-keybinds.spec.ts
Normal file
317
packages/app/e2e/settings/settings-keybinds.spec.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSettings, closeDialog, withSession } from "../actions"
|
||||
import { keybindButtonSelector } from "../selectors"
|
||||
import { modKey } from "../utils"
|
||||
|
||||
test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const initialKeybind = await keybindButton.textContent()
|
||||
expect(initialKeybind).toContain("B")
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+KeyH`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newKeybind = await keybindButton.textContent()
|
||||
expect(newKeybind).toContain("H")
|
||||
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("settings.v3")
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
expect(stored?.keybinds?.["sidebar.toggle"]).toBe("mod+shift+h")
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
const main = page.locator("main")
|
||||
const initialClasses = (await main.getAttribute("class")) ?? ""
|
||||
const initiallyClosed = initialClasses.includes("xl:border-l")
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const afterToggleClasses = (await main.getAttribute("class")) ?? ""
|
||||
const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
|
||||
expect(afterToggleClosed).toBe(!initiallyClosed)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const finalClasses = (await main.getAttribute("class")) ?? ""
|
||||
const finalClosed = finalClasses.includes("xl:border-l")
|
||||
expect(finalClosed).toBe(initiallyClosed)
|
||||
})
|
||||
|
||||
test("resetting all keybinds to defaults works", async ({ page, gotoSession }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "sidebar.toggle": "mod+shift+x" } }))
|
||||
})
|
||||
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const customKeybind = await keybindButton.textContent()
|
||||
expect(customKeybind).toContain("X")
|
||||
|
||||
const resetButton = dialog.getByRole("button", { name: "Reset to defaults" })
|
||||
await expect(resetButton).toBeVisible()
|
||||
await expect(resetButton).toBeEnabled()
|
||||
await resetButton.click()
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const restoredKeybind = await keybindButton.textContent()
|
||||
expect(restoredKeybind).toContain("B")
|
||||
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("settings.v3")
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
})
|
||||
|
||||
test("clearing a keybind works", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const initialKeybind = await keybindButton.textContent()
|
||||
expect(initialKeybind).toContain("B")
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
await page.keyboard.press("Delete")
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const clearedKeybind = await keybindButton.textContent()
|
||||
expect(clearedKeybind).toMatch(/unassigned|press/i)
|
||||
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("settings.v3")
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
expect(stored?.keybinds?.["sidebar.toggle"]).toBe("none")
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const stillOnSession = page.url().includes("/session")
|
||||
expect(stillOnSession).toBe(true)
|
||||
})
|
||||
|
||||
test("changing settings open keybind works", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("settings.open"))
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const initialKeybind = await keybindButton.textContent()
|
||||
expect(initialKeybind).toContain(",")
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Slash`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newKeybind = await keybindButton.textContent()
|
||||
expect(newKeybind).toContain("/")
|
||||
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("settings.v3")
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
expect(stored?.keybinds?.["settings.open"]).toBe("mod+/")
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
const settingsDialog = page.getByRole("dialog")
|
||||
await expect(settingsDialog).toHaveCount(0)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Slash`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
await closeDialog(page, settingsDialog)
|
||||
})
|
||||
|
||||
test("changing new session keybind works", async ({ page, sdk, gotoSession }) => {
|
||||
await withSession(sdk, "test session for keybind", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const initialUrl = page.url()
|
||||
expect(initialUrl).toContain(`/session/${session.id}`)
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("session.new"))
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+KeyN`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newKeybind = await keybindButton.textContent()
|
||||
expect(newKeybind).toContain("N")
|
||||
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("settings.v3")
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
expect(stored?.keybinds?.["session.new"]).toBe("mod+shift+n")
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+N`)
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const newUrl = page.url()
|
||||
expect(newUrl).toMatch(/\/session\/?$/)
|
||||
expect(newUrl).not.toContain(session.id)
|
||||
})
|
||||
})
|
||||
|
||||
test("changing file open keybind works", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("file.open"))
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const initialKeybind = await keybindButton.textContent()
|
||||
expect(initialKeybind).toContain("P")
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+KeyF`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newKeybind = await keybindButton.textContent()
|
||||
expect(newKeybind).toContain("F")
|
||||
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("settings.v3")
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
expect(stored?.keybinds?.["file.open"]).toBe("mod+shift+f")
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
const filePickerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder(/search files/i) })
|
||||
await expect(filePickerDialog).toHaveCount(0)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+F`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await expect(filePickerDialog).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(filePickerDialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("changing terminal toggle keybind works", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
await page.keyboard.press(`${modKey}+KeyY`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newKeybind = await keybindButton.textContent()
|
||||
expect(newKeybind).toContain("Y")
|
||||
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("settings.v3")
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
expect(stored?.keybinds?.["terminal.toggle"]).toBe("mod+y")
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const pageStable = await page.evaluate(() => document.readyState === "complete")
|
||||
expect(pageStable).toBe(true)
|
||||
})
|
||||
|
||||
test("changing command palette keybind works", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
|
||||
const keybindButton = dialog.locator(keybindButtonSelector("command.palette"))
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const initialKeybind = await keybindButton.textContent()
|
||||
expect(initialKeybind).toContain("P")
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+KeyK`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newKeybind = await keybindButton.textContent()
|
||||
expect(newKeybind).toContain("K")
|
||||
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("settings.v3")
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
expect(stored?.keybinds?.["command.palette"]).toBe("mod+shift+k")
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
const palette = page.getByRole("dialog").filter({ has: page.getByRole("textbox").first() })
|
||||
await expect(palette).toHaveCount(0)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+K`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await expect(palette).toBeVisible()
|
||||
await expect(palette.getByRole("textbox").first()).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(palette).toHaveCount(0)
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey, settingsLanguageSelectSelector } from "../utils"
|
||||
|
||||
test("smoke changing language updates settings labels", async ({ page, gotoSession }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
|
||||
})
|
||||
|
||||
await gotoSession()
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
|
||||
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
||||
|
||||
const opened = await dialog
|
||||
.waitFor({ state: "visible", timeout: 3000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (!opened) {
|
||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||
await expect(dialog).toBeVisible()
|
||||
}
|
||||
|
||||
const heading = dialog.getByRole("heading", { level: 2 })
|
||||
await expect(heading).toHaveText("General")
|
||||
|
||||
const select = dialog.locator(settingsLanguageSelectSelector)
|
||||
await expect(select).toBeVisible()
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
|
||||
|
||||
await expect(heading).toHaveText("Allgemein")
|
||||
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
|
||||
await expect(heading).toHaveText("General")
|
||||
})
|
||||
122
packages/app/e2e/settings/settings-models.spec.ts
Normal file
122
packages/app/e2e/settings/settings-models.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { closeDialog, openSettings } from "../actions"
|
||||
|
||||
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type("/model")
|
||||
|
||||
const command = page.locator('[data-slash-id="model.choose"]')
|
||||
await expect(command).toBeVisible()
|
||||
await command.hover()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const picker = page.getByRole("dialog")
|
||||
await expect(picker).toBeVisible()
|
||||
|
||||
const target = picker.locator('[data-slot="list-item"]').first()
|
||||
await expect(target).toBeVisible()
|
||||
|
||||
const key = await target.getAttribute("data-key")
|
||||
if (!key) throw new Error("Failed to resolve model key from list item")
|
||||
|
||||
const name = (await target.locator("span").first().innerText()).trim()
|
||||
if (!name) throw new Error("Failed to resolve model name from list item")
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(picker).toHaveCount(0)
|
||||
|
||||
const settings = await openSettings(page)
|
||||
|
||||
await settings.getByRole("tab", { name: "Models" }).click()
|
||||
const search = settings.getByPlaceholder("Search models")
|
||||
await expect(search).toBeVisible()
|
||||
await search.fill(name)
|
||||
|
||||
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
|
||||
const input = toggle.locator('[data-slot="switch-input"]')
|
||||
await expect(toggle).toBeVisible()
|
||||
await expect(input).toHaveAttribute("aria-checked", "true")
|
||||
await toggle.locator('[data-slot="switch-control"]').click()
|
||||
await expect(input).toHaveAttribute("aria-checked", "false")
|
||||
|
||||
await closeDialog(page, settings)
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type("/model")
|
||||
await expect(command).toBeVisible()
|
||||
await command.hover()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const pickerAgain = page.getByRole("dialog")
|
||||
await expect(pickerAgain).toBeVisible()
|
||||
await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible()
|
||||
|
||||
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0)
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(pickerAgain).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("showing a hidden model restores it to the model picker", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type("/model")
|
||||
|
||||
const command = page.locator('[data-slash-id="model.choose"]')
|
||||
await expect(command).toBeVisible()
|
||||
await command.hover()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const picker = page.getByRole("dialog")
|
||||
await expect(picker).toBeVisible()
|
||||
|
||||
const target = picker.locator('[data-slot="list-item"]').first()
|
||||
await expect(target).toBeVisible()
|
||||
|
||||
const key = await target.getAttribute("data-key")
|
||||
if (!key) throw new Error("Failed to resolve model key from list item")
|
||||
|
||||
const name = (await target.locator("span").first().innerText()).trim()
|
||||
if (!name) throw new Error("Failed to resolve model name from list item")
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(picker).toHaveCount(0)
|
||||
|
||||
const settings = await openSettings(page)
|
||||
|
||||
await settings.getByRole("tab", { name: "Models" }).click()
|
||||
const search = settings.getByPlaceholder("Search models")
|
||||
await expect(search).toBeVisible()
|
||||
await search.fill(name)
|
||||
|
||||
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
|
||||
const input = toggle.locator('[data-slot="switch-input"]')
|
||||
await expect(toggle).toBeVisible()
|
||||
await expect(input).toHaveAttribute("aria-checked", "true")
|
||||
|
||||
await toggle.locator('[data-slot="switch-control"]').click()
|
||||
await expect(input).toHaveAttribute("aria-checked", "false")
|
||||
|
||||
await toggle.locator('[data-slot="switch-control"]').click()
|
||||
await expect(input).toHaveAttribute("aria-checked", "true")
|
||||
|
||||
await closeDialog(page, settings)
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type("/model")
|
||||
await expect(command).toBeVisible()
|
||||
await command.hover()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const pickerAgain = page.getByRole("dialog")
|
||||
await expect(pickerAgain).toBeVisible()
|
||||
|
||||
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(pickerAgain).toHaveCount(0)
|
||||
})
|
||||
@@ -1,56 +1,136 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey, promptSelector } from "../utils"
|
||||
import { closeDialog, openSettings } from "../actions"
|
||||
|
||||
test("smoke providers settings opens provider selector", async ({ page, gotoSession }) => {
|
||||
test("custom provider form can be filled and validates input", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
const settings = await openSettings(page)
|
||||
await settings.getByRole("tab", { name: "Providers" }).click()
|
||||
|
||||
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
||||
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
|
||||
await expect(customProviderSection).toBeVisible()
|
||||
|
||||
const opened = await dialog
|
||||
.waitFor({ state: "visible", timeout: 3000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (!opened) {
|
||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||
await expect(dialog).toBeVisible()
|
||||
}
|
||||
|
||||
await dialog.getByRole("tab", { name: "Providers" }).click()
|
||||
await expect(dialog.getByText("Connected providers", { exact: true })).toBeVisible()
|
||||
await expect(dialog.getByText("Popular providers", { exact: true })).toBeVisible()
|
||||
|
||||
await dialog.getByRole("button", { name: "Show more providers" }).click()
|
||||
|
||||
const providerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder("Search providers") })
|
||||
const connectButton = customProviderSection.getByRole("button", { name: "Connect" })
|
||||
await connectButton.click()
|
||||
|
||||
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
|
||||
await expect(providerDialog).toBeVisible()
|
||||
await expect(providerDialog.getByPlaceholder("Search providers")).toBeVisible()
|
||||
await expect(providerDialog.locator('[data-slot="list-item"]').first()).toBeVisible()
|
||||
|
||||
await providerDialog.getByLabel("Provider ID").fill("test-provider")
|
||||
await providerDialog.getByLabel("Display name").fill("Test Provider")
|
||||
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/fake")
|
||||
await providerDialog.getByLabel("API key").fill("fake-key")
|
||||
|
||||
await providerDialog.getByPlaceholder("model-id").first().fill("test-model")
|
||||
await providerDialog.getByPlaceholder("Display Name").first().fill("Test Model")
|
||||
|
||||
await expect(providerDialog.getByRole("textbox", { name: "Provider ID" })).toHaveValue("test-provider")
|
||||
await expect(providerDialog.getByRole("textbox", { name: "Display name" })).toHaveValue("Test Provider")
|
||||
await expect(providerDialog.getByRole("textbox", { name: "Base URL" })).toHaveValue("http://localhost:9999/fake")
|
||||
await expect(providerDialog.getByRole("textbox", { name: "API key" })).toHaveValue("fake-key")
|
||||
await expect(providerDialog.getByPlaceholder("model-id").first()).toHaveValue("test-model")
|
||||
await expect(providerDialog.getByPlaceholder("Display Name").first()).toHaveValue("Test Model")
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(providerDialog).toHaveCount(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
|
||||
const stillOpen = await dialog.isVisible().catch(() => false)
|
||||
if (!stillOpen) return
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
const closed = await dialog
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (closed) return
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
const closedSecond = await dialog
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (closedSecond) return
|
||||
|
||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await closeDialog(page, settings)
|
||||
})
|
||||
|
||||
test("custom provider form shows validation errors", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const settings = await openSettings(page)
|
||||
await settings.getByRole("tab", { name: "Providers" }).click()
|
||||
|
||||
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
|
||||
await customProviderSection.getByRole("button", { name: "Connect" }).click()
|
||||
|
||||
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
|
||||
await expect(providerDialog).toBeVisible()
|
||||
|
||||
await providerDialog.getByLabel("Provider ID").fill("invalid provider id")
|
||||
await providerDialog.getByLabel("Base URL").fill("not-a-url")
|
||||
|
||||
await providerDialog.getByRole("button", { name: /submit|save/i }).click()
|
||||
|
||||
await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /lowercase/i })).toBeVisible()
|
||||
await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /http/i })).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(providerDialog).toHaveCount(0)
|
||||
|
||||
await closeDialog(page, settings)
|
||||
})
|
||||
|
||||
test("custom provider form can add and remove models", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const settings = await openSettings(page)
|
||||
await settings.getByRole("tab", { name: "Providers" }).click()
|
||||
|
||||
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
|
||||
await customProviderSection.getByRole("button", { name: "Connect" }).click()
|
||||
|
||||
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
|
||||
await expect(providerDialog).toBeVisible()
|
||||
|
||||
await providerDialog.getByLabel("Provider ID").fill("multi-model-test")
|
||||
await providerDialog.getByLabel("Display name").fill("Multi Model Test")
|
||||
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/multi")
|
||||
|
||||
await providerDialog.getByPlaceholder("model-id").first().fill("model-1")
|
||||
await providerDialog.getByPlaceholder("Display Name").first().fill("Model 1")
|
||||
|
||||
const idInputsBefore = await providerDialog.getByPlaceholder("model-id").count()
|
||||
await providerDialog.getByRole("button", { name: "Add model" }).click()
|
||||
const idInputsAfter = await providerDialog.getByPlaceholder("model-id").count()
|
||||
expect(idInputsAfter).toBe(idInputsBefore + 1)
|
||||
|
||||
await providerDialog.getByPlaceholder("model-id").nth(1).fill("model-2")
|
||||
await providerDialog.getByPlaceholder("Display Name").nth(1).fill("Model 2")
|
||||
|
||||
await expect(providerDialog.getByPlaceholder("model-id").nth(1)).toHaveValue("model-2")
|
||||
await expect(providerDialog.getByPlaceholder("Display Name").nth(1)).toHaveValue("Model 2")
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(providerDialog).toHaveCount(0)
|
||||
|
||||
await closeDialog(page, settings)
|
||||
})
|
||||
|
||||
test("custom provider form can add and remove headers", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const settings = await openSettings(page)
|
||||
await settings.getByRole("tab", { name: "Providers" }).click()
|
||||
|
||||
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
|
||||
await customProviderSection.getByRole("button", { name: "Connect" }).click()
|
||||
|
||||
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
|
||||
await expect(providerDialog).toBeVisible()
|
||||
|
||||
await providerDialog.getByLabel("Provider ID").fill("header-test")
|
||||
await providerDialog.getByLabel("Display name").fill("Header Test")
|
||||
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/headers")
|
||||
|
||||
await providerDialog.getByPlaceholder("model-id").first().fill("model-x")
|
||||
await providerDialog.getByPlaceholder("Display Name").first().fill("Model X")
|
||||
|
||||
const headerInputsBefore = await providerDialog.getByPlaceholder("Header-Name").count()
|
||||
await providerDialog.getByRole("button", { name: "Add header" }).click()
|
||||
const headerInputsAfter = await providerDialog.getByPlaceholder("Header-Name").count()
|
||||
expect(headerInputsAfter).toBe(headerInputsBefore + 1)
|
||||
|
||||
await providerDialog.getByPlaceholder("Header-Name").first().fill("Authorization")
|
||||
await providerDialog.getByPlaceholder("value").first().fill("Bearer token123")
|
||||
|
||||
await expect(providerDialog.getByPlaceholder("Header-Name").first()).toHaveValue("Authorization")
|
||||
await expect(providerDialog.getByPlaceholder("value").first()).toHaveValue("Bearer token123")
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(providerDialog).toHaveCount(0)
|
||||
|
||||
await closeDialog(page, settings)
|
||||
})
|
||||
|
||||
@@ -1,44 +1,292 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey } from "../utils"
|
||||
import { test, expect, settingsKey } from "../fixtures"
|
||||
import { closeDialog, openSettings } from "../actions"
|
||||
import {
|
||||
settingsColorSchemeSelector,
|
||||
settingsFontSelector,
|
||||
settingsLanguageSelectSelector,
|
||||
settingsNotificationsAgentSelector,
|
||||
settingsNotificationsErrorsSelector,
|
||||
settingsNotificationsPermissionsSelector,
|
||||
settingsReleaseNotesSelector,
|
||||
settingsSoundsAgentSelector,
|
||||
settingsThemeSelector,
|
||||
settingsUpdatesStartupSelector,
|
||||
} from "../selectors"
|
||||
|
||||
test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
|
||||
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
||||
|
||||
const opened = await dialog
|
||||
.waitFor({ state: "visible", timeout: 3000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (!opened) {
|
||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||
await expect(dialog).toBeVisible()
|
||||
}
|
||||
const dialog = await openSettings(page)
|
||||
|
||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||
await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible()
|
||||
await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
const closed = await dialog
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (closed) return
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
const closedSecond = await dialog
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (closedSecond) return
|
||||
|
||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await closeDialog(page, dialog)
|
||||
})
|
||||
|
||||
test("changing language updates settings labels", async ({ page, gotoSession }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
|
||||
})
|
||||
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
|
||||
const heading = dialog.getByRole("heading", { level: 2 })
|
||||
await expect(heading).toHaveText("General")
|
||||
|
||||
const select = dialog.locator(settingsLanguageSelectSelector)
|
||||
await expect(select).toBeVisible()
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
|
||||
|
||||
await expect(heading).toHaveText("Allgemein")
|
||||
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
|
||||
await expect(heading).toHaveText("General")
|
||||
})
|
||||
|
||||
test("changing color scheme persists in localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const select = dialog.locator(settingsColorSchemeSelector)
|
||||
await expect(select).toBeVisible()
|
||||
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
|
||||
|
||||
const colorScheme = await page.evaluate(() => {
|
||||
return document.documentElement.getAttribute("data-color-scheme")
|
||||
})
|
||||
expect(colorScheme).toBe("dark")
|
||||
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Light" }).click()
|
||||
|
||||
const lightColorScheme = await page.evaluate(() => {
|
||||
return document.documentElement.getAttribute("data-color-scheme")
|
||||
})
|
||||
expect(lightColorScheme).toBe("light")
|
||||
})
|
||||
|
||||
test("changing theme persists in localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const select = dialog.locator(settingsThemeSelector)
|
||||
await expect(select).toBeVisible()
|
||||
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
const items = page.locator('[data-slot="select-select-item"]')
|
||||
const count = await items.count()
|
||||
expect(count).toBeGreaterThan(1)
|
||||
|
||||
const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent()
|
||||
expect(firstTheme).toBeTruthy()
|
||||
|
||||
await items.nth(1).click()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
const storedThemeId = await page.evaluate(() => {
|
||||
return localStorage.getItem("opencode-theme-id")
|
||||
})
|
||||
|
||||
expect(storedThemeId).not.toBeNull()
|
||||
expect(storedThemeId).not.toBe("oc-1")
|
||||
|
||||
const dataTheme = await page.evaluate(() => {
|
||||
return document.documentElement.getAttribute("data-theme")
|
||||
})
|
||||
expect(dataTheme).toBe(storedThemeId)
|
||||
})
|
||||
|
||||
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const select = dialog.locator(settingsFontSelector)
|
||||
await expect(select).toBeVisible()
|
||||
|
||||
const initialFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
|
||||
})
|
||||
expect(initialFontFamily).toContain("IBM Plex Mono")
|
||||
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
const items = page.locator('[data-slot="select-select-item"]')
|
||||
await items.nth(2).click()
|
||||
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const stored = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
expect(stored?.appearance?.font).not.toBe("ibm-plex-mono")
|
||||
|
||||
const newFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
|
||||
})
|
||||
expect(newFontFamily).not.toBe(initialFontFamily)
|
||||
})
|
||||
|
||||
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const switchContainer = dialog.locator(settingsNotificationsAgentSelector)
|
||||
await expect(switchContainer).toBeVisible()
|
||||
|
||||
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
||||
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(initialState).toBe(true)
|
||||
|
||||
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(newState).toBe(false)
|
||||
|
||||
const stored = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
expect(stored?.notifications?.agent).toBe(false)
|
||||
})
|
||||
|
||||
test("toggling notification permissions switch updates localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const switchContainer = dialog.locator(settingsNotificationsPermissionsSelector)
|
||||
await expect(switchContainer).toBeVisible()
|
||||
|
||||
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
||||
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(initialState).toBe(true)
|
||||
|
||||
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(newState).toBe(false)
|
||||
|
||||
const stored = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
expect(stored?.notifications?.permissions).toBe(false)
|
||||
})
|
||||
|
||||
test("toggling notification errors switch updates localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const switchContainer = dialog.locator(settingsNotificationsErrorsSelector)
|
||||
await expect(switchContainer).toBeVisible()
|
||||
|
||||
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
||||
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(initialState).toBe(false)
|
||||
|
||||
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(newState).toBe(true)
|
||||
|
||||
const stored = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
expect(stored?.notifications?.errors).toBe(true)
|
||||
})
|
||||
|
||||
test("changing sound agent selection persists in localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const select = dialog.locator(settingsSoundsAgentSelector)
|
||||
await expect(select).toBeVisible()
|
||||
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
const items = page.locator('[data-slot="select-select-item"]')
|
||||
await items.nth(2).click()
|
||||
|
||||
const stored = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
|
||||
})
|
||||
|
||||
test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const switchContainer = dialog.locator(settingsUpdatesStartupSelector)
|
||||
await expect(switchContainer).toBeVisible()
|
||||
|
||||
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
||||
|
||||
const isDisabled = await toggleInput.evaluate((el: HTMLInputElement) => el.disabled)
|
||||
if (isDisabled) {
|
||||
test.skip()
|
||||
return
|
||||
}
|
||||
|
||||
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(initialState).toBe(true)
|
||||
|
||||
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(newState).toBe(false)
|
||||
|
||||
const stored = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
expect(stored?.updates?.startup).toBe(false)
|
||||
})
|
||||
|
||||
test("toggling release notes switch updates localStorage", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const switchContainer = dialog.locator(settingsReleaseNotesSelector)
|
||||
await expect(switchContainer).toBeVisible()
|
||||
|
||||
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
||||
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(initialState).toBe(true)
|
||||
|
||||
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
||||
expect(newState).toBe(false)
|
||||
|
||||
const stored = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
expect(stored?.general?.releaseNotes).toBe(false)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey, promptSelector } from "../utils"
|
||||
import { openSidebar, withSession } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
@@ -13,12 +14,7 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
|
||||
try {
|
||||
await gotoSession(one.id)
|
||||
|
||||
const main = page.locator("main")
|
||||
const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l")
|
||||
if (collapsed) {
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
await expect(main).not.toHaveClass(/xl:border-l/)
|
||||
}
|
||||
await openSidebar(page)
|
||||
|
||||
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||
await expect(target).toBeVisible()
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { modKey } from "../utils"
|
||||
import { openSidebar, toggleSidebar } from "../actions"
|
||||
|
||||
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const main = page.locator("main")
|
||||
const closedClass = /xl:border-l/
|
||||
const isClosed = await main.evaluate((node) => node.className.includes("xl:border-l"))
|
||||
await openSidebar(page)
|
||||
|
||||
if (isClosed) {
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
await expect(main).not.toHaveClass(closedClass)
|
||||
}
|
||||
await toggleSidebar(page)
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
await expect(main).toHaveClass(closedClass)
|
||||
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
await expect(main).not.toHaveClass(closedClass)
|
||||
await toggleSidebar(page)
|
||||
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
||||
})
|
||||
|
||||
94
packages/app/e2e/status/status-popover.spec.ts
Normal file
94
packages/app/e2e/status/status-popover.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openStatusPopover, defocus } from "../actions"
|
||||
|
||||
test("status popover opens and shows tabs", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const { popoverBody } = await openStatusPopover(page)
|
||||
|
||||
await expect(popoverBody.getByRole("tab", { name: /servers/i })).toBeVisible()
|
||||
await expect(popoverBody.getByRole("tab", { name: /mcp/i })).toBeVisible()
|
||||
await expect(popoverBody.getByRole("tab", { name: /lsp/i })).toBeVisible()
|
||||
await expect(popoverBody.getByRole("tab", { name: /plugins/i })).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(popoverBody).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("status popover servers tab shows current server", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const { popoverBody } = await openStatusPopover(page)
|
||||
|
||||
const serversTab = popoverBody.getByRole("tab", { name: /servers/i })
|
||||
await expect(serversTab).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
const serverList = popoverBody.locator('[role="tabpanel"]').first()
|
||||
await expect(serverList.locator("button").first()).toBeVisible()
|
||||
})
|
||||
|
||||
test("status popover can switch to mcp tab", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const { popoverBody } = await openStatusPopover(page)
|
||||
|
||||
const mcpTab = popoverBody.getByRole("tab", { name: /mcp/i })
|
||||
await mcpTab.click()
|
||||
|
||||
const ariaSelected = await mcpTab.getAttribute("aria-selected")
|
||||
expect(ariaSelected).toBe("true")
|
||||
|
||||
const mcpContent = popoverBody.locator('[role="tabpanel"]:visible').first()
|
||||
await expect(mcpContent).toBeVisible()
|
||||
})
|
||||
|
||||
test("status popover can switch to lsp tab", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const { popoverBody } = await openStatusPopover(page)
|
||||
|
||||
const lspTab = popoverBody.getByRole("tab", { name: /lsp/i })
|
||||
await lspTab.click()
|
||||
|
||||
const ariaSelected = await lspTab.getAttribute("aria-selected")
|
||||
expect(ariaSelected).toBe("true")
|
||||
|
||||
const lspContent = popoverBody.locator('[role="tabpanel"]:visible').first()
|
||||
await expect(lspContent).toBeVisible()
|
||||
})
|
||||
|
||||
test("status popover can switch to plugins tab", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const { popoverBody } = await openStatusPopover(page)
|
||||
|
||||
const pluginsTab = popoverBody.getByRole("tab", { name: /plugins/i })
|
||||
await pluginsTab.click()
|
||||
|
||||
const ariaSelected = await pluginsTab.getAttribute("aria-selected")
|
||||
expect(ariaSelected).toBe("true")
|
||||
|
||||
const pluginsContent = popoverBody.locator('[role="tabpanel"]:visible').first()
|
||||
await expect(pluginsContent).toBeVisible()
|
||||
})
|
||||
|
||||
test("status popover closes on escape", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const { popoverBody } = await openStatusPopover(page)
|
||||
await expect(popoverBody).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(popoverBody).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("status popover closes when clicking outside", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const { popoverBody } = await openStatusPopover(page)
|
||||
await expect(popoverBody).toBeVisible()
|
||||
|
||||
await defocus(page)
|
||||
|
||||
await expect(popoverBody).toHaveCount(0)
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector, terminalSelector, terminalToggleKey } from "../utils"
|
||||
import { promptSelector, terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey } from "../utils"
|
||||
|
||||
test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { terminalSelector, terminalToggleKey } from "../utils"
|
||||
import { terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey } from "../utils"
|
||||
|
||||
test("terminal panel can be toggled", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "./fixtures"
|
||||
import { modelVariantCycleSelector } from "./utils"
|
||||
import { modelVariantCycleSelector } from "./selectors"
|
||||
|
||||
test("smoke model variant cycle updates label", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -10,12 +10,6 @@ export const serverName = `${serverHost}:${serverPort}`
|
||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||
export const terminalToggleKey = "Control+Backquote"
|
||||
|
||||
export const promptSelector = '[data-component="prompt-input"]'
|
||||
export const terminalSelector = '[data-component="terminal"]'
|
||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||
|
||||
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
||||
|
||||
export function createSdk(directory?: string) {
|
||||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.46",
|
||||
"version": "1.1.48",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -6,6 +6,7 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
|
||||
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||
const reuse = !process.env.CI
|
||||
const win = process.platform === "win32"
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
@@ -14,7 +15,8 @@ export default defineConfig({
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
fullyParallel: true,
|
||||
fullyParallel: !win,
|
||||
workers: win ? 1 : undefined,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||
|
||||
@@ -58,7 +58,7 @@ const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
|
||||
|
||||
const serverEnv = {
|
||||
...process.env,
|
||||
OPENCODE_DISABLE_SHARE: "true",
|
||||
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
||||
|
||||
@@ -93,6 +93,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
||||
children?: JSX.Element | ((open: boolean) => JSX.Element)
|
||||
triggerAs?: T
|
||||
triggerProps?: ComponentProps<T>
|
||||
gutter?: number
|
||||
}) {
|
||||
const [store, setStore] = createStore<{
|
||||
open: boolean
|
||||
@@ -175,7 +176,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
||||
}}
|
||||
modal={false}
|
||||
placement="top-start"
|
||||
gutter={8}
|
||||
gutter={props.gutter ?? 8}
|
||||
>
|
||||
<Kobalte.Trigger
|
||||
ref={(el) => setStore("trigger", el)}
|
||||
@@ -186,9 +187,8 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Content
|
||||
class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||
data-component="model-popover-content"
|
||||
ref={(el) => setStore("content", el)}
|
||||
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||
onEscapeKeyDown={(event) => {
|
||||
setStore("dismiss", "escape")
|
||||
setStore("open", false)
|
||||
|
||||
@@ -115,6 +115,7 @@ interface SlashCommand {
|
||||
description?: string
|
||||
keybind?: string
|
||||
type: "builtin" | "custom"
|
||||
source?: "command" | "mcp" | "skill"
|
||||
}
|
||||
|
||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
@@ -520,6 +521,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
title: cmd.name,
|
||||
description: cmd.description,
|
||||
type: "custom" as const,
|
||||
source: cmd.source,
|
||||
}))
|
||||
|
||||
return [...custom, ...builtin]
|
||||
@@ -1726,9 +1728,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Show when={cmd.type === "custom"}>
|
||||
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
|
||||
<span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
|
||||
{language.t("prompt.slash.badge.custom")}
|
||||
{cmd.source === "skill"
|
||||
? language.t("prompt.slash.badge.skill")
|
||||
: cmd.source === "mcp"
|
||||
? language.t("prompt.slash.badge.mcp")
|
||||
: language.t("prompt.slash.badge.custom")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={command.keybind(cmd.id)}>
|
||||
@@ -1937,6 +1943,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize"
|
||||
variant="ghost"
|
||||
gutter={12}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
<Show
|
||||
@@ -1951,13 +1958,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
as="div"
|
||||
variant="ghost"
|
||||
class="px-2"
|
||||
onClick={() => dialog.render(<DialogSelectModelUnpaid />, "select-model")}
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
||||
</Show>
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
<MorphChevron expanded={dialog.isActive("select-model")} />
|
||||
<MorphChevron
|
||||
expanded={!!dialog.active?.id && dialog.active.id.startsWith("select-model-unpaid")}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
@@ -1967,7 +1976,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }}>
|
||||
<ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }} gutter={12}>
|
||||
{(open) => (
|
||||
<>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
|
||||
@@ -64,8 +64,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
}
|
||||
|
||||
const circle = () => (
|
||||
<div class="text-icon-base">
|
||||
<ProgressCircle size={18} percentage={context()?.percentage ?? 0} />
|
||||
<div class="p-1">
|
||||
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -101,7 +101,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="size-7 text-icon-base"
|
||||
class="size-6"
|
||||
onClick={openContext}
|
||||
aria-label={language.t("context.usage.view")}
|
||||
>
|
||||
|
||||
@@ -167,7 +167,7 @@ export function SessionHeader() {
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "secondary",
|
||||
class: "rounded-sm w-[60px] h-[24px]",
|
||||
class: "rounded-sm h-[24px] px-3",
|
||||
classList: { "rounded-r-none": shareUrl() !== undefined },
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
|
||||
@@ -3,11 +3,12 @@ import type { JSX } from "solid-js"
|
||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useCommand } from "@/context/command"
|
||||
|
||||
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
|
||||
return (
|
||||
@@ -27,6 +28,7 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
|
||||
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const sortable = createSortable(props.tab)
|
||||
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||
return (
|
||||
@@ -36,7 +38,11 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
||||
<Tabs.Trigger
|
||||
value={props.tab}
|
||||
closeButton={
|
||||
<Tooltip value={language.t("common.closeTab")} placement="bottom">
|
||||
<TooltipKeybind
|
||||
title={language.t("common.closeTab")}
|
||||
keybind={command.keybind("tab.close")}
|
||||
placement="bottom"
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
@@ -44,7 +50,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
||||
onClick={() => props.onTabClose(props.tab)}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||
|
||||
@@ -5,12 +5,12 @@ import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings, monoFontFamily } from "@/context/settings"
|
||||
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "./link"
|
||||
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
|
||||
|
||||
let demoSoundState = {
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
@@ -171,6 +171,7 @@ export const SettingsGeneral: Component = () => {
|
||||
description={language.t("settings.general.row.appearance.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-color-scheme"
|
||||
options={colorSchemeOptions()}
|
||||
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
|
||||
value={(o) => o.value}
|
||||
@@ -197,6 +198,7 @@ export const SettingsGeneral: Component = () => {
|
||||
}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-theme"
|
||||
options={themeOptions()}
|
||||
current={themeOptions().find((o) => o.id === theme.themeId())}
|
||||
value={(o) => o.id}
|
||||
@@ -221,6 +223,7 @@ export const SettingsGeneral: Component = () => {
|
||||
description={language.t("settings.general.row.font.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-font"
|
||||
options={fontOptionsList}
|
||||
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
|
||||
value={(o) => o.value}
|
||||
@@ -229,7 +232,7 @@ export const SettingsGeneral: Component = () => {
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
|
||||
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }}
|
||||
>
|
||||
{(option) => (
|
||||
<span style={{ "font-family": monoFontFamily(option?.value) }}>
|
||||
@@ -250,30 +253,36 @@ export const SettingsGeneral: Component = () => {
|
||||
title={language.t("settings.general.notifications.agent.title")}
|
||||
description={language.t("settings.general.notifications.agent.description")}
|
||||
>
|
||||
<Switch
|
||||
checked={settings.notifications.agent()}
|
||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||
/>
|
||||
<div data-action="settings-notifications-agent">
|
||||
<Switch
|
||||
checked={settings.notifications.agent()}
|
||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.notifications.permissions.title")}
|
||||
description={language.t("settings.general.notifications.permissions.description")}
|
||||
>
|
||||
<Switch
|
||||
checked={settings.notifications.permissions()}
|
||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||
/>
|
||||
<div data-action="settings-notifications-permissions">
|
||||
<Switch
|
||||
checked={settings.notifications.permissions()}
|
||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.notifications.errors.title")}
|
||||
description={language.t("settings.general.notifications.errors.description")}
|
||||
>
|
||||
<Switch
|
||||
checked={settings.notifications.errors()}
|
||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||
/>
|
||||
<div data-action="settings-notifications-errors">
|
||||
<Switch
|
||||
checked={settings.notifications.errors()}
|
||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
@@ -288,6 +297,7 @@ export const SettingsGeneral: Component = () => {
|
||||
description={language.t("settings.general.sounds.agent.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-sounds-agent"
|
||||
options={soundOptions}
|
||||
current={soundOptions.find((o) => o.id === settings.sounds.agent())}
|
||||
value={(o) => o.id}
|
||||
@@ -312,6 +322,7 @@ export const SettingsGeneral: Component = () => {
|
||||
description={language.t("settings.general.sounds.permissions.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-sounds-permissions"
|
||||
options={soundOptions}
|
||||
current={soundOptions.find((o) => o.id === settings.sounds.permissions())}
|
||||
value={(o) => o.id}
|
||||
@@ -336,6 +347,7 @@ export const SettingsGeneral: Component = () => {
|
||||
description={language.t("settings.general.sounds.errors.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-sounds-errors"
|
||||
options={soundOptions}
|
||||
current={soundOptions.find((o) => o.id === settings.sounds.errors())}
|
||||
value={(o) => o.id}
|
||||
@@ -366,21 +378,25 @@ export const SettingsGeneral: Component = () => {
|
||||
title={language.t("settings.updates.row.startup.title")}
|
||||
description={language.t("settings.updates.row.startup.description")}
|
||||
>
|
||||
<Switch
|
||||
checked={settings.updates.startup()}
|
||||
disabled={!platform.checkUpdate}
|
||||
onChange={(checked) => settings.updates.setStartup(checked)}
|
||||
/>
|
||||
<div data-action="settings-updates-startup">
|
||||
<Switch
|
||||
checked={settings.updates.startup()}
|
||||
disabled={!platform.checkUpdate}
|
||||
onChange={(checked) => settings.updates.setStartup(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.releaseNotes.title")}
|
||||
description={language.t("settings.general.row.releaseNotes.description")}
|
||||
>
|
||||
<Switch
|
||||
checked={settings.general.releaseNotes()}
|
||||
onChange={(checked) => settings.general.setReleaseNotes(checked)}
|
||||
/>
|
||||
<div data-action="settings-release-notes">
|
||||
<Switch
|
||||
checked={settings.general.releaseNotes()}
|
||||
onChange={(checked) => settings.general.setReleaseNotes(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
|
||||
@@ -5,11 +5,11 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
const PALETTE_ID = "command.palette"
|
||||
@@ -402,6 +402,7 @@ export const SettingsKeybinds: Component = () => {
|
||||
<span class="text-14-regular text-text-strong">{title(id)}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-keybind-id={id}
|
||||
classList={{
|
||||
"h-8 px-3 rounded-md text-12-regular": true,
|
||||
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
|
||||
|
||||
@@ -9,6 +9,7 @@ import { type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useModels } from "@/context/models"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
|
||||
|
||||
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
||||
|
||||
@@ -39,7 +40,12 @@ export const SettingsModels: Component = () => {
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<ScrollFade
|
||||
direction="vertical"
|
||||
fadeStartSize={0}
|
||||
fadeEndSize={16}
|
||||
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
|
||||
>
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
|
||||
@@ -125,6 +131,6 @@ export const SettingsModels: Component = () => {
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollFade>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useGlobalSync } from "@/context/global-sync"
|
||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
|
||||
|
||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||
type ProviderMeta = { source?: ProviderSource }
|
||||
@@ -115,7 +116,12 @@ export const SettingsProviders: Component = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<ScrollFade
|
||||
direction="vertical"
|
||||
fadeStartSize={0}
|
||||
fadeEndSize={16}
|
||||
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
|
||||
>
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
|
||||
@@ -123,7 +129,7 @@ export const SettingsProviders: Component = () => {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 max-w-[720px]">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-col gap-1" data-component="connected-providers-section">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<Show
|
||||
@@ -225,7 +231,10 @@ export const SettingsProviders: Component = () => {
|
||||
)}
|
||||
</For>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 h-16 border-b border-border-weak-base last:border-none">
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 h-16 border-b border-border-weak-base last:border-none"
|
||||
data-component="custom-provider-section"
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<ProviderIcon id={icon("synthetic")} class="size-5 shrink-0 icon-strong-base" />
|
||||
@@ -258,6 +267,6 @@ export const SettingsProviders: Component = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollFade>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ export function Titlebar() {
|
||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||
const web = createMemo(() => platform.platform === "web")
|
||||
const zoom = () => platform.webviewZoom?.() ?? 1
|
||||
const minHeight = () => (mac() ? `${40 / zoom()}px` : undefined)
|
||||
|
||||
const [history, setHistory] = createStore({
|
||||
stack: [] as string[],
|
||||
@@ -134,6 +136,7 @@ export function Titlebar() {
|
||||
return (
|
||||
<header
|
||||
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
||||
style={{ "min-height": minHeight() }}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div
|
||||
@@ -145,7 +148,7 @@ export function Titlebar() {
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<Show when={mac()}>
|
||||
<div class="w-[72px] h-full shrink-0" data-tauri-drag-region />
|
||||
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} data-tauri-drag-region />
|
||||
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
|
||||
<IconButton
|
||||
icon="menu"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||
import type { Accessor } from "solid-js"
|
||||
|
||||
export type Platform = {
|
||||
/** Platform discriminator */
|
||||
@@ -55,6 +56,9 @@ export type Platform = {
|
||||
|
||||
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
|
||||
parseMarkdown?(markdown: string): Promise<string>
|
||||
|
||||
/** Webview zoom level (desktop only) */
|
||||
webviewZoom?: Accessor<number>
|
||||
}
|
||||
|
||||
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "فتح الإعدادات",
|
||||
"command.session.previous": "الجلسة السابقة",
|
||||
"command.session.next": "الجلسة التالية",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "أرشفة الجلسة",
|
||||
|
||||
"command.palette": "لوحة الأوامر",
|
||||
@@ -210,6 +212,8 @@ export const dict = {
|
||||
"prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
|
||||
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا",
|
||||
"prompt.slash.badge.custom": "مخصص",
|
||||
"prompt.slash.badge.skill": "مهارة",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
"prompt.context.active": "نشط",
|
||||
"prompt.context.includeActiveFile": "تضمين الملف النشط",
|
||||
"prompt.context.removeActiveFile": "إزالة الملف النشط من السياق",
|
||||
@@ -432,6 +436,7 @@ export const dict = {
|
||||
"session.review.noChanges": "لا توجد تغييرات",
|
||||
"session.files.selectToOpen": "اختر ملفًا لفتحه",
|
||||
"session.files.all": "كل الملفات",
|
||||
"session.files.binaryContent": "ملف ثنائي (لا يمكن عرض المحتوى)",
|
||||
"session.messages.renderEarlier": "عرض الرسائل السابقة",
|
||||
"session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...",
|
||||
"session.messages.loadEarlier": "تحميل الرسائل السابقة",
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "Abrir configurações",
|
||||
"command.session.previous": "Sessão anterior",
|
||||
"command.session.next": "Próxima sessão",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Arquivar sessão",
|
||||
|
||||
"command.palette": "Paleta de comandos",
|
||||
@@ -210,6 +212,8 @@ export const dict = {
|
||||
"prompt.popover.emptyCommands": "Nenhum comando correspondente",
|
||||
"prompt.dropzone.label": "Solte imagens ou PDFs aqui",
|
||||
"prompt.slash.badge.custom": "personalizado",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
"prompt.context.active": "ativo",
|
||||
"prompt.context.includeActiveFile": "Incluir arquivo ativo",
|
||||
"prompt.context.removeActiveFile": "Remover arquivo ativo do contexto",
|
||||
@@ -433,6 +437,7 @@ export const dict = {
|
||||
"session.review.noChanges": "Sem alterações",
|
||||
"session.files.selectToOpen": "Selecione um arquivo para abrir",
|
||||
"session.files.all": "Todos os arquivos",
|
||||
"session.files.binaryContent": "Arquivo binário (conteúdo não pode ser exibido)",
|
||||
"session.messages.renderEarlier": "Renderizar mensagens anteriores",
|
||||
"session.messages.loadingEarlier": "Carregando mensagens anteriores...",
|
||||
"session.messages.loadEarlier": "Carregar mensagens anteriores",
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "Åbn indstillinger",
|
||||
"command.session.previous": "Forrige session",
|
||||
"command.session.next": "Næste session",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Arkivér session",
|
||||
|
||||
"command.palette": "Kommandopalette",
|
||||
@@ -210,6 +212,8 @@ export const dict = {
|
||||
"prompt.popover.emptyCommands": "Ingen matchende kommandoer",
|
||||
"prompt.dropzone.label": "Slip billeder eller PDF'er her",
|
||||
"prompt.slash.badge.custom": "brugerdefineret",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
"prompt.context.active": "aktiv",
|
||||
"prompt.context.includeActiveFile": "Inkluder aktiv fil",
|
||||
"prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst",
|
||||
@@ -434,6 +438,7 @@ export const dict = {
|
||||
"session.review.noChanges": "Ingen ændringer",
|
||||
"session.files.selectToOpen": "Vælg en fil at åbne",
|
||||
"session.files.all": "Alle filer",
|
||||
"session.files.binaryContent": "Binær fil (indhold kan ikke vises)",
|
||||
"session.messages.renderEarlier": "Vis tidligere beskeder",
|
||||
"session.messages.loadingEarlier": "Indlæser tidligere beskeder...",
|
||||
"session.messages.loadEarlier": "Indlæs tidligere beskeder",
|
||||
|
||||
@@ -32,6 +32,8 @@ export const dict = {
|
||||
"command.settings.open": "Einstellungen öffnen",
|
||||
"command.session.previous": "Vorherige Sitzung",
|
||||
"command.session.next": "Nächste Sitzung",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Sitzung archivieren",
|
||||
|
||||
"command.palette": "Befehlspalette",
|
||||
@@ -214,6 +216,8 @@ export const dict = {
|
||||
"prompt.popover.emptyCommands": "Keine passenden Befehle",
|
||||
"prompt.dropzone.label": "Bilder oder PDFs hier ablegen",
|
||||
"prompt.slash.badge.custom": "benutzerdefiniert",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
"prompt.context.active": "aktiv",
|
||||
"prompt.context.includeActiveFile": "Aktive Datei einbeziehen",
|
||||
"prompt.context.removeActiveFile": "Aktive Datei aus dem Kontext entfernen",
|
||||
@@ -442,6 +446,7 @@ export const dict = {
|
||||
"session.review.noChanges": "Keine Änderungen",
|
||||
"session.files.selectToOpen": "Datei zum Öffnen auswählen",
|
||||
"session.files.all": "Alle Dateien",
|
||||
"session.files.binaryContent": "Binärdatei (Inhalt kann nicht angezeigt werden)",
|
||||
"session.messages.renderEarlier": "Frühere Nachrichten rendern",
|
||||
"session.messages.loadingEarlier": "Lade frühere Nachrichten...",
|
||||
"session.messages.loadEarlier": "Frühere Nachrichten laden",
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "Open settings",
|
||||
"command.session.previous": "Previous session",
|
||||
"command.session.next": "Next session",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Archive session",
|
||||
|
||||
"command.palette": "Command palette",
|
||||
@@ -43,6 +45,7 @@ export const dict = {
|
||||
"command.session.new": "New session",
|
||||
"command.file.open": "Open file",
|
||||
"command.file.open.description": "Search files and commands",
|
||||
"command.tab.close": "Close tab",
|
||||
"command.context.addSelection": "Add selection to context",
|
||||
"command.context.addSelection.description": "Add selected lines from the current file",
|
||||
"command.terminal.toggle": "Toggle terminal",
|
||||
@@ -216,6 +219,8 @@ export const dict = {
|
||||
"prompt.popover.emptyCommands": "No matching commands",
|
||||
"prompt.dropzone.label": "Drop images or PDFs here",
|
||||
"prompt.slash.badge.custom": "custom",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
"prompt.context.active": "active",
|
||||
"prompt.context.includeActiveFile": "Include active file",
|
||||
"prompt.context.removeActiveFile": "Remove active file from context",
|
||||
@@ -441,6 +446,7 @@ export const dict = {
|
||||
|
||||
"session.files.selectToOpen": "Select a file to open",
|
||||
"session.files.all": "All files",
|
||||
"session.files.binaryContent": "Binary file (content cannot be displayed)",
|
||||
|
||||
"session.messages.renderEarlier": "Render earlier messages",
|
||||
"session.messages.loadingEarlier": "Loading earlier messages...",
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "Abrir ajustes",
|
||||
"command.session.previous": "Sesión anterior",
|
||||
"command.session.next": "Siguiente sesión",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Archivar sesión",
|
||||
|
||||
"command.palette": "Paleta de comandos",
|
||||
@@ -210,6 +212,8 @@ export const dict = {
|
||||
"prompt.popover.emptyCommands": "Sin comandos coincidentes",
|
||||
"prompt.dropzone.label": "Suelta imágenes o PDFs aquí",
|
||||
"prompt.slash.badge.custom": "personalizado",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
"prompt.context.active": "activo",
|
||||
"prompt.context.includeActiveFile": "Incluir archivo activo",
|
||||
"prompt.context.removeActiveFile": "Eliminar archivo activo del contexto",
|
||||
@@ -436,6 +440,7 @@ export const dict = {
|
||||
"session.review.noChanges": "Sin cambios",
|
||||
"session.files.selectToOpen": "Selecciona un archivo para abrir",
|
||||
"session.files.all": "Todos los archivos",
|
||||
"session.files.binaryContent": "Archivo binario (el contenido no puede ser mostrado)",
|
||||
"session.messages.renderEarlier": "Renderizar mensajes anteriores",
|
||||
"session.messages.loadingEarlier": "Cargando mensajes anteriores...",
|
||||
"session.messages.loadEarlier": "Cargar mensajes anteriores",
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "Ouvrir les paramètres",
|
||||
"command.session.previous": "Session précédente",
|
||||
"command.session.next": "Session suivante",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Archiver la session",
|
||||
|
||||
"command.palette": "Palette de commandes",
|
||||
@@ -210,6 +212,8 @@ export const dict = {
|
||||
"prompt.popover.emptyCommands": "Aucune commande correspondante",
|
||||
"prompt.dropzone.label": "Déposez des images ou des PDF ici",
|
||||
"prompt.slash.badge.custom": "personnalisé",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
"prompt.context.active": "actif",
|
||||
"prompt.context.includeActiveFile": "Inclure le fichier actif",
|
||||
"prompt.context.removeActiveFile": "Retirer le fichier actif du contexte",
|
||||
@@ -441,6 +445,7 @@ export const dict = {
|
||||
"session.review.noChanges": "Aucune modification",
|
||||
"session.files.selectToOpen": "Sélectionnez un fichier à ouvrir",
|
||||
"session.files.all": "Tous les fichiers",
|
||||
"session.files.binaryContent": "Fichier binaire (le contenu ne peut pas être affiché)",
|
||||
"session.messages.renderEarlier": "Afficher les messages précédents",
|
||||
"session.messages.loadingEarlier": "Chargement des messages précédents...",
|
||||
"session.messages.loadEarlier": "Charger les messages précédents",
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "設定を開く",
|
||||
"command.session.previous": "前のセッション",
|
||||
"command.session.next": "次のセッション",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "セッションをアーカイブ",
|
||||
|
||||
"command.palette": "コマンドパレット",
|
||||
@@ -209,6 +211,8 @@ export const dict = {
|
||||
"prompt.popover.emptyCommands": "一致するコマンドがありません",
|
||||
"prompt.dropzone.label": "画像またはPDFをここにドロップ",
|
||||
"prompt.slash.badge.custom": "カスタム",
|
||||
"prompt.slash.badge.skill": "スキル",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
"prompt.context.active": "アクティブ",
|
||||
"prompt.context.includeActiveFile": "アクティブなファイルを含める",
|
||||
"prompt.context.removeActiveFile": "コンテキストからアクティブなファイルを削除",
|
||||
@@ -433,6 +437,7 @@ export const dict = {
|
||||
"session.review.noChanges": "変更なし",
|
||||
"session.files.selectToOpen": "開くファイルを選択",
|
||||
"session.files.all": "すべてのファイル",
|
||||
"session.files.binaryContent": "バイナリファイル(内容を表示できません)",
|
||||
"session.messages.renderEarlier": "以前のメッセージを表示",
|
||||
"session.messages.loadingEarlier": "以前のメッセージを読み込み中...",
|
||||
"session.messages.loadEarlier": "以前のメッセージを読み込む",
|
||||
|
||||
@@ -32,6 +32,8 @@ export const dict = {
|
||||
"command.settings.open": "설정 열기",
|
||||
"command.session.previous": "이전 세션",
|
||||
"command.session.next": "다음 세션",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "세션 보관",
|
||||
|
||||
"command.palette": "명령 팔레트",
|
||||
@@ -213,6 +215,8 @@ export const dict = {
|
||||
"prompt.popover.emptyCommands": "일치하는 명령어 없음",
|
||||
"prompt.dropzone.label": "이미지나 PDF를 여기에 드롭하세요",
|
||||
"prompt.slash.badge.custom": "사용자 지정",
|
||||
"prompt.slash.badge.skill": "스킬",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
"prompt.context.active": "활성",
|
||||
"prompt.context.includeActiveFile": "활성 파일 포함",
|
||||
"prompt.context.removeActiveFile": "컨텍스트에서 활성 파일 제거",
|
||||
@@ -435,6 +439,7 @@ export const dict = {
|
||||
"session.review.noChanges": "변경 없음",
|
||||
"session.files.selectToOpen": "열 파일을 선택하세요",
|
||||
"session.files.all": "모든 파일",
|
||||
"session.files.binaryContent": "바이너리 파일 (내용을 표시할 수 없음)",
|
||||
"session.messages.renderEarlier": "이전 메시지 렌더링",
|
||||
"session.messages.loadingEarlier": "이전 메시지 로드 중...",
|
||||
"session.messages.loadEarlier": "이전 메시지 로드",
|
||||
|
||||
@@ -31,6 +31,8 @@ export const dict = {
|
||||
"command.settings.open": "Åpne innstillinger",
|
||||
"command.session.previous": "Forrige sesjon",
|
||||
"command.session.next": "Neste sesjon",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Arkiver sesjon",
|
||||
|
||||
"command.palette": "Kommandopalett",
|
||||
@@ -213,6 +215,8 @@ export const dict = {
|
||||
"prompt.popover.emptyCommands": "Ingen matchende kommandoer",
|
||||
"prompt.dropzone.label": "Slipp bilder eller PDF-er her",
|
||||
"prompt.slash.badge.custom": "egendefinert",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
"prompt.context.active": "aktiv",
|
||||
"prompt.context.includeActiveFile": "Inkluder aktiv fil",
|
||||
"prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst",
|
||||
@@ -436,6 +440,7 @@ export const dict = {
|
||||
"session.review.noChanges": "Ingen endringer",
|
||||
"session.files.selectToOpen": "Velg en fil å åpne",
|
||||
"session.files.all": "Alle filer",
|
||||
"session.files.binaryContent": "Binær fil (innhold kan ikke vises)",
|
||||
"session.messages.renderEarlier": "Vis tidligere meldinger",
|
||||
"session.messages.loadingEarlier": "Laster inn tidligere meldinger...",
|
||||
"session.messages.loadEarlier": "Last inn tidligere meldinger",
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "Otwórz ustawienia",
|
||||
"command.session.previous": "Poprzednia sesja",
|
||||
"command.session.next": "Następna sesja",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Zarchiwizuj sesję",
|
||||
|
||||
"command.palette": "Paleta poleceń",
|
||||
@@ -210,6 +212,8 @@ export const dict = {
|
||||
"prompt.popover.emptyCommands": "Brak pasujących poleceń",
|
||||
"prompt.dropzone.label": "Upuść obrazy lub pliki PDF tutaj",
|
||||
"prompt.slash.badge.custom": "własne",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
"prompt.context.active": "aktywny",
|
||||
"prompt.context.includeActiveFile": "Dołącz aktywny plik",
|
||||
"prompt.context.removeActiveFile": "Usuń aktywny plik z kontekstu",
|
||||
@@ -435,6 +439,7 @@ export const dict = {
|
||||
"session.review.noChanges": "Brak zmian",
|
||||
"session.files.selectToOpen": "Wybierz plik do otwarcia",
|
||||
"session.files.all": "Wszystkie pliki",
|
||||
"session.files.binaryContent": "Plik binarny (zawartość nie może być wyświetlona)",
|
||||
"session.messages.renderEarlier": "Renderuj wcześniejsze wiadomości",
|
||||
"session.messages.loadingEarlier": "Ładowanie wcześniejszych wiadomości...",
|
||||
"session.messages.loadEarlier": "Załaduj wcześniejsze wiadomości",
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "Открыть настройки",
|
||||
"command.session.previous": "Предыдущая сессия",
|
||||
"command.session.next": "Следующая сессия",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Архивировать сессию",
|
||||
|
||||
"command.palette": "Палитра команд",
|
||||
@@ -210,6 +212,8 @@ export const dict = {
|
||||
"prompt.popover.emptyCommands": "Нет совпадающих команд",
|
||||
"prompt.dropzone.label": "Перетащите изображения или PDF сюда",
|
||||
"prompt.slash.badge.custom": "своё",
|
||||
"prompt.slash.badge.skill": "навык",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
"prompt.context.active": "активно",
|
||||
"prompt.context.includeActiveFile": "Включить активный файл",
|
||||
"prompt.context.removeActiveFile": "Удалить активный файл из контекста",
|
||||
@@ -437,6 +441,7 @@ export const dict = {
|
||||
"session.review.noChanges": "Нет изменений",
|
||||
"session.files.selectToOpen": "Выберите файл, чтобы открыть",
|
||||
"session.files.all": "Все файлы",
|
||||
"session.files.binaryContent": "Двоичный файл (содержимое не может быть отображено)",
|
||||
"session.messages.renderEarlier": "Показать предыдущие сообщения",
|
||||
"session.messages.loadingEarlier": "Загрузка предыдущих сообщений...",
|
||||
"session.messages.loadEarlier": "Загрузить предыдущие сообщения",
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "เปิดการตั้งค่า",
|
||||
"command.session.previous": "เซสชันก่อนหน้า",
|
||||
"command.session.next": "เซสชันถัดไป",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "จัดเก็บเซสชัน",
|
||||
|
||||
"command.palette": "คำสั่งค้นหา",
|
||||
@@ -215,6 +217,8 @@ export const dict = {
|
||||
"prompt.popover.emptyCommands": "ไม่พบคำสั่งที่ตรงกัน",
|
||||
"prompt.dropzone.label": "วางรูปภาพหรือ PDF ที่นี่",
|
||||
"prompt.slash.badge.custom": "กำหนดเอง",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
"prompt.context.active": "ใช้งานอยู่",
|
||||
"prompt.context.includeActiveFile": "รวมไฟล์ที่ใช้งานอยู่",
|
||||
"prompt.context.removeActiveFile": "เอาไฟล์ที่ใช้งานอยู่ออกจากบริบท",
|
||||
@@ -322,20 +326,20 @@ export const dict = {
|
||||
"context.usage.clickToView": "คลิกเพื่อดูบริบท",
|
||||
"context.usage.view": "ดูการใช้บริบท",
|
||||
|
||||
"language.en": "อังกฤษ",
|
||||
"language.zh": "จีนตัวย่อ",
|
||||
"language.zht": "จีนตัวเต็ม",
|
||||
"language.ko": "เกาหลี",
|
||||
"language.de": "เยอรมัน",
|
||||
"language.es": "สเปน",
|
||||
"language.fr": "ฝรั่งเศส",
|
||||
"language.da": "เดนมาร์ก",
|
||||
"language.ja": "ญี่ปุ่น",
|
||||
"language.pl": "โปแลนด์",
|
||||
"language.ru": "รัสเซีย",
|
||||
"language.ar": "อาหรับ",
|
||||
"language.no": "นอร์เวย์",
|
||||
"language.br": "โปรตุเกส (บราซิล)",
|
||||
"language.en": "English",
|
||||
"language.zh": "简体中文",
|
||||
"language.zht": "繁體中文",
|
||||
"language.ko": "한국어",
|
||||
"language.de": "Deutsch",
|
||||
"language.es": "Español",
|
||||
"language.fr": "Français",
|
||||
"language.da": "Dansk",
|
||||
"language.ja": "日本語",
|
||||
"language.pl": "Polski",
|
||||
"language.ru": "Русский",
|
||||
"language.ar": "العربية",
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.th": "ไทย",
|
||||
|
||||
"toast.language.title": "ภาษา",
|
||||
@@ -438,6 +442,7 @@ export const dict = {
|
||||
|
||||
"session.files.selectToOpen": "เลือกไฟล์เพื่อเปิด",
|
||||
"session.files.all": "ไฟล์ทั้งหมด",
|
||||
"session.files.binaryContent": "ไฟล์ไบนารี (ไม่สามารถแสดงเนื้อหาได้)",
|
||||
|
||||
"session.messages.renderEarlier": "แสดงข้อความก่อนหน้า",
|
||||
"session.messages.loadingEarlier": "กำลังโหลดข้อความก่อนหน้า...",
|
||||
|
||||
@@ -32,6 +32,8 @@ export const dict = {
|
||||
"command.settings.open": "打开设置",
|
||||
"command.session.previous": "上一个会话",
|
||||
"command.session.next": "下一个会话",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "归档会话",
|
||||
|
||||
"command.palette": "命令面板",
|
||||
@@ -214,6 +216,8 @@ export const dict = {
|
||||
"prompt.popover.emptyCommands": "没有匹配的命令",
|
||||
"prompt.dropzone.label": "将图片或 PDF 拖到这里",
|
||||
"prompt.slash.badge.custom": "自定义",
|
||||
"prompt.slash.badge.skill": "技能",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
"prompt.context.active": "当前",
|
||||
"prompt.context.includeActiveFile": "包含当前文件",
|
||||
"prompt.context.removeActiveFile": "从上下文移除活动文件",
|
||||
@@ -434,6 +438,7 @@ export const dict = {
|
||||
"session.review.noChanges": "无更改",
|
||||
"session.files.selectToOpen": "选择要打开的文件",
|
||||
"session.files.all": "所有文件",
|
||||
"session.files.binaryContent": "二进制文件(无法显示内容)",
|
||||
"session.messages.renderEarlier": "显示更早的消息",
|
||||
"session.messages.loadingEarlier": "正在加载更早的消息...",
|
||||
"session.messages.loadEarlier": "加载更早的消息",
|
||||
|
||||
@@ -32,6 +32,8 @@ export const dict = {
|
||||
"command.settings.open": "開啟設定",
|
||||
"command.session.previous": "上一個工作階段",
|
||||
"command.session.next": "下一個工作階段",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "封存工作階段",
|
||||
|
||||
"command.palette": "命令面板",
|
||||
@@ -211,6 +213,8 @@ export const dict = {
|
||||
"prompt.popover.emptyCommands": "沒有符合的命令",
|
||||
"prompt.dropzone.label": "將圖片或 PDF 拖到這裡",
|
||||
"prompt.slash.badge.custom": "自訂",
|
||||
"prompt.slash.badge.skill": "技能",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
"prompt.context.active": "作用中",
|
||||
"prompt.context.includeActiveFile": "包含作用中檔案",
|
||||
"prompt.context.removeActiveFile": "從上下文移除目前檔案",
|
||||
@@ -431,6 +435,7 @@ export const dict = {
|
||||
"session.review.noChanges": "沒有變更",
|
||||
"session.files.selectToOpen": "選取要開啟的檔案",
|
||||
"session.files.all": "所有檔案",
|
||||
"session.files.binaryContent": "二進位檔案(無法顯示內容)",
|
||||
"session.messages.renderEarlier": "顯示更早的訊息",
|
||||
"session.messages.loadingEarlier": "正在載入更早的訊息...",
|
||||
"session.messages.loadEarlier": "載入更早的訊息",
|
||||
|
||||
@@ -886,6 +886,52 @@ export default function Layout(props: ParentProps) {
|
||||
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
|
||||
}
|
||||
|
||||
function navigateSessionByUnseen(offset: number) {
|
||||
const sessions = currentSessions()
|
||||
if (sessions.length === 0) return
|
||||
|
||||
const hasUnseen = sessions.some((session) => notification.session.unseen(session.id).length > 0)
|
||||
if (!hasUnseen) return
|
||||
|
||||
const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
|
||||
const start = activeIndex === -1 ? (offset > 0 ? -1 : 0) : activeIndex
|
||||
|
||||
for (let i = 1; i <= sessions.length; i++) {
|
||||
const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length
|
||||
const session = sessions[index]
|
||||
if (!session) continue
|
||||
if (notification.session.unseen(session.id).length === 0) continue
|
||||
|
||||
prefetchSession(session, "high")
|
||||
|
||||
const next = sessions[(index + 1) % sessions.length]
|
||||
const prev = sessions[(index - 1 + sessions.length) % sessions.length]
|
||||
|
||||
if (offset > 0) {
|
||||
if (next) prefetchSession(next, "high")
|
||||
if (prev) prefetchSession(prev)
|
||||
}
|
||||
|
||||
if (offset < 0) {
|
||||
if (prev) prefetchSession(prev, "high")
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveSession(session: Session) {
|
||||
const [store, setStore] = globalSync.child(session.directory)
|
||||
const sessions = store.session ?? []
|
||||
@@ -1024,6 +1070,20 @@ export default function Layout(props: ParentProps) {
|
||||
keybind: "alt+arrowdown",
|
||||
onSelect: () => navigateSessionByOffset(1),
|
||||
},
|
||||
{
|
||||
id: "session.previous.unseen",
|
||||
title: language.t("command.session.previous.unseen"),
|
||||
category: language.t("command.category.session"),
|
||||
keybind: "shift+alt+arrowup",
|
||||
onSelect: () => navigateSessionByUnseen(-1),
|
||||
},
|
||||
{
|
||||
id: "session.next.unseen",
|
||||
title: language.t("command.session.next.unseen"),
|
||||
category: language.t("command.category.session"),
|
||||
keybind: "shift+alt+arrowdown",
|
||||
onSelect: () => navigateSessionByUnseen(1),
|
||||
},
|
||||
{
|
||||
id: "session.archive",
|
||||
title: language.t("command.session.archive"),
|
||||
@@ -2114,12 +2174,20 @@ export default function Layout(props: ParentProps) {
|
||||
>
|
||||
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
|
||||
<div class="px-2 py-1">
|
||||
<div class="group/workspace relative">
|
||||
<div
|
||||
class="group/workspace relative"
|
||||
data-component="workspace-item"
|
||||
data-workspace={base64Encode(props.directory)}
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<Show
|
||||
when={workspaceEditActive()}
|
||||
fallback={
|
||||
<Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
|
||||
<Collapsible.Trigger
|
||||
class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover"
|
||||
data-action="workspace-toggle"
|
||||
data-workspace={base64Encode(props.directory)}
|
||||
>
|
||||
{header()}
|
||||
</Collapsible.Trigger>
|
||||
}
|
||||
@@ -2146,6 +2214,8 @@ export default function Layout(props: ParentProps) {
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md"
|
||||
data-action="workspace-menu"
|
||||
data-workspace={base64Encode(props.directory)}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -2285,6 +2355,8 @@ export default function Layout(props: ParentProps) {
|
||||
<button
|
||||
type="button"
|
||||
aria-label={projectName()}
|
||||
data-action="project-switch"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
classList={{
|
||||
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
|
||||
@@ -2335,6 +2407,8 @@ export default function Layout(props: ParentProps) {
|
||||
icon="circle-x"
|
||||
variant="ghost"
|
||||
class="shrink-0"
|
||||
data-action="project-close-hover"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
aria-label={language.t("common.close")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
@@ -2577,6 +2651,8 @@ export default function Layout(props: ParentProps) {
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
data-action="project-menu"
|
||||
data-project={base64Encode(p.worktree)}
|
||||
class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
@@ -2586,6 +2662,8 @@ export default function Layout(props: ParentProps) {
|
||||
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
data-action="project-workspaces-toggle"
|
||||
data-project={base64Encode(p.worktree)}
|
||||
disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
|
||||
onSelect={() => {
|
||||
const enabled = layout.sidebar.workspaces(p.worktree)()
|
||||
@@ -2604,7 +2682,11 @@ export default function Layout(props: ParentProps) {
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={() => closeProject(p.worktree)}>
|
||||
<DropdownMenu.Item
|
||||
data-action="project-close-menu"
|
||||
data-project={base64Encode(p.worktree)}
|
||||
onSelect={() => closeProject(p.worktree)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
@@ -2814,6 +2896,7 @@ export default function Layout(props: ParentProps) {
|
||||
<div class="flex-1 min-h-0 flex">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"hidden xl:block": true,
|
||||
"relative shrink-0": true,
|
||||
@@ -2873,6 +2956,7 @@ export default function Layout(props: ParentProps) {
|
||||
/>
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
classList={{
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-72 bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user